diff options
Diffstat (limited to 'cli/lsp/completions.rs')
-rw-r--r-- | cli/lsp/completions.rs | 783 |
1 files changed, 783 insertions, 0 deletions
diff --git a/cli/lsp/completions.rs b/cli/lsp/completions.rs new file mode 100644 index 000000000..06b123783 --- /dev/null +++ b/cli/lsp/completions.rs @@ -0,0 +1,783 @@ +// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. + +use super::analysis; +use super::language_server; +use super::tsc; + +use crate::fs_util::is_supported_ext; +use crate::media_type::MediaType; + +use deno_core::normalize_path; +use deno_core::resolve_path; +use deno_core::serde::Deserialize; +use deno_core::serde::Serialize; +use deno_core::url::Position; +use deno_core::ModuleSpecifier; +use lspower::lsp; +use std::rc::Rc; +use swc_common::Loc; +use swc_common::SourceMap; +use swc_common::DUMMY_SP; +use swc_ecmascript::ast as swc_ast; +use swc_ecmascript::visit::Node; +use swc_ecmascript::visit::Visit; +use swc_ecmascript::visit::VisitWith; + +const CURRENT_PATH: &str = "."; +const PARENT_PATH: &str = ".."; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CompletionItemData { + #[serde(skip_serializing_if = "Option::is_none")] + pub tsc: Option<tsc::CompletionItemData>, +} + +/// Given a specifier, a position, and a snapshot, optionally return a +/// completion response, which will be valid import completions for the specific +/// context. +pub fn get_import_completions( + specifier: &ModuleSpecifier, + position: &lsp::Position, + state_snapshot: &language_server::StateSnapshot, +) -> Option<lsp::CompletionResponse> { + if let Ok(Some(source)) = state_snapshot.documents.content(specifier) { + let media_type = MediaType::from(specifier); + if let Some((current_specifier, range)) = + is_module_specifier_position(specifier, &source, &media_type, position) + { + // completions for local relative modules + if current_specifier.starts_with("./") + || current_specifier.starts_with("../") + { + return Some(lsp::CompletionResponse::List(lsp::CompletionList { + is_incomplete: false, + items: get_local_completions(specifier, ¤t_specifier, &range)?, + })); + } + // completion of modules within the workspace + if !current_specifier.is_empty() { + return Some(lsp::CompletionResponse::List(lsp::CompletionList { + is_incomplete: false, + items: get_workspace_completions( + specifier, + ¤t_specifier, + &range, + state_snapshot, + ), + })); + } + } + } + None +} + +/// Return local completions that are relative to the base specifier. +fn get_local_completions( + base: &ModuleSpecifier, + current: &str, + range: &lsp::Range, +) -> Option<Vec<lsp::CompletionItem>> { + if base.scheme() != "file" { + return None; + } + + let mut base_path = base.to_file_path().ok()?; + base_path.pop(); + let mut current_path = normalize_path(base_path.join(current)); + // if the current text does not end in a `/` then we are still selecting on + // the parent and should show all completions from there. + let is_parent = if !current.ends_with('/') { + current_path.pop(); + true + } else { + false + }; + if current_path.is_dir() { + let items = std::fs::read_dir(current_path).ok()?; + Some( + items + .filter_map(|de| { + let de = de.ok()?; + let label = de.path().file_name()?.to_string_lossy().to_string(); + let entry_specifier = resolve_path(de.path().to_str()?).ok()?; + if &entry_specifier == base { + return None; + } + let full_text = relative_specifier(&entry_specifier, base); + // this weeds out situations where we are browsing in the parent, but + // we want to filter out non-matches when the completion is manually + // invoked by the user, but still allows for things like `../src/../` + // which is silly, but no reason to not allow it. + if is_parent && !full_text.starts_with(current) { + return None; + } + let text_edit = Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: *range, + new_text: full_text.clone(), + })); + let filter_text = if full_text.starts_with(current) { + Some(full_text) + } else { + Some(format!("{}{}", current, label)) + }; + match de.file_type() { + Ok(file_type) if file_type.is_dir() => Some(lsp::CompletionItem { + label, + kind: Some(lsp::CompletionItemKind::Folder), + filter_text, + sort_text: Some("1".to_string()), + text_edit, + ..Default::default() + }), + Ok(file_type) if file_type.is_file() => { + if is_supported_ext(&de.path()) { + Some(lsp::CompletionItem { + label, + kind: Some(lsp::CompletionItemKind::File), + detail: Some("(local)".to_string()), + filter_text, + sort_text: Some("1".to_string()), + text_edit, + ..Default::default() + }) + } else { + None + } + } + _ => None, + } + }) + .collect(), + ) + } else { + None + } +} + +fn get_relative_specifiers( + base: &ModuleSpecifier, + specifiers: Vec<ModuleSpecifier>, +) -> Vec<String> { + specifiers + .iter() + .filter_map(|s| { + if s != base { + Some(relative_specifier(s, base)) + } else { + None + } + }) + .collect() +} + +/// Get workspace completions that include modules in the Deno cache which match +/// the current specifier string. +fn get_workspace_completions( + specifier: &ModuleSpecifier, + current: &str, + range: &lsp::Range, + state_snapshot: &language_server::StateSnapshot, +) -> Vec<lsp::CompletionItem> { + let workspace_specifiers = state_snapshot.sources.specifiers(); + let specifier_strings = + get_relative_specifiers(specifier, workspace_specifiers); + specifier_strings + .into_iter() + .filter_map(|label| { + if label.starts_with(¤t) { + let detail = Some( + if label.starts_with("http:") || label.starts_with("https:") { + "(remote)".to_string() + } else if label.starts_with("data:") { + "(data)".to_string() + } else { + "(local)".to_string() + }, + ); + let text_edit = Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: *range, + new_text: label.clone(), + })); + Some(lsp::CompletionItem { + label, + kind: Some(lsp::CompletionItemKind::File), + detail, + sort_text: Some("1".to_string()), + text_edit, + ..Default::default() + }) + } else { + None + } + }) + .collect() +} + +/// A structure that implements the visit trait to determine if the supplied +/// position falls within the module specifier of an import/export statement. +/// Once the module has been visited, +struct ImportLocator { + pub maybe_range: Option<lsp::Range>, + pub maybe_specifier: Option<String>, + position: lsp::Position, + source_map: Rc<SourceMap>, +} + +impl ImportLocator { + pub fn new(position: lsp::Position, source_map: Rc<SourceMap>) -> Self { + Self { + maybe_range: None, + maybe_specifier: None, + position, + source_map, + } + } +} + +impl Visit for ImportLocator { + fn visit_import_decl( + &mut self, + node: &swc_ast::ImportDecl, + _parent: &dyn Node, + ) { + if self.maybe_specifier.is_none() { + let start = self.source_map.lookup_char_pos(node.src.span.lo); + let end = self.source_map.lookup_char_pos(node.src.span.hi); + if span_includes_pos(&self.position, &start, &end) { + self.maybe_range = Some(lsp::Range { + start: lsp::Position { + line: (start.line - 1) as u32, + character: (start.col_display + 1) as u32, + }, + end: lsp::Position { + line: (end.line - 1) as u32, + character: (end.col_display - 1) as u32, + }, + }); + self.maybe_specifier = Some(node.src.value.to_string()); + } + } + } + + fn visit_named_export( + &mut self, + node: &swc_ast::NamedExport, + _parent: &dyn Node, + ) { + if self.maybe_specifier.is_none() { + if let Some(src) = &node.src { + let start = self.source_map.lookup_char_pos(src.span.lo); + let end = self.source_map.lookup_char_pos(src.span.hi); + if span_includes_pos(&self.position, &start, &end) { + self.maybe_specifier = Some(src.value.to_string()); + } + } + } + } + + fn visit_export_all( + &mut self, + node: &swc_ast::ExportAll, + _parent: &dyn Node, + ) { + if self.maybe_specifier.is_none() { + let start = self.source_map.lookup_char_pos(node.src.span.lo); + let end = self.source_map.lookup_char_pos(node.src.span.hi); + if span_includes_pos(&self.position, &start, &end) { + self.maybe_specifier = Some(node.src.value.to_string()); + } + } + } + + fn visit_ts_import_type( + &mut self, + node: &swc_ast::TsImportType, + _parent: &dyn Node, + ) { + if self.maybe_specifier.is_none() { + let start = self.source_map.lookup_char_pos(node.arg.span.lo); + let end = self.source_map.lookup_char_pos(node.arg.span.hi); + if span_includes_pos(&self.position, &start, &end) { + self.maybe_specifier = Some(node.arg.value.to_string()); + } + } + } +} + +/// Determine if the provided position falls into an module specifier of an +/// import/export statement, optionally returning the current value of the +/// specifier. +fn is_module_specifier_position( + specifier: &ModuleSpecifier, + source: &str, + media_type: &MediaType, + position: &lsp::Position, +) -> Option<(String, lsp::Range)> { + if let Ok(parsed_module) = + analysis::parse_module(specifier, source, media_type) + { + let mut import_locator = + ImportLocator::new(*position, parsed_module.source_map.clone()); + parsed_module + .module + .visit_with(&swc_ast::Invalid { span: DUMMY_SP }, &mut import_locator); + if let (Some(specifier), Some(range)) = + (import_locator.maybe_specifier, import_locator.maybe_range) + { + Some((specifier, range)) + } else { + None + } + } else { + None + } +} + +/// Converts a specifier into a relative specifier to the provided base +/// specifier as a string. If a relative path cannot be found, then the +/// specifier is simply returned as a string. +/// +/// ``` +/// use deno_core::resolve_url; +/// +/// let specifier = resolve_url("file:///a/b.ts").unwrap(); +/// let base = resolve_url("file:///a/c/d.ts").unwrap(); +/// assert_eq!(relative_specifier(&specifier, &base), "../b.ts"); +/// ``` +/// +fn relative_specifier( + specifier: &ModuleSpecifier, + base: &ModuleSpecifier, +) -> String { + if specifier.cannot_be_a_base() + || base.cannot_be_a_base() + || specifier.scheme() != base.scheme() + || specifier.host() != base.host() + || specifier.port_or_known_default() != base.port_or_known_default() + { + if specifier.scheme() == "file" { + specifier.to_file_path().unwrap().to_string_lossy().into() + } else { + specifier.as_str().into() + } + } else if let (Some(iter_a), Some(iter_b)) = + (specifier.path_segments(), base.path_segments()) + { + let mut vec_a: Vec<&str> = iter_a.collect(); + let mut vec_b: Vec<&str> = iter_b.collect(); + let last_a = if !specifier.path().ends_with('/') && !vec_a.is_empty() { + vec_a.pop().unwrap() + } else { + "" + }; + let is_dir_b = base.path().ends_with('/'); + if !is_dir_b && !vec_b.is_empty() { + vec_b.pop(); + } + if !vec_a.is_empty() && !vec_b.is_empty() && base.path() != "/" { + let mut parts: Vec<&str> = Vec::new(); + let mut segments_a = vec_a.into_iter(); + let mut segments_b = vec_b.into_iter(); + loop { + match (segments_a.next(), segments_b.next()) { + (None, None) => break, + (Some(a), None) => { + if parts.is_empty() { + parts.push(CURRENT_PATH); + } + parts.push(a); + parts.extend(segments_a.by_ref()); + break; + } + (None, _) if is_dir_b => parts.push(CURRENT_PATH), + (None, _) => parts.push(PARENT_PATH), + (Some(a), Some(b)) if parts.is_empty() && a == b => (), + (Some(a), Some(b)) if b == CURRENT_PATH => parts.push(a), + (Some(_), Some(b)) if b == PARENT_PATH => { + return specifier[Position::BeforePath..].to_string() + } + (Some(a), Some(_)) => { + if parts.is_empty() && is_dir_b { + parts.push(CURRENT_PATH); + } else { + parts.push(PARENT_PATH); + } + // actually the clippy suggestions here are less readable for once + #[allow(clippy::same_item_push)] + for _ in segments_b { + parts.push(PARENT_PATH); + } + parts.push(a); + parts.extend(segments_a.by_ref()); + break; + } + } + } + if parts.is_empty() { + format!( + "./{}{}", + last_a, + specifier[Position::AfterPath..].to_string() + ) + } else { + parts.push(last_a); + format!( + "{}{}", + parts.join("/"), + specifier[Position::AfterPath..].to_string() + ) + } + } else { + specifier[Position::BeforePath..].into() + } + } else { + specifier[Position::BeforePath..].into() + } +} + +/// Does the position fall within the start and end location? +fn span_includes_pos(position: &lsp::Position, start: &Loc, end: &Loc) -> bool { + (position.line > (start.line - 1) as u32 + || position.line == (start.line - 1) as u32 + && position.character >= start.col_display as u32) + && (position.line < (end.line - 1) as u32 + || position.line == (end.line - 1) as u32 + && position.character <= end.col_display as u32) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::http_cache::HttpCache; + use crate::lsp::analysis; + use crate::lsp::documents::DocumentCache; + use crate::lsp::sources::Sources; + use crate::media_type::MediaType; + use deno_core::resolve_url; + use std::collections::HashMap; + use std::path::Path; + use tempfile::TempDir; + + fn mock_state_snapshot( + fixtures: &[(&str, &str, i32)], + source_fixtures: &[(&str, &str)], + location: &Path, + ) -> language_server::StateSnapshot { + let mut documents = DocumentCache::default(); + for (specifier, source, version) in fixtures { + let specifier = + resolve_url(specifier).expect("failed to create specifier"); + documents.open(specifier.clone(), *version, source); + let media_type = MediaType::from(&specifier); + let parsed_module = + analysis::parse_module(&specifier, source, &media_type).unwrap(); + let (deps, _) = analysis::analyze_dependencies( + &specifier, + &media_type, + &parsed_module, + &None, + ); + documents.set_dependencies(&specifier, Some(deps)).unwrap(); + } + let sources = Sources::new(location); + let http_cache = HttpCache::new(location); + for (specifier, source) in source_fixtures { + let specifier = + resolve_url(specifier).expect("failed to create specifier"); + http_cache + .set(&specifier, HashMap::default(), source.as_bytes()) + .expect("could not cache file"); + assert!( + sources.get_source(&specifier).is_some(), + "source could not be setup" + ); + } + language_server::StateSnapshot { + documents, + sources, + ..Default::default() + } + } + + fn setup( + documents: &[(&str, &str, i32)], + sources: &[(&str, &str)], + ) -> language_server::StateSnapshot { + let temp_dir = TempDir::new().expect("could not create temp dir"); + let location = temp_dir.path().join("deps"); + mock_state_snapshot(documents, sources, &location) + } + + #[test] + fn test_get_relative_specifiers() { + let base = resolve_url("file:///a/b/c.ts").unwrap(); + let specifiers = vec![ + resolve_url("file:///a/b/c.ts").unwrap(), + resolve_url("file:///a/b/d.ts").unwrap(), + resolve_url("file:///a/c/c.ts").unwrap(), + resolve_url("file:///a/b/d/d.ts").unwrap(), + resolve_url("https://deno.land/x/a/b/c.ts").unwrap(), + ]; + assert_eq!( + get_relative_specifiers(&base, specifiers), + vec![ + "./d.ts".to_string(), + "../c/c.ts".to_string(), + "./d/d.ts".to_string(), + "https://deno.land/x/a/b/c.ts".to_string(), + ] + ); + } + + #[test] + fn test_relative_specifier() { + let fixtures: Vec<(&str, &str, &str)> = vec![ + ( + "https://deno.land/x/a/b/c.ts", + "https://deno.land/x/a/b/d.ts", + "./c.ts", + ), + ( + "https://deno.land/x/a/c.ts", + "https://deno.land/x/a/b/d.ts", + "../c.ts", + ), + ( + "https://deno.land/x/a/b/c/d.ts", + "https://deno.land/x/a/b/d.ts", + "./c/d.ts", + ), + ( + "https://deno.land/x/a/b/c/d.ts", + "https://deno.land/x/a/b/c/", + "./d.ts", + ), + ( + "https://deno.land/x/a/b/c/d/e.ts", + "https://deno.land/x/a/b/c/", + "./d/e.ts", + ), + ( + "https://deno.land/x/a/b/c/d/e.ts", + "https://deno.land/x/a/b/c/f.ts", + "./d/e.ts", + ), + ( + "https://deno.land/x/a/c.ts?foo=bar", + "https://deno.land/x/a/b/d.ts", + "../c.ts?foo=bar", + ), + ( + "https://deno.land/x/a/b/c.ts", + "https://deno.land/x/a/b/d.ts?foo=bar", + "./c.ts", + ), + #[cfg(not(windows))] + ("file:///a/b/c.ts", "file:///a/b/d.ts", "./c.ts"), + #[cfg(not(windows))] + ( + "file:///a/b/c.ts", + "https://deno.land/x/a/b/c.ts", + "/a/b/c.ts", + ), + ( + "https://deno.land/x/a/b/c.ts", + "https://deno.land/", + "/x/a/b/c.ts", + ), + ( + "https://deno.land/x/a/b/c.ts", + "https://deno.land/x/d/e/f.ts", + "../../a/b/c.ts", + ), + ]; + for (specifier_str, base_str, expected) in fixtures { + let specifier = resolve_url(specifier_str).unwrap(); + let base = resolve_url(base_str).unwrap(); + let actual = relative_specifier(&specifier, &base); + assert_eq!( + actual, expected, + "specifier: \"{}\" base: \"{}\"", + specifier_str, base_str + ); + } + } + + #[test] + fn test_is_module_specifier_position() { + let specifier = resolve_url("file:///a/b/c.ts").unwrap(); + let source = r#"import * as a from """#; + let media_type = MediaType::TypeScript; + assert_eq!( + is_module_specifier_position( + &specifier, + source, + &media_type, + &lsp::Position { + line: 0, + character: 0 + } + ), + None + ); + assert_eq!( + is_module_specifier_position( + &specifier, + source, + &media_type, + &lsp::Position { + line: 0, + character: 20 + } + ), + Some(( + "".to_string(), + lsp::Range { + start: lsp::Position { + line: 0, + character: 20 + }, + end: lsp::Position { + line: 0, + character: 20 + } + } + )) + ); + } + + #[test] + fn test_is_module_specifier_position_partial() { + let specifier = resolve_url("file:///a/b/c.ts").unwrap(); + let source = r#"import * as a from "https://""#; + let media_type = MediaType::TypeScript; + assert_eq!( + is_module_specifier_position( + &specifier, + source, + &media_type, + &lsp::Position { + line: 0, + character: 0 + } + ), + None + ); + assert_eq!( + is_module_specifier_position( + &specifier, + source, + &media_type, + &lsp::Position { + line: 0, + character: 28 + } + ), + Some(( + "https://".to_string(), + lsp::Range { + start: lsp::Position { + line: 0, + character: 20 + }, + end: lsp::Position { + line: 0, + character: 28 + } + } + )) + ); + } + + #[test] + fn test_get_local_completions() { + let temp_dir = TempDir::new().expect("could not create temp dir"); + let fixtures = temp_dir.path().join("fixtures"); + std::fs::create_dir(&fixtures).expect("could not create"); + let dir_a = fixtures.join("a"); + std::fs::create_dir(&dir_a).expect("could not create"); + let dir_b = dir_a.join("b"); + std::fs::create_dir(&dir_b).expect("could not create"); + let file_c = dir_a.join("c.ts"); + std::fs::write(&file_c, b"").expect("could not create"); + let file_d = dir_b.join("d.ts"); + std::fs::write(&file_d, b"").expect("could not create"); + let file_e = dir_a.join("e.txt"); + std::fs::write(&file_e, b"").expect("could not create"); + let file_f = dir_a.join("f.mjs"); + std::fs::write(&file_f, b"").expect("could not create"); + let specifier = + ModuleSpecifier::from_file_path(file_c).expect("could not create"); + let actual = get_local_completions( + &specifier, + "./", + &lsp::Range { + start: lsp::Position { + line: 0, + character: 20, + }, + end: lsp::Position { + line: 0, + character: 22, + }, + }, + ); + assert!(actual.is_some()); + let actual = actual.unwrap(); + assert_eq!(actual.len(), 2); + for item in actual { + match item.text_edit { + Some(lsp::CompletionTextEdit::Edit(text_edit)) => { + assert!( + text_edit.new_text == "./f.mjs" || text_edit.new_text == "./b" + ); + } + _ => unreachable!(), + } + } + } + + #[test] + fn test_get_import_completions() { + let specifier = resolve_url("file:///a/b/c.ts").unwrap(); + let position = lsp::Position { + line: 0, + character: 21, + }; + let state_snapshot = setup( + &[ + ("file:///a/b/c.ts", "import * as d from \"h\"", 1), + ("file:///a/c.ts", r#""#, 1), + ], + &[("https://deno.land/x/a/b/c.ts", "console.log(1);\n")], + ); + let actual = get_import_completions(&specifier, &position, &state_snapshot); + assert_eq!( + actual, + Some(lsp::CompletionResponse::List(lsp::CompletionList { + is_incomplete: false, + items: vec![lsp::CompletionItem { + label: "https://deno.land/x/a/b/c.ts".to_string(), + kind: Some(lsp::CompletionItemKind::File), + detail: Some("(remote)".to_string()), + sort_text: Some("1".to_string()), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range { + start: lsp::Position { + line: 0, + character: 20 + }, + end: lsp::Position { + line: 0, + character: 21, + } + }, + new_text: "https://deno.land/x/a/b/c.ts".to_string(), + })), + ..Default::default() + }] + })) + ); + } +} |