diff options
author | David Sherret <dsherret@users.noreply.github.com> | 2023-07-01 21:07:57 -0400 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-07-02 01:07:57 +0000 |
commit | cfbc9b471f9ae0a00639b69623068021a6cfcbbd (patch) | |
tree | 6609ee07363f0e5b415b9a12706dd72fa93d41c7 /cli/lsp | |
parent | e746b6d80654ba4e4e26370fe6e4f784ce841d92 (diff) |
feat(lsp): basic support of auto-imports for npm specifiers (#19675)
Closes #19625
Closes https://github.com/denoland/vscode_deno/issues/857
Diffstat (limited to 'cli/lsp')
-rw-r--r-- | cli/lsp/analysis.rs | 248 | ||||
-rw-r--r-- | cli/lsp/language_server.rs | 11 | ||||
-rw-r--r-- | cli/lsp/tsc.rs | 11 |
3 files changed, 249 insertions, 21 deletions
diff --git a/cli/lsp/analysis.rs b/cli/lsp/analysis.rs index a5ebbbbb8..80c748055 100644 --- a/cli/lsp/analysis.rs +++ b/cli/lsp/analysis.rs @@ -5,6 +5,8 @@ use super::documents::Documents; use super::language_server; use super::tsc; +use crate::npm::CliNpmResolver; +use crate::npm::NpmResolution; use crate::tools::lint::create_linter; use deno_ast::SourceRange; @@ -14,13 +16,17 @@ use deno_core::anyhow::anyhow; use deno_core::error::custom_error; use deno_core::error::AnyError; use deno_core::serde::Deserialize; +use deno_core::serde_json; use deno_core::serde_json::json; use deno_core::ModuleSpecifier; use deno_lint::rules::LintRule; +use deno_runtime::deno_node::PackageJson; +use deno_runtime::deno_node::PathClean; use once_cell::sync::Lazy; use regex::Regex; use std::cmp::Ordering; use std::collections::HashMap; +use std::path::Path; use tower_lsp::lsp_types as lsp; use tower_lsp::lsp_types::Position; use tower_lsp::lsp_types::Range; @@ -148,21 +154,169 @@ fn code_as_string(code: &Option<lsp::NumberOrString>) -> String { } } -/// Iterate over the supported extensions, concatenating the extension on the -/// specifier, returning the first specifier that is resolve-able, otherwise -/// None if none match. -fn check_specifier( - specifier: &str, - referrer: &ModuleSpecifier, - documents: &Documents, +/// Rewrites imports in quick fixes and code changes to be Deno specific. +pub struct TsResponseImportMapper<'a> { + documents: &'a Documents, + npm_resolution: &'a NpmResolution, + npm_resolver: &'a CliNpmResolver, +} + +impl<'a> TsResponseImportMapper<'a> { + pub fn new( + documents: &'a Documents, + npm_resolution: &'a NpmResolution, + npm_resolver: &'a CliNpmResolver, + ) -> Self { + Self { + documents, + npm_resolution, + npm_resolver, + } + } + + pub fn check_specifier(&self, specifier: &ModuleSpecifier) -> Option<String> { + if self.npm_resolver.in_npm_package(specifier) { + if let Ok(pkg_id) = self + .npm_resolver + .resolve_package_id_from_specifier(specifier) + { + // todo(dsherret): once supporting an import map, we should prioritize which + // pkg requirement we use, based on what's specified in the import map + if let Some(pkg_req) = self + .npm_resolution + .resolve_pkg_reqs_from_pkg_id(&pkg_id) + .first() + { + let result = format!("npm:{}", pkg_req); + return Some(match self.resolve_package_path(specifier) { + Some(path) => format!("{}/{}", result, path), + None => result, + }); + } + } + } + None + } + + fn resolve_package_path( + &self, + specifier: &ModuleSpecifier, + ) -> Option<String> { + let specifier_path = specifier.to_file_path().ok()?; + let root_folder = self + .npm_resolver + .resolve_package_folder_from_specifier(specifier) + .ok()?; + let package_json_path = root_folder.join("package.json"); + let package_json_text = std::fs::read_to_string(&package_json_path).ok()?; + let package_json = + PackageJson::load_from_string(package_json_path, package_json_text) + .ok()?; + + let mut search_paths = vec![specifier_path.clone()]; + // TypeScript will provide a .js extension for quick fixes, so do + // a search for the .d.ts file instead + if specifier_path.extension().and_then(|e| e.to_str()) == Some("js") { + search_paths.insert(0, specifier_path.with_extension("d.ts")); + } + + for search_path in search_paths { + if let Some(exports) = &package_json.exports { + if let Some(result) = try_reverse_map_package_json_exports( + &root_folder, + &search_path, + exports, + ) { + return Some(result); + } + } + } + + None + } + + /// Iterate over the supported extensions, concatenating the extension on the + /// specifier, returning the first specifier that is resolve-able, otherwise + /// None if none match. + pub fn check_specifier_with_referrer( + &self, + specifier: &str, + referrer: &ModuleSpecifier, + ) -> Option<String> { + if let Ok(specifier) = referrer.join(specifier) { + if let Some(specifier) = self.check_specifier(&specifier) { + return Some(specifier); + } + } + for ext in SUPPORTED_EXTENSIONS { + let specifier_with_ext = format!("{specifier}{ext}"); + if self + .documents + .contains_import(&specifier_with_ext, referrer) + { + return Some(specifier_with_ext); + } + } + None + } +} + +fn try_reverse_map_package_json_exports( + root_path: &Path, + target_path: &Path, + exports: &serde_json::Map<String, serde_json::Value>, ) -> Option<String> { - for ext in SUPPORTED_EXTENSIONS { - let specifier_with_ext = format!("{specifier}{ext}"); - if documents.contains_import(&specifier_with_ext, referrer) { - return Some(specifier_with_ext); + use deno_core::serde_json::Value; + + fn try_reverse_map_package_json_exports_inner( + root_path: &Path, + target_path: &Path, + exports: &serde_json::Map<String, Value>, + ) -> Option<String> { + for (key, value) in exports { + match value { + Value::String(str) => { + if root_path.join(str).clean() == target_path { + return Some(if let Some(suffix) = key.strip_prefix("./") { + suffix.to_string() + } else { + String::new() // condition (ex. "types"), ignore + }); + } + } + Value::Object(obj) => { + if let Some(result) = try_reverse_map_package_json_exports_inner( + root_path, + target_path, + obj, + ) { + return Some(if let Some(suffix) = key.strip_prefix("./") { + if result.is_empty() { + suffix.to_string() + } else { + format!("{}/{}", suffix, result) + } + } else { + result // condition (ex. "types"), ignore + }); + } + } + _ => {} + } } + None + } + + let result = try_reverse_map_package_json_exports_inner( + root_path, + target_path, + exports, + )?; + if result.is_empty() { + None + } else { + Some(result) } - None } /// For a set of tsc changes, can them for any that contain something that looks @@ -170,7 +324,7 @@ fn check_specifier( pub fn fix_ts_import_changes( referrer: &ModuleSpecifier, changes: &[tsc::FileTextChanges], - documents: &Documents, + import_mapper: &TsResponseImportMapper, ) -> Result<Vec<tsc::FileTextChanges>, AnyError> { let mut r = Vec::new(); for change in changes { @@ -184,7 +338,7 @@ pub fn fix_ts_import_changes( if let Some(captures) = IMPORT_SPECIFIER_RE.captures(line) { let specifier = captures.get(1).unwrap().as_str(); if let Some(new_specifier) = - check_specifier(specifier, referrer, documents) + import_mapper.check_specifier_with_referrer(specifier, referrer) { line.replace(specifier, &new_specifier) } else { @@ -215,7 +369,7 @@ pub fn fix_ts_import_changes( fn fix_ts_import_action( referrer: &ModuleSpecifier, action: &tsc::CodeFixAction, - documents: &Documents, + import_mapper: &TsResponseImportMapper, ) -> Result<tsc::CodeFixAction, AnyError> { if action.fix_name == "import" { let change = action @@ -233,7 +387,7 @@ fn fix_ts_import_action( .ok_or_else(|| anyhow!("Missing capture."))? .as_str(); if let Some(new_specifier) = - check_specifier(specifier, referrer, documents) + import_mapper.check_specifier_with_referrer(specifier, referrer) { let description = action.description.replace(specifier, &new_specifier); let changes = action @@ -554,8 +708,11 @@ impl CodeActionCollection { "The action returned from TypeScript is unsupported.", )); } - let action = - fix_ts_import_action(specifier, action, &language_server.documents)?; + let action = fix_ts_import_action( + specifier, + action, + &language_server.get_ts_response_import_mapper(), + )?; let edit = ts_changes_to_edit(&action.changes, language_server)?; let code_action = lsp::CodeAction { title: action.description.clone(), @@ -735,6 +892,8 @@ pub fn source_range_to_lsp_range( #[cfg(test)] mod tests { + use std::path::PathBuf; + use super::*; #[test] @@ -824,4 +983,57 @@ mod tests { } ); } + + #[test] + fn test_try_reverse_map_package_json_exports() { + let exports = json!({ + ".": { + "types": "./src/index.d.ts", + "browser": "./dist/module.js", + }, + "./hooks": { + "types": "./hooks/index.d.ts", + "browser": "./dist/devtools.module.js", + }, + "./utils": { + "types": { + "./sub_utils": "./utils_sub_utils.d.ts" + } + } + }); + let exports = exports.as_object().unwrap(); + assert_eq!( + try_reverse_map_package_json_exports( + &PathBuf::from("/project/"), + &PathBuf::from("/project/hooks/index.d.ts"), + exports, + ) + .unwrap(), + "hooks" + ); + assert_eq!( + try_reverse_map_package_json_exports( + &PathBuf::from("/project/"), + &PathBuf::from("/project/dist/devtools.module.js"), + exports, + ) + .unwrap(), + "hooks" + ); + assert!(try_reverse_map_package_json_exports( + &PathBuf::from("/project/"), + &PathBuf::from("/project/src/index.d.ts"), + exports, + ) + .is_none()); + assert_eq!( + try_reverse_map_package_json_exports( + &PathBuf::from("/project/"), + &PathBuf::from("/project/utils_sub_utils.d.ts"), + exports, + ) + .unwrap(), + "utils/sub_utils" + ); + } } diff --git a/cli/lsp/language_server.rs b/cli/lsp/language_server.rs index 95bdf8724..0b6051212 100644 --- a/cli/lsp/language_server.rs +++ b/cli/lsp/language_server.rs @@ -38,6 +38,7 @@ use super::analysis::fix_ts_import_changes; use super::analysis::ts_changes_to_edit; use super::analysis::CodeActionCollection; use super::analysis::CodeActionData; +use super::analysis::TsResponseImportMapper; use super::cache; use super::capabilities; use super::client::Client; @@ -2029,7 +2030,7 @@ impl Inner { fix_ts_import_changes( &code_action_data.specifier, &combined_code_actions.changes, - &self.documents, + &self.get_ts_response_import_mapper(), ) .map_err(|err| { error!("Unable to remap changes: {}", err); @@ -2081,6 +2082,14 @@ impl Inner { Ok(result) } + pub fn get_ts_response_import_mapper(&self) -> TsResponseImportMapper { + TsResponseImportMapper::new( + &self.documents, + &self.npm.resolution, + &self.npm.resolver, + ) + } + async fn code_lens( &self, params: CodeLensParams, diff --git a/cli/lsp/tsc.rs b/cli/lsp/tsc.rs index 20edf31d9..00a5d2bc7 100644 --- a/cli/lsp/tsc.rs +++ b/cli/lsp/tsc.rs @@ -1,6 +1,7 @@ // Copyright 2018-2023 the Deno authors. All rights reserved. MIT license. use super::analysis::CodeActionData; +use super::analysis::TsResponseImportMapper; use super::code_lens; use super::config; use super::documents::AssetOrDocument; @@ -2326,6 +2327,7 @@ fn parse_code_actions( update_import_statement( tc.as_text_edit(asset_or_doc.line_index()), data, + Some(&language_server.get_ts_response_import_mapper()), ) })); } else { @@ -2521,6 +2523,7 @@ struct CompletionEntryDataImport { fn update_import_statement( mut text_edit: lsp::TextEdit, item_data: &CompletionItemData, + maybe_import_mapper: Option<&TsResponseImportMapper>, ) -> lsp::TextEdit { if let Some(data) = &item_data.data { if let Ok(import_data) = @@ -2528,8 +2531,11 @@ fn update_import_statement( { if let Ok(import_specifier) = normalize_specifier(&import_data.file_name) { - if let Some(new_module_specifier) = - relative_specifier(&item_data.specifier, &import_specifier) + if let Some(new_module_specifier) = maybe_import_mapper + .and_then(|m| m.check_specifier(&import_specifier)) + .or_else(|| { + relative_specifier(&item_data.specifier, &import_specifier) + }) { text_edit.new_text = text_edit .new_text @@ -4716,6 +4722,7 @@ mod tests { new_text: orig_text.to_string(), }, &item_data, + None, ); assert_eq!( actual, |