diff --git a/Cargo.lock b/Cargo.lock index eec2a9766..cc7265f33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1139,6 +1139,7 @@ dependencies = [ "futures-util", "helix-core", "helix-loader", + "helix-parsec", "log", "lsp-types", "serde", diff --git a/helix-lsp/src/snippet.rs b/helix-lsp/src/snippet.rs index 27b103d5d..f74237496 100644 --- a/helix-lsp/src/snippet.rs +++ b/helix-lsp/src/snippet.rs @@ -1,3 +1,5 @@ +use std::borrow::Cow; + use anyhow::{anyhow, Result}; use crate::{util::lsp_pos_to_pos, OffsetEncoding}; @@ -54,6 +56,112 @@ pub fn parse(s: &str) -> Result> { parser::parse(s).map_err(|rest| anyhow!("Failed to parse snippet. Remaining input: {}", rest)) } +pub fn into_transaction<'a>( + snippet: Snippet<'a>, + doc: &helix_core::Rope, + selection: &helix_core::Selection, + edit: &lsp_types::TextEdit, + line_ending: &str, + offset_encoding: OffsetEncoding, +) -> helix_core::Transaction { + use helix_core::{smallvec, Range, Selection, Transaction}; + use SnippetElement::*; + + let text = doc.slice(..); + let primary_cursor = selection.primary().cursor(text); + + let start_offset = match lsp_pos_to_pos(doc, edit.range.start, offset_encoding) { + Some(start) => start as i128 - primary_cursor as i128, + None => return Transaction::new(doc), + }; + let end_offset = match lsp_pos_to_pos(doc, edit.range.end, offset_encoding) { + Some(end) => end as i128 - primary_cursor as i128, + None => return Transaction::new(doc), + }; + + let newline_with_offset = format!( + "{line_ending}{blank:width$}", + width = edit.range.start.character as usize, + blank = "" + ); + + let mut insert = String::new(); + let mut offset = (primary_cursor as i128 + start_offset) as usize; + let mut tabstops: Vec = Vec::new(); + + for element in snippet.elements { + match element { + Text(text) => { + // small optimization to avoid calling replace when it's unnecessary + let text = if text.contains('\n') { + Cow::Owned(text.replace('\n', &newline_with_offset)) + } else { + Cow::Borrowed(text) + }; + offset += text.chars().count(); + insert.push_str(&text); + } + Variable { + name: _name, + regex: None, + r#default, + } => { + // TODO: variables. For now, fall back to the default, which defaults to "". + let text = r#default.unwrap_or_default(); + offset += text.chars().count(); + insert.push_str(text); + } + Tabstop { .. } => { + // TODO: tabstop indexing: 0 is final cursor position. 1,2,.. are positions. + // TODO: merge tabstops with the same index + tabstops.push(Range::point(offset)); + } + Placeholder { + tabstop: _tabstop, + value, + } => match value.as_ref() { + // https://doc.rust-lang.org/beta/unstable-book/language-features/box-patterns.html + // would make this a bit nicer + Text(text) => { + let len_chars = text.chars().count(); + tabstops.push(Range::new(offset, offset + len_chars + 1)); + offset += len_chars; + insert.push_str(text); + } + other => { + log::error!( + "Discarding snippet: generating a transaction for placeholder contents {:?} is unimplemented.", + other + ); + return Transaction::new(doc); + } + }, + other => { + log::error!( + "Discarding snippet: generating a transaction for {:?} is unimplemented.", + other + ); + return Transaction::new(doc); + } + } + } + + let transaction = Transaction::change_by_selection(doc, selection, |range| { + let cursor = range.cursor(text); + ( + (cursor as i128 + start_offset) as usize, + (cursor as i128 + end_offset) as usize, + Some(insert.clone().into()), + ) + }); + + if let Some(first) = tabstops.first() { + transaction.with_selection(Selection::new(smallvec![*first], 0)) + } else { + transaction + } +} + mod parser { use helix_parsec::*; diff --git a/helix-term/src/ui/completion.rs b/helix-term/src/ui/completion.rs index a24da20a9..6897305de 100644 --- a/helix-term/src/ui/completion.rs +++ b/helix-term/src/ui/completion.rs @@ -119,7 +119,9 @@ fn item_to_transaction( start_offset: usize, trigger_offset: usize, ) -> Transaction { - let transaction = if let Some(edit) = &item.text_edit { + use helix_lsp::snippet; + + if let Some(edit) = &item.text_edit { let edit = match edit { lsp::CompletionTextEdit::Edit(edit) => edit.clone(), lsp::CompletionTextEdit::InsertAndReplace(item) => { @@ -128,12 +130,38 @@ fn item_to_transaction( } }; - util::generate_transaction_from_completion_edit( - doc.text(), - doc.selection(view_id), - edit, - offset_encoding, // TODO: should probably transcode in Client - ) + if matches!(item.kind, Some(lsp::CompletionItemKind::SNIPPET)) + || matches!( + item.insert_text_format, + Some(lsp::InsertTextFormat::SNIPPET) + ) + { + match snippet::parse(&edit.new_text) { + Ok(snippet) => snippet::into_transaction( + snippet, + doc.text(), + doc.selection(view_id), + &edit, + doc.line_ending.as_str(), + offset_encoding, + ), + Err(err) => { + log::error!( + "Failed to parse snippet: {:?}, remaining output: {}", + &edit.new_text, + err + ); + Transaction::new(doc.text()) + } + } + } else { + util::generate_transaction_from_completion_edit( + doc.text(), + doc.selection(view_id), + edit, + offset_encoding, // TODO: should probably transcode in Client + ) + } } else { let text = item.insert_text.as_ref().unwrap_or(&item.label); // Some LSPs just give you an insertText with no offset ¯\_(ツ)_/¯ @@ -157,9 +185,7 @@ fn item_to_transaction( (cursor, cursor, Some(text.into())) }) - }; - - transaction + } } fn completion_changes(transaction: &Transaction, trigger_offset: usize) -> Vec {