mod client; pub mod jsonrpc; pub mod snippet; mod transport; pub use client::Client; pub use futures_executor::block_on; pub use jsonrpc::Call; pub use lsp::{Position, Url}; pub use lsp_types as lsp; use futures_util::stream::select_all::SelectAll; use helix_core::{ path, syntax::{LanguageConfiguration, LanguageServerConfiguration, LanguageServerFeatures}, }; use tokio::sync::mpsc::UnboundedReceiver; use std::{ collections::HashMap, path::{Path, PathBuf}, sync::Arc, }; use thiserror::Error; use tokio_stream::wrappers::UnboundedReceiverStream; pub type Result = core::result::Result; pub type LanguageServerName = String; #[derive(Error, Debug)] pub enum Error { #[error("protocol error: {0}")] Rpc(#[from] jsonrpc::Error), #[error("failed to parse: {0}")] Parse(#[from] serde_json::Error), #[error("IO Error: {0}")] IO(#[from] std::io::Error), #[error("request {0} timed out")] Timeout(jsonrpc::Id), #[error("server closed the stream")] StreamClosed, #[error("Unhandled")] Unhandled, #[error(transparent)] Other(#[from] anyhow::Error), } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum OffsetEncoding { /// UTF-8 code units aka bytes Utf8, /// UTF-32 code units aka chars Utf32, /// UTF-16 code units #[default] Utf16, } 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::{diagnostic::NumberOrString, Range, Rope, Selection, Tendril, Transaction}; /// Converts a diagnostic in the document to [`lsp::Diagnostic`]. /// /// Panics when [`pos_to_lsp_pos`] would for an invalid range on the diagnostic. pub fn diagnostic_to_lsp_diagnostic( doc: &Rope, diag: &helix_core::diagnostic::Diagnostic, offset_encoding: OffsetEncoding, ) -> lsp::Diagnostic { use helix_core::diagnostic::Severity::*; let range = Range::new(diag.range.start, diag.range.end); let severity = diag.severity.map(|s| match s { Hint => lsp::DiagnosticSeverity::HINT, Info => lsp::DiagnosticSeverity::INFORMATION, Warning => lsp::DiagnosticSeverity::WARNING, Error => lsp::DiagnosticSeverity::ERROR, }); let code = match diag.code.clone() { Some(x) => match x { NumberOrString::Number(x) => Some(lsp::NumberOrString::Number(x)), NumberOrString::String(x) => Some(lsp::NumberOrString::String(x)), }, None => None, }; let new_tags: Vec<_> = diag .tags .iter() .map(|tag| match tag { helix_core::diagnostic::DiagnosticTag::Unnecessary => { lsp::DiagnosticTag::UNNECESSARY } helix_core::diagnostic::DiagnosticTag::Deprecated => lsp::DiagnosticTag::DEPRECATED, }) .collect(); let tags = if !new_tags.is_empty() { Some(new_tags) } else { None }; lsp::Diagnostic { range: range_to_lsp_range(doc, range, offset_encoding), severity, code, source: diag.source.clone(), message: diag.message.to_owned(), related_information: None, tags, data: diag.data.to_owned(), ..Default::default() } } /// Converts [`lsp::Position`] to a position in the document. /// /// Returns `None` if position.line is out of bounds or an overflow occurs pub fn lsp_pos_to_pos( doc: &Rope, pos: lsp::Position, offset_encoding: OffsetEncoding, ) -> Option { let pos_line = pos.line as usize; if pos_line > doc.len_lines() - 1 { // If it extends past the end, truncate it to the end. This is because the // way the LSP describes the range including the last newline is by // specifying a line number after what we would call the last line. log::warn!("LSP position {pos:?} out of range assuming EOF"); return Some(doc.len_chars()); } // We need to be careful here to fully comply ith the LSP spec. // Two relevant quotes from the spec: // // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#position // > If the character value is greater than the line length it defaults back // > to the line length. // // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocuments // > To ensure that both client and server split the string into the same // > line representation the protocol specifies the following end-of-line sequences: // > ‘\n’, ‘\r\n’ and ‘\r’. Positions are line end character agnostic. // > So you can not specify a position that denotes \r|\n or \n| where | represents the character offset. // // This means that while the line must be in bounds the `character` // must be capped to the end of the line. // Note that the end of the line here is **before** the line terminator // so we must use `line_end_char_index` instead of `doc.line_to_char(pos_line + 1)` // // FIXME: Helix does not fully comply with the LSP spec for line terminators. // The LSP standard requires that line terminators are ['\n', '\r\n', '\r']. // Without the unicode-linebreak feature disabled, the `\r` terminator is not handled by helix. // With the unicode-linebreak feature, helix recognizes multiple extra line break chars // which means that positions will be decoded/encoded incorrectly in their presence let line = match offset_encoding { OffsetEncoding::Utf8 => { let line_start = doc.line_to_byte(pos_line); let line_end = line_end_byte_index(&doc.slice(..), pos_line); line_start..line_end } OffsetEncoding::Utf16 => { // TODO directly translate line index to char-idx // ropey can do this just as easily as utf-8 byte translation // but the functions are just missing. // Translate to char first and then utf-16 as a workaround let line_start = doc.line_to_char(pos_line); let line_end = line_end_char_index(&doc.slice(..), pos_line); doc.char_to_utf16_cu(line_start)..doc.char_to_utf16_cu(line_end) } OffsetEncoding::Utf32 => { let line_start = doc.line_to_char(pos_line); let line_end = line_end_char_index(&doc.slice(..), pos_line); line_start..line_end } }; // The LSP spec demands that the offset is capped to the end of the line let pos = line .start .checked_add(pos.character as usize) .unwrap_or(line.end) .min(line.end); match offset_encoding { OffsetEncoding::Utf8 => doc.try_byte_to_char(pos).ok(), OffsetEncoding::Utf16 => doc.try_utf16_cu_to_char(pos).ok(), OffsetEncoding::Utf32 => Some(pos), } } /// Converts position in the document to [`lsp::Position`]. /// /// Panics when `pos` is out of `doc` bounds or operation overflows. pub fn pos_to_lsp_pos( doc: &Rope, pos: usize, offset_encoding: OffsetEncoding, ) -> lsp::Position { match offset_encoding { OffsetEncoding::Utf8 => { let line = doc.char_to_line(pos); let line_start = doc.line_to_byte(line); let col = doc.char_to_byte(pos) - line_start; lsp::Position::new(line as u32, col as u32) } OffsetEncoding::Utf16 => { let line = doc.char_to_line(pos); let line_start = doc.char_to_utf16_cu(doc.line_to_char(line)); let col = doc.char_to_utf16_cu(pos) - line_start; lsp::Position::new(line as u32, col as u32) } OffsetEncoding::Utf32 => { let line = doc.char_to_line(pos); let line_start = doc.line_to_char(line); let col = pos - line_start; lsp::Position::new(line as u32, col as u32) } } } /// Converts a range in the document to [`lsp::Range`]. pub fn range_to_lsp_range( doc: &Rope, range: Range, offset_encoding: OffsetEncoding, ) -> lsp::Range { let start = pos_to_lsp_pos(doc, range.from(), offset_encoding); let end = pos_to_lsp_pos(doc, range.to(), offset_encoding); lsp::Range::new(start, end) } pub fn lsp_range_to_range( doc: &Rope, mut range: lsp::Range, offset_encoding: OffsetEncoding, ) -> Option { // This is sort of an edgecase. It's not clear from the spec how to deal with // ranges where end < start. They don't make much sense but vscode simply caps start to end // and because it's not specified quite a few LS rely on this as a result (for example the TS server) if range.start > range.end { log::error!( "Invalid LSP range start {:?} > end {:?}, using an empty range at the end instead", range.start, range.end ); range.start = range.end; } let start = lsp_pos_to_pos(doc, range.start, offset_encoding)?; let end = lsp_pos_to_pos(doc, range.end, offset_encoding)?; Some(Range::new(start, end)) } /// If the LS did not provide a range for the completion or the range of the /// primary cursor can not be used for the secondary cursor, this function /// can be used to find the completion range for a cursor fn find_completion_range(text: RopeSlice, replace_mode: bool, cursor: usize) -> (usize, usize) { let start = cursor - text .chars_at(cursor) .reversed() .take_while(|ch| chars::char_is_word(*ch)) .count(); let mut end = cursor; if replace_mode { end += text .chars_at(cursor) .skip(1) .take_while(|ch| chars::char_is_word(*ch)) .count(); } (start, end) } fn completion_range( text: RopeSlice, edit_offset: Option<(i128, i128)>, replace_mode: bool, cursor: usize, ) -> Option<(usize, usize)> { let res = match edit_offset { Some((start_offset, end_offset)) => { let start_offset = cursor as i128 + start_offset; if start_offset < 0 { return None; } let end_offset = cursor as i128 + end_offset; if end_offset > text.len_chars() as i128 { return None; } (start_offset as usize, end_offset as usize) } None => find_completion_range(text, replace_mode, cursor), }; Some(res) } /// Creates a [Transaction] from the [lsp::TextEdit] in a completion response. /// The transaction applies the edit to all cursors. pub fn generate_transaction_from_completion_edit( doc: &Rope, selection: &Selection, edit_offset: Option<(i128, i128)>, replace_mode: bool, new_text: String, ) -> Transaction { let replacement: Option = if new_text.is_empty() { None } else { Some(new_text.into()) }; let text = doc.slice(..); let (removed_start, removed_end) = completion_range( text, edit_offset, replace_mode, selection.primary().cursor(text), ) .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( doc, selection, |range| { let cursor = range.cursor(text); completion_range(text, edit_offset, replace_mode, cursor) .filter(|(start, end)| text.slice(start..end) == removed_text) .unwrap_or_else(|| find_completion_range(text, replace_mode, cursor)) }, |_, _| replacement.clone(), ); if transaction.changes().is_empty() { return transaction; } selection = selection.map(transaction.changes()); transaction.with_selection(selection) } /// Creates a [Transaction] from the [snippet::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 { 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, replace_mode, selection.primary().cursor(text), ) .expect("transaction must be valid for primary selection"); let removed_text = text.slice(removed_start..removed_end); let (transaction, selection) = Transaction::change_by_selection_ignore_overlapping( doc, selection, |range| { let cursor = range.cursor(text); completion_range(text, edit_offset, replace_mode, cursor) .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) }, ); let changes = transaction.changes(); if changes.is_empty() { return transaction; } 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 range = range.map(changes); 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 first_tabstop = tabstop_anchor + tabstops[0].1; // 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 { let range = Range::new(range.anchor, first_tabstop); mapped_selection.push(range); let rem_tabstops = tabstops[1..] .iter() .map(|tabstop| Range::point(tabstop_anchor + tabstop.1)); mapped_selection.extend(rem_tabstops); continue; } } else { let last_idx = tabstops.len() - 1; let last_tabstop = tabstop_anchor + tabstops[last_idx].1; // 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 { // 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, last_tabstop + 1); 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; } }; 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)) } pub fn generate_transaction_from_edits( doc: &Rope, mut edits: Vec, offset_encoding: OffsetEncoding, ) -> Transaction { // Sort edits by start range, since some LSPs (Omnisharp) send them // in reverse order. edits.sort_unstable_by_key(|edit| edit.range.start); // Generate a diff if the edit is a full document replacement. #[allow(clippy::collapsible_if)] if edits.len() == 1 { let is_document_replacement = edits.first().and_then(|edit| { let start = lsp_pos_to_pos(doc, edit.range.start, offset_encoding)?; let end = lsp_pos_to_pos(doc, edit.range.end, offset_encoding)?; Some(start..end) }) == Some(0..doc.len_chars()); if is_document_replacement { let new_text = Rope::from(edits.pop().unwrap().new_text); return helix_core::diff::compare_ropes(doc, &new_text); } } Transaction::change( doc, edits.into_iter().map(|edit| { // simplify "" into None for cleaner changesets let replacement = if !edit.new_text.is_empty() { Some(edit.new_text.into()) } else { None }; let start = if let Some(start) = lsp_pos_to_pos(doc, edit.range.start, offset_encoding) { start } else { return (0, 0, None); }; let end = if let Some(end) = lsp_pos_to_pos(doc, edit.range.end, offset_encoding) { end } else { return (0, 0, None); }; (start, end, replacement) }), ) } } #[derive(Debug, PartialEq, Clone)] pub enum MethodCall { WorkDoneProgressCreate(lsp::WorkDoneProgressCreateParams), ApplyWorkspaceEdit(lsp::ApplyWorkspaceEditParams), WorkspaceFolders, WorkspaceConfiguration(lsp::ConfigurationParams), RegisterCapability(lsp::RegistrationParams), } impl MethodCall { pub fn parse(method: &str, params: jsonrpc::Params) -> Result { use lsp::request::Request; let request = match method { lsp::request::WorkDoneProgressCreate::METHOD => { let params: lsp::WorkDoneProgressCreateParams = params.parse()?; Self::WorkDoneProgressCreate(params) } lsp::request::ApplyWorkspaceEdit::METHOD => { let params: lsp::ApplyWorkspaceEditParams = params.parse()?; Self::ApplyWorkspaceEdit(params) } lsp::request::WorkspaceFoldersRequest::METHOD => Self::WorkspaceFolders, lsp::request::WorkspaceConfiguration::METHOD => { let params: lsp::ConfigurationParams = params.parse()?; Self::WorkspaceConfiguration(params) } lsp::request::RegisterCapability::METHOD => { let params: lsp::RegistrationParams = params.parse()?; Self::RegisterCapability(params) } _ => { return Err(Error::Unhandled); } }; Ok(request) } } #[derive(Debug, PartialEq, Clone)] pub enum Notification { // we inject this notification to signal the LSP is ready Initialized, // and this notification to signal that the LSP exited Exit, PublishDiagnostics(lsp::PublishDiagnosticsParams), ShowMessage(lsp::ShowMessageParams), LogMessage(lsp::LogMessageParams), ProgressMessage(lsp::ProgressParams), } impl Notification { pub fn parse(method: &str, params: jsonrpc::Params) -> Result { use lsp::notification::Notification as _; let notification = match method { lsp::notification::Initialized::METHOD => Self::Initialized, lsp::notification::Exit::METHOD => Self::Exit, lsp::notification::PublishDiagnostics::METHOD => { let params: lsp::PublishDiagnosticsParams = params.parse()?; Self::PublishDiagnostics(params) } lsp::notification::ShowMessage::METHOD => { let params: lsp::ShowMessageParams = params.parse()?; Self::ShowMessage(params) } lsp::notification::LogMessage::METHOD => { let params: lsp::LogMessageParams = params.parse()?; Self::LogMessage(params) } lsp::notification::Progress::METHOD => { let params: lsp::ProgressParams = params.parse()?; Self::ProgressMessage(params) } _ => { return Err(Error::Unhandled); } }; Ok(notification) } } #[derive(Debug)] pub struct Registry { inner: HashMap>>, syn_loader: Arc, counter: usize, pub incoming: SelectAll>, } impl Registry { pub fn new(syn_loader: Arc) -> Self { Self { inner: HashMap::new(), syn_loader, counter: 0, incoming: SelectAll::new(), } } pub fn get_by_id(&self, id: usize) -> Option<&Client> { self.inner .values() .flatten() .find(|client| client.id() == id) .map(|client| &**client) } pub fn remove_by_id(&mut self, id: usize) { self.inner.retain(|_, language_servers| { language_servers.retain(|ls| id != ls.id()); !language_servers.is_empty() }); } fn start_client( &mut self, name: String, ls_config: &LanguageConfiguration, doc_path: Option<&std::path::PathBuf>, root_dirs: &[PathBuf], enable_snippets: bool, ) -> Result> { let config = self .syn_loader .language_server_configs() .get(&name) .ok_or_else(|| anyhow::anyhow!("Language server '{name}' not defined"))?; let id = self.counter; self.counter += 1; let NewClient(client, incoming) = start_client( id, name, ls_config, config, doc_path, root_dirs, enable_snippets, )?; self.incoming.push(UnboundedReceiverStream::new(incoming)); Ok(client) } pub fn restart( &mut self, language_config: &LanguageConfiguration, doc_path: Option<&std::path::PathBuf>, root_dirs: &[PathBuf], enable_snippets: bool, ) -> Result>> { language_config .language_servers .iter() .filter_map(|LanguageServerFeatures { name, .. }| { if self.inner.contains_key(name) { let client = match self.start_client( name.clone(), language_config, doc_path, root_dirs, enable_snippets, ) { Ok(client) => client, error => return Some(error), }; let old_clients = self .inner .insert(name.clone(), vec![client.clone()]) .unwrap(); // TODO what if there are multiple instances for different workspaces? // I think the language servers will be stopped without being restarted, which is not intended for old_client in old_clients { tokio::spawn(async move { let _ = old_client.force_shutdown().await; }); } Some(Ok(client)) } else { None } }) .collect() } pub fn stop(&mut self, name: &str) { if let Some(clients) = self.inner.remove(name) { for client in clients { tokio::spawn(async move { let _ = client.force_shutdown().await; }); } } } pub fn get( &mut self, language_config: &LanguageConfiguration, doc_path: Option<&std::path::PathBuf>, root_dirs: &[PathBuf], enable_snippets: bool, ) -> Result>> { language_config .language_servers .iter() .map(|LanguageServerFeatures { name, .. }| { if let Some(clients) = self.inner.get(name) { if let Some((_, client)) = clients.iter().enumerate().find(|(i, client)| { client.try_add_doc(&language_config.roots, root_dirs, doc_path, *i == 0) }) { return Ok((name.to_owned(), client.clone())); } } let client = self.start_client( name.clone(), language_config, doc_path, root_dirs, enable_snippets, )?; let clients = self.inner.entry(name.clone()).or_default(); clients.push(client.clone()); Ok((name.clone(), client)) }) .collect() } pub fn iter_clients(&self) -> impl Iterator> { self.inner.values().flatten() } } #[derive(Debug)] pub enum ProgressStatus { Created, Started(lsp::WorkDoneProgress), } impl ProgressStatus { pub fn progress(&self) -> Option<&lsp::WorkDoneProgress> { match &self { ProgressStatus::Created => None, ProgressStatus::Started(progress) => Some(progress), } } } #[derive(Default, Debug)] /// Acts as a container for progress reported by language servers. Each server /// has a unique id assigned at creation through [`Registry`]. This id is then used /// to store the progress in this map. pub struct LspProgressMap(HashMap>); impl LspProgressMap { pub fn new() -> Self { Self::default() } /// Returns a map of all tokens corresponding to the language server with `id`. pub fn progress_map(&self, id: usize) -> Option<&HashMap> { self.0.get(&id) } pub fn is_progressing(&self, id: usize) -> bool { self.0.get(&id).map(|it| !it.is_empty()).unwrap_or_default() } /// Returns last progress status for a given server with `id` and `token`. pub fn progress(&self, id: usize, token: &lsp::ProgressToken) -> Option<&ProgressStatus> { self.0.get(&id).and_then(|values| values.get(token)) } /// Checks if progress `token` for server with `id` is created. pub fn is_created(&mut self, id: usize, token: &lsp::ProgressToken) -> bool { self.0 .get(&id) .map(|values| values.get(token).is_some()) .unwrap_or_default() } pub fn create(&mut self, id: usize, token: lsp::ProgressToken) { self.0 .entry(id) .or_default() .insert(token, ProgressStatus::Created); } /// Ends the progress by removing the `token` from server with `id`, if removed returns the value. pub fn end_progress( &mut self, id: usize, token: &lsp::ProgressToken, ) -> Option { self.0.get_mut(&id).and_then(|vals| vals.remove(token)) } /// Updates the progress of `token` for server with `id` to `status`, returns the value replaced or `None`. pub fn update( &mut self, id: usize, token: lsp::ProgressToken, status: lsp::WorkDoneProgress, ) -> Option { self.0 .entry(id) .or_default() .insert(token, ProgressStatus::Started(status)) } } struct NewClient(Arc, UnboundedReceiver<(usize, Call)>); /// start_client takes both a LanguageConfiguration and a LanguageServerConfiguration to ensure that /// it is only called when it makes sense. fn start_client( id: usize, name: String, config: &LanguageConfiguration, ls_config: &LanguageServerConfiguration, doc_path: Option<&std::path::PathBuf>, root_dirs: &[PathBuf], enable_snippets: bool, ) -> Result { let (client, incoming, initialize_notify) = Client::start( &ls_config.command, &ls_config.args, ls_config.config.clone(), ls_config.environment.clone(), &config.roots, config.workspace_lsp_roots.as_deref().unwrap_or(root_dirs), id, name, ls_config.timeout, doc_path, )?; let client = Arc::new(client); // Initialize the client asynchronously let _client = client.clone(); tokio::spawn(async move { use futures_util::TryFutureExt; let value = _client .capabilities .get_or_try_init(|| { _client .initialize(enable_snippets) .map_ok(|response| response.capabilities) }) .await; if let Err(e) = value { log::error!("failed to initialize language server: {}", e); return; } // next up, notify _client .notify::(lsp::InitializedParams {}) .await .unwrap(); initialize_notify.notify_one(); }); Ok(NewClient(client, incoming)) } /// Find an LSP workspace of a file using the following mechanism: /// * if the file is outside `workspace` return `None` /// * start at `file` and search the file tree upward /// * stop the search at the first `root_dirs` entry that contains `file` /// * if no `root_dirs` matches `file` stop at workspace /// * Returns the top most directory that contains a `root_marker` /// * If no root marker and we stopped at a `root_dirs` entry, return the directory we stopped at /// * If we stopped at `workspace` instead and `workspace_is_cwd == false` return `None` /// * If we stopped at `workspace` instead and `workspace_is_cwd == true` return `workspace` pub fn find_lsp_workspace( file: &str, root_markers: &[String], root_dirs: &[PathBuf], workspace: &Path, workspace_is_cwd: bool, ) -> Option { let file = std::path::Path::new(file); let mut file = if file.is_absolute() { file.to_path_buf() } else { let current_dir = std::env::current_dir().expect("unable to determine current directory"); current_dir.join(file) }; file = path::get_normalized_path(&file); if !file.starts_with(workspace) { return None; } let mut top_marker = None; for ancestor in file.ancestors() { if root_markers .iter() .any(|marker| ancestor.join(marker).exists()) { top_marker = Some(ancestor); } if root_dirs .iter() .any(|root_dir| path::get_normalized_path(&workspace.join(root_dir)) == ancestor) { // if the worskapce is the cwd do not search any higher for workspaces // but specify return Some(top_marker.unwrap_or(workspace).to_owned()); } if ancestor == workspace { // if the workspace is the CWD, let the LSP decide what the workspace // is return top_marker .or_else(|| (!workspace_is_cwd).then_some(workspace)) .map(Path::to_owned); } } debug_assert!(false, "workspace must be an ancestor of "); None } #[cfg(test)] mod tests { use super::{lsp, util::*, OffsetEncoding}; use helix_core::Rope; #[test] fn converts_lsp_pos_to_pos() { macro_rules! test_case { ($doc:expr, ($x:expr, $y:expr) => $want:expr) => { let doc = Rope::from($doc); let pos = lsp::Position::new($x, $y); assert_eq!($want, lsp_pos_to_pos(&doc, pos, OffsetEncoding::Utf16)); assert_eq!($want, lsp_pos_to_pos(&doc, pos, OffsetEncoding::Utf8)) }; } test_case!("", (0, 0) => Some(0)); test_case!("", (0, 1) => Some(0)); test_case!("", (1, 0) => Some(0)); test_case!("\n\n", (0, 0) => Some(0)); test_case!("\n\n", (1, 0) => Some(1)); test_case!("\n\n", (1, 1) => Some(1)); test_case!("\n\n", (2, 0) => Some(2)); test_case!("\n\n", (3, 0) => Some(2)); test_case!("test\n\n\n\ncase", (4, 3) => Some(11)); test_case!("test\n\n\n\ncase", (4, 4) => Some(12)); test_case!("test\n\n\n\ncase", (4, 5) => Some(12)); test_case!("", (u32::MAX, u32::MAX) => Some(0)); } #[test] fn emoji_format_gh_4791() { use lsp_types::{Position, Range, TextEdit}; let edits = vec![ TextEdit { range: Range { start: Position { line: 0, character: 1, }, end: Position { line: 1, character: 0, }, }, new_text: "\n ".to_string(), }, TextEdit { range: Range { start: Position { line: 1, character: 7, }, end: Position { line: 2, character: 0, }, }, new_text: "\n ".to_string(), }, ]; let mut source = Rope::from_str("[\n\"🇺🇸\",\n\"🎄\",\n]"); let transaction = generate_transaction_from_edits(&source, edits, OffsetEncoding::Utf8); assert!(transaction.apply(&mut source)); } }