on master help sign in
fix snippet bugs and multicursor completion edgecases

Multicursor completions may overlap and therefore overlapping completions must be dropped to avoid crashes. Furthermore, multicursor edits might simply be out of range if the word before/after the cursor is shorter. This currently leads to crashes, instead these selections are now also removed for completions. This commit also significantly refactors snippet transaction generation so that tabstops behave correctly with the above rules. Furthermore, snippet tabstops need to be carefully mapped to ensure their position is correct and consistent with our selection semantics. Finally, we now keep a partially updated Rope while creating snippet transactions so that we can fill information into snippets that depends on the position in the document.

commitb1f75280909884c9621b553b43030ac39cfa47ce authorPascal Kuthe <pascal.kuthe@semimod.de> dateThu 9 Mar 2023, 23:08 (4 years ago) parent2b64a64
2 files changed+180−79
helix-lsp/src/lib.rs+177−64 helix-term/src/ui/completion.rs+3−15
helix-lsp/src/lib.rs+177−64
@@ -59,8 +59,8 @@ pub enum OffsetEncoding {
5959pub mod util {
6060 use super::*;
6161 use helix_core::line_ending::{line_end_byte_index, line_end_char_index};
62+ use helix_core::{chars, RopeSlice, SmallVec};
6263 use helix_core::{diagnostic::NumberOrString, Range, Rope, Selection, Tendril, Transaction};
63− use helix_core::{smallvec, SmallVec};
6464
6565 /// Converts a diagnostic in the document to [`lsp::Diagnostic`].
6666 ///
@@ -247,13 +247,46 @@ pub mod util {
247247 Some(Range::new(start, end))
248248 }
249249
250+ /// If the LS did not provide a range for the completion or the range of the
251+ /// primary cursor can not be used for the secondary cursor, this function
252+ /// can be used to find the completion range for a cursor
253+ fn find_completion_range(text: RopeSlice, cursor: usize) -> (usize, usize) {
254+ let start = cursor
255+ - text
256+ .chars_at(cursor)
257+ .reversed()
258+ .take_while(|ch| chars::char_is_word(*ch))
259+ .count();
260+ (start, cursor)
261+ }
262+ fn completion_range(
263+ text: RopeSlice,
264+ edit_offset: Option<(i128, i128)>,
265+ cursor: usize,
266+ ) -> Option<(usize, usize)> {
267+ let res = match edit_offset {
268+ Some((start_offset, end_offset)) => {
269+ let start_offset = cursor as i128 + start_offset;
270+ if start_offset < 0 {
271+ return None;
272+ }
273+ let end_offset = cursor as i128 + end_offset;
274+ if end_offset > text.len_chars() as i128 {
275+ return None;
276+ }
277+ (start_offset as usize, end_offset as usize)
278+ }
279+ None => find_completion_range(text, cursor),
280+ };
281+ Some(res)
282+ }
283+
250284 /// Creates a [Transaction] from the [lsp::TextEdit] in a completion response.
251285 /// The transaction applies the edit to all cursors.
252286 pub fn generate_transaction_from_completion_edit(
253287 doc: &Rope,
254288 selection: &Selection,
255− start_offset: i128,
256− end_offset: i128,
289+ edit_offset: Option<(i128, i128)>,
257290 new_text: String,
258291 ) -> Transaction {
259292 let replacement: Option<Tendril> = if new_text.is_empty() {
@@ -263,83 +296,163 @@ pub mod util {
263296 };
264297
265298 let text = doc.slice(..);
299+ let (removed_start, removed_end) =
300+ completion_range(text, edit_offset, selection.primary().cursor(text))
301+ .expect("transaction must be valid for primary selection");
302+ let removed_text = text.slice(removed_start..removed_end);
266303
267− Transaction::change_by_selection(doc, selection, |range| {
268− let cursor = range.cursor(text);
269− (
270− (cursor as i128 + start_offset) as usize,
271− (cursor as i128 + end_offset) as usize,
272− replacement.clone(),
273− )
274− })
304+ let (transaction, mut selection) = Transaction::change_by_selection_ignore_overlapping(
305+ doc,
306+ selection,
307+ |range| {
308+ let cursor = range.cursor(text);
309+ completion_range(text, edit_offset, cursor)
310+ .filter(|(start, end)| text.slice(start..end) == removed_text)
311+ .unwrap_or_else(|| find_completion_range(text, cursor))
312+ },
313+ |_, _| replacement.clone(),
314+ );
315+ if transaction.changes().is_empty() {
316+ return transaction;
317+ }
318+ selection = selection.map(transaction.changes());
319+ transaction.with_selection(selection)
275320 }
276321
277322 /// Creates a [Transaction] from the [snippet::Snippet] in a completion response.
278323 /// The transaction applies the edit to all cursors.
324+ #[allow(clippy::too_many_arguments)]
279325 pub fn generate_transaction_from_snippet(
280326 doc: &Rope,
281327 selection: &Selection,
282− start_offset: i128,
283− end_offset: i128,
328+ edit_offset: Option<(i128, i128)>,
284329 snippet: snippet::Snippet,
285330 line_ending: &str,
286331 include_placeholder: bool,
332+ tab_width: usize,
287333 ) -> Transaction {
288334 let text = doc.slice(..);
289335
290− // For each cursor store offsets for the first tabstop
291− let mut cursor_tabstop_offsets = Vec::<SmallVec<[(i128, i128); 1]>>::new();
292− let transaction = Transaction::change_by_selection(doc, selection, |range| {
293− let cursor = range.cursor(text);
294− let replacement_start = (cursor as i128 + start_offset) as usize;
295− let replacement_end = (cursor as i128 + end_offset) as usize;
296− let newline_with_offset = format!(
297− "{line_ending}{blank:width$}",
298− line_ending = line_ending,
299− width = replacement_start - doc.line_to_char(doc.char_to_line(replacement_start)),
300− blank = ""
301− );
302−
303− let (replacement, tabstops) =
304− snippet::render(&snippet, newline_with_offset, include_placeholder);
305−
306− let replacement_len = replacement.chars().count();
307− cursor_tabstop_offsets.push(
308− tabstops
309− .first()
310− .unwrap_or(&smallvec![(replacement_len, replacement_len)])
311− .iter()
312− .map(|(from, to)| -> (i128, i128) {
313− (
314− *from as i128 - replacement_len as i128,
315− *to as i128 - replacement_len as i128,
316− )
317− })
318− .collect(),
319− );
320−
321− (replacement_start, replacement_end, Some(replacement.into()))
322− });
336+ let mut off = 0i128;
337+ let mut mapped_doc = doc.clone();
338+ let mut selection_tabstops: SmallVec<[_; 1]> = SmallVec::new();
339+ let (removed_start, removed_end) =
340+ completion_range(text, edit_offset, selection.primary().cursor(text))
341+ .expect("transaction must be valid for primary selection");
342+ let removed_text = text.slice(removed_start..removed_end);
323343
324− // Create new selection based on the cursor tabstop from above
325− let mut cursor_tabstop_offsets_iter = cursor_tabstop_offsets.iter();
326− let selection = selection
327− .clone()
328− .map(transaction.changes())
329− .transform_iter(|range| {
330− cursor_tabstop_offsets_iter
331− .next()
332− .unwrap()
333− .iter()
334− .map(move |(from, to)| {
335− Range::new(
336− (range.anchor as i128 + *from) as usize,
337− (range.anchor as i128 + *to) as usize,
338− )
339− })
340− });
344+ let (transaction, selection) = Transaction::change_by_selection_ignore_overlapping(
345+ doc,
346+ selection,
347+ |range| {
348+ let cursor = range.cursor(text);
349+ completion_range(text, edit_offset, cursor)
350+ .filter(|(start, end)| text.slice(start..end) == removed_text)
351+ .unwrap_or_else(|| find_completion_range(text, cursor))
352+ },
353+ |replacement_start, replacement_end| {
354+ let mapped_replacement_start = (replacement_start as i128 + off) as usize;
355+ let mapped_replacement_end = (replacement_end as i128 + off) as usize;
356+
357+ let line_idx = mapped_doc.char_to_line(mapped_replacement_start);
358+ let pos_on_line = mapped_replacement_start - mapped_doc.line_to_char(line_idx);
359+
360+ // we only care about the actual offset here (not virtual text/softwrap)
361+ // so it's ok to use the deprecated function here
362+ #[allow(deprecated)]
363+ let width = helix_core::visual_coords_at_pos(
364+ mapped_doc.line(line_idx),
365+ pos_on_line,
366+ tab_width,
367+ )
368+ .col;
369+ let newline_with_offset = format!(
370+ "{line_ending}{blank:width$}",
371+ line_ending = line_ending,
372+ blank = ""
373+ );
374+
375+ let (replacement, tabstops) =
376+ snippet::render(&snippet, &newline_with_offset, include_placeholder);
377+ selection_tabstops.push((mapped_replacement_start, tabstops));
378+ mapped_doc.remove(mapped_replacement_start..mapped_replacement_end);
379+ mapped_doc.insert(mapped_replacement_start, &replacement);
380+ off +=
381+ replacement_start as i128 - replacement_end as i128 + replacement.len() as i128;
382+
383+ Some(replacement)
384+ },
385+ );
341386
342− transaction.with_selection(selection)
387+ let changes = transaction.changes();
388+ if changes.is_empty() {
389+ return transaction;
390+ }
391+
392+ let mut mapped_selection = SmallVec::with_capacity(selection.len());
393+ let mut mapped_primary_idx = 0;
394+ let primary_range = selection.primary();
395+ for (range, (tabstop_anchor, tabstops)) in selection.into_iter().zip(selection_tabstops) {
396+ if range == primary_range {
397+ mapped_primary_idx = mapped_selection.len()
398+ }
399+
400+ let range = range.map(changes);
401+ let tabstops = tabstops.first().filter(|tabstops| !tabstops.is_empty());
402+ let Some(tabstops) = tabstops else{
403+ // no tabstop normal mapping
404+ mapped_selection.push(range);
405+ continue;
406+ };
407+
408+ // expand the selection to cover the tabstop to retain the helix selection semantic
409+ // the tabstop closest to the range simply replaces `head` while anchor remains in place
410+ // the remaining tabstops receive their own single-width cursor
411+ if range.head < range.anchor {
412+ let first_tabstop = tabstop_anchor + tabstops[0].1;
413+
414+ // if selection is forward but was moved to the right it is
415+ // contained entirely in the replacement text, just do a point
416+ // selection (fallback below)
417+ if range.anchor >= first_tabstop {
418+ let range = Range::new(range.anchor, first_tabstop);
419+ mapped_selection.push(range);
420+ let rem_tabstops = tabstops[1..]
421+ .iter()
422+ .map(|tabstop| Range::point(tabstop_anchor + tabstop.1));
423+ mapped_selection.extend(rem_tabstops);
424+ continue;
425+ }
426+ } else {
427+ let last_idx = tabstops.len() - 1;
428+ let last_tabstop = tabstop_anchor + tabstops[last_idx].1;
429+
430+ // if selection is forward but was moved to the right it is
431+ // contained entirely in the replacement text, just do a point
432+ // selection (fallback below)
433+ if range.anchor <= last_tabstop {
434+ // we can't properly compute the the next grapheme
435+ // here because the transaction hasn't been applied yet
436+ // that is not a problem because the range gets grapheme aligned anyway
437+ // tough so just adding one will always cause head to be grapheme
438+ // aligned correctly when applied to the document
439+ let range = Range::new(range.anchor, last_tabstop + 1);
440+ mapped_selection.push(range);
441+ let rem_tabstops = tabstops[..last_idx]
442+ .iter()
443+ .map(|tabstop| Range::point(tabstop_anchor + tabstop.0));
444+ mapped_selection.extend(rem_tabstops);
445+ continue;
446+ }
447+ };
448+
449+ let tabstops = tabstops
450+ .iter()
451+ .map(|tabstop| Range::point(tabstop_anchor + tabstop.0));
452+ mapped_selection.extend(tabstops);
453+ }
454+
455+ transaction.with_selection(Selection::new(mapped_selection, mapped_primary_idx))
343456 }
344457
345458 pub fn generate_transaction_from_edits(
helix-term/src/ui/completion.rs+3−15
@@ -118,7 +118,6 @@ impl Completion {
118118 view_id: ViewId,
119119 item: &CompletionItem,
120120 offset_encoding: helix_lsp::OffsetEncoding,
121− start_offset: usize,
122121 trigger_offset: usize,
123122 include_placeholder: bool,
124123 ) -> Transaction {
@@ -147,28 +146,18 @@ impl Completion {
147146 None => return Transaction::new(doc.text()),
148147 };
149148
150− (start_offset, end_offset, edit.new_text)
149+ (Some((start_offset, end_offset)), edit.new_text)
151150 } else {
152151 let new_text = item
153152 .insert_text
154153 .clone()
155154 .unwrap_or_else(|| item.label.clone());
156−
157155 // check that we are still at the correct savepoint
158156 // we can still generate a transaction regardless but if the
159157 // document changed (and not just the selection) then we will
160158 // likely delete the wrong text (same if we applied an edit sent by the LS)
161159 debug_assert!(primary_cursor == trigger_offset);
162−
163− // TODO: Respect editor.completion_replace?
164− // Would require detecting the end of the word boundary for every cursor individually.
165− // We don't do the same for normal `edits, to be consistent we would have to do it for those too
166−
167− (
168− start_offset as i128 - primary_cursor as i128,
169− trigger_offset as i128 - primary_cursor as i128,
170− new_text,
171− )
160+ (None, Some(0), new_text)
172161 };
173162
174163 if matches!(item.kind, Some(lsp::CompletionItemKind::SNIPPET))
@@ -186,6 +175,7 @@ impl Completion {
186175 snippet,
187176 doc.line_ending.as_str(),
188177 include_placeholder,
178+ doc.tab_width(),
189179 ),
190180 Err(err) => {
191181 log::error!(
@@ -232,7 +222,6 @@ impl Completion {
232222 view.id,
233223 item,
234224 offset_encoding,
235− start_offset,
236225 trigger_offset,
237226 true,
238227 );
@@ -254,7 +243,6 @@ impl Completion {
254243 view.id,
255244 item,
256245 offset_encoding,
257− start_offset,
258246 trigger_offset,
259247 false,
260248 );