implement snippet tabstop support

Pascal Kuthe 3 months ago
parent c30298a44b
commit 87fd778c81
No known key found for this signature in database
GPG Key ID: D715E8655AE166A6

@ -2,7 +2,6 @@ mod client;
pub mod file_event;
mod file_operations;
pub mod jsonrpc;
pub mod snippet;
mod transport;
use arc_swap::ArcSwap;
@ -65,7 +64,8 @@ pub enum OffsetEncoding {
pub mod util {
use super::*;
use helix_core::line_ending::{line_end_byte_index, line_end_char_index};
use helix_core::{chars, RopeSlice, SmallVec};
use helix_core::snippets::{RenderedSnippet, Snippet, SnippetRenderCtx};
use helix_core::{chars, RopeSlice};
use helix_core::{diagnostic::NumberOrString, Range, Rope, Selection, Tendril, Transaction};
/// Converts a diagnostic in the document to [`lsp::Diagnostic`].
@ -354,25 +354,17 @@ pub mod util {
transaction.with_selection(selection)
}
/// Creates a [Transaction] from the [snippet::Snippet] in a completion response.
/// Creates a [Transaction] from the [Snippet](helix_core::snippets::Snippet) in a completion response.
/// The transaction applies the edit to all cursors.
#[allow(clippy::too_many_arguments)]
pub fn generate_transaction_from_snippet(
doc: &Rope,
selection: &Selection,
edit_offset: Option<(i128, i128)>,
replace_mode: bool,
snippet: snippet::Snippet,
line_ending: &str,
include_placeholder: bool,
tab_width: usize,
indent_width: usize,
) -> Transaction {
snippet: Snippet,
cx: &mut SnippetRenderCtx,
) -> (Transaction, RenderedSnippet) {
let text = doc.slice(..);
let mut off = 0i128;
let mut mapped_doc = doc.clone();
let mut selection_tabstops: SmallVec<[_; 1]> = SmallVec::new();
let (removed_start, removed_end) = completion_range(
text,
edit_offset,
@ -381,8 +373,7 @@ pub mod util {
)
.expect("transaction must be valid for primary selection");
let removed_text = text.slice(removed_start..removed_end);
let (transaction, mut selection) = Transaction::change_by_selection_ignore_overlapping(
let (transaction, mapped_selection, snippet) = snippet.render(
doc,
selection,
|range| {
@ -391,108 +382,15 @@ pub mod util {
.filter(|(start, end)| text.slice(start..end) == removed_text)
.unwrap_or_else(|| find_completion_range(text, replace_mode, cursor))
},
|replacement_start, replacement_end| {
let mapped_replacement_start = (replacement_start as i128 + off) as usize;
let mapped_replacement_end = (replacement_end as i128 + off) as usize;
let line_idx = mapped_doc.char_to_line(mapped_replacement_start);
let indent_level = helix_core::indent::indent_level_for_line(
mapped_doc.line(line_idx),
tab_width,
indent_width,
) * indent_width;
let newline_with_offset = format!(
"{line_ending}{blank:indent_level$}",
line_ending = line_ending,
blank = ""
);
let (replacement, tabstops) =
snippet::render(&snippet, &newline_with_offset, include_placeholder);
selection_tabstops.push((mapped_replacement_start, tabstops));
mapped_doc.remove(mapped_replacement_start..mapped_replacement_end);
mapped_doc.insert(mapped_replacement_start, &replacement);
off +=
replacement_start as i128 - replacement_end as i128 + replacement.len() as i128;
Some(replacement)
},
cx,
);
let changes = transaction.changes();
if changes.is_empty() {
return transaction;
}
// Don't normalize to avoid merging/reording selections which would
// break the association between tabstops and selections. Most ranges
// will be replaced by tabstops anyways and the final selection will be
// normalized anyways
selection = selection.map_no_normalize(changes);
let mut mapped_selection = SmallVec::with_capacity(selection.len());
let mut mapped_primary_idx = 0;
let primary_range = selection.primary();
for (range, (tabstop_anchor, tabstops)) in selection.into_iter().zip(selection_tabstops) {
if range == primary_range {
mapped_primary_idx = mapped_selection.len()
}
let tabstops = tabstops.first().filter(|tabstops| !tabstops.is_empty());
let Some(tabstops) = tabstops else {
// no tabstop normal mapping
mapped_selection.push(range);
continue;
};
// expand the selection to cover the tabstop to retain the helix selection semantic
// the tabstop closest to the range simply replaces `head` while anchor remains in place
// the remaining tabstops receive their own single-width cursor
if range.head < range.anchor {
let last_idx = tabstops.len() - 1;
let last_tabstop = tabstop_anchor + tabstops[last_idx].0;
// if selection is forward but was moved to the right it is
// contained entirely in the replacement text, just do a point
// selection (fallback below)
if range.anchor > last_tabstop {
let range = Range::new(range.anchor, last_tabstop);
mapped_selection.push(range);
let rem_tabstops = tabstops[..last_idx]
.iter()
.map(|tabstop| Range::point(tabstop_anchor + tabstop.0));
mapped_selection.extend(rem_tabstops);
continue;
}
} else {
let first_tabstop = tabstop_anchor + tabstops[0].0;
// if selection is forward but was moved to the right it is
// contained entirely in the replacement text, just do a point
// selection (fallback below)
if range.anchor < first_tabstop {
// we can't properly compute the the next grapheme
// here because the transaction hasn't been applied yet
// that is not a problem because the range gets grapheme aligned anyway
// tough so just adding one will always cause head to be grapheme
// aligned correctly when applied to the document
let range = Range::new(range.anchor, first_tabstop + 1);
mapped_selection.push(range);
let rem_tabstops = tabstops[1..]
.iter()
.map(|tabstop| Range::point(tabstop_anchor + tabstop.0));
mapped_selection.extend(rem_tabstops);
continue;
}
};
let tabstops = tabstops
.iter()
.map(|tabstop| Range::point(tabstop_anchor + tabstop.0));
mapped_selection.extend(tabstops);
}
transaction.with_selection(Selection::new(mapped_selection, mapped_primary_idx))
let transaction = transaction.with_selection(snippet.first_selection(
// we keep the direction of the old primary selection in case it changed during mapping
// but use the primary idx from the mapped selection in case ranges had to be merged
selection.primary().direction(),
mapped_selection.primary_index(),
));
(transaction, snippet)
}
pub fn generate_transaction_from_edits(

File diff suppressed because it is too large Load Diff

@ -517,6 +517,8 @@ impl MappableCommand {
record_macro, "Record macro",
replay_macro, "Replay macro",
command_palette, "Open command palette",
goto_next_tabstop, "goto next snippet placeholder",
goto_prev_tabstop, "goto next snippet placeholder",
);
}
@ -3609,7 +3611,11 @@ pub mod insert {
});
if !cursors_after_whitespace {
move_parent_node_end(cx);
if doc.active_snippet.is_some() {
goto_next_tabstop(cx);
} else {
move_parent_node_end(cx);
}
return;
}
}
@ -5696,6 +5702,47 @@ fn increment_impl(cx: &mut Context, increment_direction: IncrementDirection) {
}
}
fn goto_next_tabstop(cx: &mut Context) {
goto_next_tabstop_impl(cx, Direction::Forward)
}
fn goto_prev_tabstop(cx: &mut Context) {
goto_next_tabstop_impl(cx, Direction::Backward)
}
fn goto_next_tabstop_impl(cx: &mut Context, direction: Direction) {
let (view, doc) = current!(cx.editor);
let view_id = view.id;
let Some(mut snippet) = doc.active_snippet.take() else {
cx.editor.set_error("no snippet is currently active");
return;
};
let tabstop = match direction {
Direction::Forward => Some(snippet.next_tabstop(doc.selection(view_id))),
Direction::Backward => snippet
.prev_tabstop(doc.selection(view_id))
.map(|selection| (selection, false)),
};
let Some((selection, last_tabstop)) = tabstop else {
return;
};
doc.set_selection(view_id, selection);
if !last_tabstop {
doc.active_snippet = Some(snippet)
}
if cx.editor.mode() == Mode::Insert {
cx.on_next_key_fallback(|cx, key| {
if let Some(c) = key.char() {
let (view, doc) = current!(cx.editor);
if let Some(snippet) = &doc.active_snippet {
doc.apply(&snippet.delete_placeholder(doc.text()), view.id);
}
insert_char(cx, c);
}
})
}
}
fn record_macro(cx: &mut Context) {
if let Some((reg, mut keys)) = cx.editor.macro_recording.take() {
// Remove the keypress which ends the recording

@ -14,6 +14,7 @@ pub use helix_view::handlers::Handlers;
mod completion;
mod signature_help;
mod snippet;
pub fn setup(config: Arc<ArcSwap<Config>>) -> Handlers {
events::register();
@ -26,5 +27,6 @@ pub fn setup(config: Arc<ArcSwap<Config>>) -> Handlers {
};
completion::register_hooks(&handlers);
signature_help::register_hooks(&handlers);
snippet::register_hooks(&handlers);
handlers
}

@ -0,0 +1,28 @@
use helix_event::register_hook;
use helix_view::events::{DocumentDidChange, DocumentFocusLost, SelectionDidChange};
use helix_view::handlers::Handlers;
pub(super) fn register_hooks(_handlers: &Handlers) {
register_hook!(move |event: &mut SelectionDidChange<'_>| {
if let Some(snippet) = &event.doc.active_snippet {
if !snippet.is_valid(event.doc.selection(event.view)) {
event.doc.active_snippet = None;
}
}
Ok(())
});
register_hook!(move |event: &mut DocumentDidChange<'_>| {
if let Some(snippet) = &mut event.doc.active_snippet {
let invalid = snippet.map(event.changes);
if invalid {
event.doc.active_snippet = None;
}
}
Ok(())
});
register_hook!(move |event: &mut DocumentFocusLost<'_>| {
let editor = &mut event.editor;
doc_mut!(editor).active_snippet = None;
Ok(())
});
}

@ -17,7 +17,11 @@ use tui::{buffer::Buffer as Surface, text::Span};
use std::{borrow::Cow, sync::Arc, time::Duration};
use helix_core::{chars, Change, Transaction};
use helix_core::{
chars,
snippets::{ActiveSnippet, RenderedSnippet, Snippet},
Change, Transaction,
};
use helix_view::{graphics::Rect, Document, Editor};
use crate::ui::{menu, Markdown, Menu, Popup, PromptEvent};
@ -123,101 +127,6 @@ impl Completion {
// Then create the menu
let menu = Menu::new(items, (), move |editor: &mut Editor, item, event| {
fn item_to_transaction(
doc: &Document,
view_id: ViewId,
item: &lsp::CompletionItem,
offset_encoding: OffsetEncoding,
trigger_offset: usize,
include_placeholder: bool,
replace_mode: bool,
) -> Transaction {
use helix_lsp::snippet;
let selection = doc.selection(view_id);
let text = doc.text().slice(..);
let primary_cursor = selection.primary().cursor(text);
let (edit_offset, new_text) = if let Some(edit) = &item.text_edit {
let edit = match edit {
lsp::CompletionTextEdit::Edit(edit) => edit.clone(),
lsp::CompletionTextEdit::InsertAndReplace(item) => {
let range = if replace_mode {
item.replace
} else {
item.insert
};
lsp::TextEdit::new(range, item.new_text.clone())
}
};
let Some(range) =
util::lsp_range_to_range(doc.text(), edit.range, offset_encoding)
else {
return Transaction::new(doc.text());
};
let start_offset = range.anchor as i128 - primary_cursor as i128;
let end_offset = range.head as i128 - primary_cursor as i128;
(Some((start_offset, end_offset)), edit.new_text)
} else {
let new_text = item
.insert_text
.clone()
.unwrap_or_else(|| item.label.clone());
// check that we are still at the correct savepoint
// we can still generate a transaction regardless but if the
// document changed (and not just the selection) then we will
// likely delete the wrong text (same if we applied an edit sent by the LS)
debug_assert!(primary_cursor == trigger_offset);
(None, new_text)
};
if matches!(item.kind, Some(lsp::CompletionItemKind::SNIPPET))
|| matches!(
item.insert_text_format,
Some(lsp::InsertTextFormat::SNIPPET)
)
{
match snippet::parse(&new_text) {
Ok(snippet) => util::generate_transaction_from_snippet(
doc.text(),
selection,
edit_offset,
replace_mode,
snippet,
doc.line_ending.as_str(),
include_placeholder,
doc.tab_width(),
doc.indent_width(),
),
Err(err) => {
log::error!(
"Failed to parse snippet: {:?}, remaining output: {}",
&new_text,
err
);
Transaction::new(doc.text())
}
}
} else {
util::generate_transaction_from_completion_edit(
doc.text(),
selection,
edit_offset,
replace_mode,
new_text,
)
}
}
fn completion_changes(transaction: &Transaction, trigger_offset: usize) -> Vec<Change> {
transaction
.changes_iter()
.filter(|(start, end, _)| (*start..=*end).contains(&trigger_offset))
.collect()
}
let (view, doc) = current!(editor);
macro_rules! language_server {
@ -261,13 +170,12 @@ impl Completion {
// always present here
let item = item.unwrap();
let transaction = item_to_transaction(
let (transaction, _) = item_to_transaction(
doc,
view.id,
&item.item,
language_server!(item).offset_encoding(),
trigger_offset,
true,
replace_mode,
);
doc.apply_temporary(&transaction, view.id);
@ -302,20 +210,27 @@ impl Completion {
doc.restore(view, &savepoint, true);
// save an undo checkpoint before the completion
doc.append_changes_to_history(view);
let transaction = item_to_transaction(
let (transaction, snippet) = item_to_transaction(
doc,
view.id,
&item.item,
offset_encoding,
trigger_offset,
false,
replace_mode,
);
doc.apply(&transaction, view.id);
let placeholder = snippet.is_some();
if let Some(snippet) = snippet {
doc.active_snippet = match doc.active_snippet.take() {
Some(active) => active.insert_subsnippet(snippet),
None => ActiveSnippet::new(snippet),
};
}
editor.last_completion = Some(CompleteAction::Applied {
trigger_offset,
changes: completion_changes(&transaction, trigger_offset),
placeholder,
});
// TODO: add additional _edits to completion_changes?
@ -553,6 +468,94 @@ impl Component for Completion {
}
}
fn item_to_transaction(
doc: &Document,
view_id: ViewId,
item: &lsp::CompletionItem,
offset_encoding: OffsetEncoding,
trigger_offset: usize,
replace_mode: bool,
) -> (Transaction, Option<RenderedSnippet>) {
let selection = doc.selection(view_id);
let text = doc.text().slice(..);
let primary_cursor = selection.primary().cursor(text);
let (edit_offset, new_text) = if let Some(edit) = &item.text_edit {
let edit = match edit {
lsp::CompletionTextEdit::Edit(edit) => edit.clone(),
lsp::CompletionTextEdit::InsertAndReplace(item) => {
let range = if replace_mode {
item.replace
} else {
item.insert
};
lsp::TextEdit::new(range, item.new_text.clone())
}
};
let Some(range) =
util::lsp_range_to_range(doc.text(), edit.range, offset_encoding)
else {
return (Transaction::new(doc.text()), None);
};
let start_offset = range.anchor as i128 - primary_cursor as i128;
let end_offset = range.head as i128 - primary_cursor as i128;
(Some((start_offset, end_offset)), edit.new_text)
} else {
let new_text = item
.insert_text
.clone()
.unwrap_or_else(|| item.label.clone());
// check that we are still at the correct savepoint
// we can still generate a transaction regardless but if the
// document changed (and not just the selection) then we will
// likely delete the wrong text (same if we applied an edit sent by the LS)
debug_assert!(primary_cursor == trigger_offset);
(None, new_text)
};
if matches!(item.kind, Some(lsp::CompletionItemKind::SNIPPET))
|| matches!(
item.insert_text_format,
Some(lsp::InsertTextFormat::SNIPPET)
)
{
let Ok(snippet) = Snippet::parse(&new_text) else {
log::error!(
"Failed to parse snippet: {new_text:?}",
);
return (Transaction::new(doc.text()), None);
};
let (transaction, snippet) = util::generate_transaction_from_snippet(
doc.text(),
selection,
edit_offset,
replace_mode,
snippet,
&mut doc.snippet_ctx(),
);
(transaction, Some(snippet))
} else {
let transaction = util::generate_transaction_from_completion_edit(
doc.text(),
selection,
edit_offset,
replace_mode,
new_text,
);
(transaction, None)
}
}
fn completion_changes(transaction: &Transaction, trigger_offset: usize) -> Vec<Change> {
transaction
.changes_iter()
.filter(|(start, end, _)| (*start..=*end).contains(&trigger_offset))
.collect()
}
/// A hook for resolving incomplete completion items.
///
/// From the [LSP spec](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_completion):

@ -124,7 +124,7 @@ impl EditorView {
line_decorations.push(Box::new(line_decoration));
}
let syntax_highlights =
let mut syntax_highlights =
Self::doc_syntax_highlights(doc, view.offset.anchor, inner.height, theme);
let mut overlay_highlights =
@ -167,6 +167,9 @@ impl EditorView {
} else {
overlay_highlights = Box::new(syntax::merge(highlights, focused_view_elements))
}
if let Some(highlights) = Self::tabstop_highlights(doc, theme) {
syntax_highlights = Box::new(syntax::merge(syntax_highlights, highlights))
}
}
let gutter_overflow = view.gutter_offset(doc) == 0;
@ -558,6 +561,24 @@ impl EditorView {
Vec::new()
}
pub fn tabstop_highlights(
doc: &Document,
theme: &Theme,
) -> Option<Vec<(usize, std::ops::Range<usize>)>> {
let snippet = doc.active_snippet.as_ref()?;
let highlight = theme.find_scope_index_exact("tabstop")?;
let mut highlights = Vec::new();
for tabstop in snippet.tabstops() {
highlights.extend(
tabstop
.ranges
.iter()
.map(|range| (highlight, range.start..range.end)),
);
}
(!highlights.is_empty()).then_some(highlights)
}
/// Render bufferline at the top
pub fn render_bufferline(editor: &Editor, viewport: Rect, surface: &mut Surface) {
let scratch = PathBuf::from(SCRATCH_BUFFER_NAME); // default filename to use for scratch buffer
@ -1023,24 +1044,38 @@ impl EditorView {
Some(area)
}
pub fn clear_completion(&mut self, editor: &mut Editor) {
pub fn clear_completion(&mut self, editor: &mut Editor) -> Option<OnKeyCallback> {
self.completion = None;
let mut on_next_key: Option<OnKeyCallback> = None;
if let Some(last_completion) = editor.last_completion.take() {
match last_completion {
CompleteAction::Triggered => (),
CompleteAction::Applied {
trigger_offset,
changes,
} => self.last_insert.1.push(InsertEvent::CompletionApply {
trigger_offset,
changes,
}),
placeholder,
} => {
self.last_insert.1.push(InsertEvent::CompletionApply {
trigger_offset,
changes,
});
on_next_key = placeholder.then_some(Box::new(|cx, key| {
if let Some(c) = key.char() {
let (view, doc) = current!(cx.editor);
if let Some(snippet) = &doc.active_snippet {
doc.apply(&snippet.delete_placeholder(doc.text()), view.id);
}
commands::insert::insert_char(cx, c);
}
}))
}
CompleteAction::Selected { savepoint } => {
let (view, doc) = current!(editor);
doc.restore(view, &savepoint, false);
}
}
}
on_next_key
}
pub fn handle_idle_timeout(&mut self, cx: &mut commands::Context) -> EventResult {
@ -1362,7 +1397,10 @@ impl Component for EditorView {
if let Some(callback) = res {
if callback.is_some() {
// assume close_fn
self.clear_completion(cx.editor);
if let Some(cb) = self.clear_completion(cx.editor) {
cx.on_next_key_callback =
Some((cb, OnKeyCallbackKind::Fallback))
}
}
}
}

@ -7,6 +7,7 @@ use helix_core::auto_pairs::AutoPairs;
use helix_core::chars::char_is_word;
use helix_core::doc_formatter::TextFormat;
use helix_core::encoding::Encoding;
use helix_core::snippets::{ActiveSnippet, SnippetRenderCtx};
use helix_core::syntax::{Highlight, LanguageServerFeature};
use helix_core::text_annotations::{InlineAnnotation, TextAnnotations};
use helix_lsp::util::lsp_pos_to_pos;
@ -121,6 +122,7 @@ pub struct Document {
pub(crate) id: DocumentId,
text: Rope,
selections: HashMap<ViewId, Selection>,
pub active_snippet: Option<ActiveSnippet>,
/// Inlay hints annotations for the document, by view.
///
@ -639,6 +641,7 @@ impl Document {
Self {
id: DocumentId::default(),
active_snippet: None,
path: None,
encoding,
has_bom,
@ -1886,6 +1889,16 @@ impl Document {
}
}
pub fn snippet_ctx(&self) -> SnippetRenderCtx {
SnippetRenderCtx {
// TODO snippet variable resolution
resolve_var: Box::new(|_| None),
tab_width: self.tab_width(),
indent_style: self.indent_style,
line_ending: self.line_ending.as_str(),
}
}
pub fn text_format(&self, mut viewport_width: u16, theme: Option<&Theme>) -> TextFormat {
let config = self.config.load();
let text_width = self

@ -1004,6 +1004,7 @@ pub enum CompleteAction {
Applied {
trigger_offset: usize,
changes: Vec<Change>,
placeholder: bool,
},
}

@ -27,6 +27,7 @@ string = "silver"
"constant.character.escape" = "honey"
# used for lifetimes
label = "honey"
tabstop = { modifiers = ["italic"], bg = "bossanova" }
"markup.heading" = "lilac"
"markup.bold" = { modifiers = ["bold"] }

Loading…
Cancel
Save