From ddfbe71cedbfe2ac31dbc7dbcf25761e5a7a1dce Mon Sep 17 00:00:00 2001 From: David Sherret Date: Fri, 8 Dec 2023 09:57:06 -0500 Subject: [PATCH] feat(lsp): provide quick fixes for specifiers that could be resolved sloppily (#21506) --- cli/graph_util.rs | 97 ++------------------- cli/lsp/diagnostics.rs | 96 ++++++++++++++++----- cli/lsp/documents.rs | 3 +- cli/lsp/language_server.rs | 2 +- cli/module_loader.rs | 7 +- cli/resolver.rs | 133 +++++++++++++++++++++++++---- cli/tests/integration/lsp_tests.rs | 76 ++++++++++++++++- cli/tools/bench/mod.rs | 2 +- cli/tools/test/mod.rs | 2 +- cli/tools/vendor/build.rs | 3 +- 10 files changed, 285 insertions(+), 136 deletions(-) diff --git a/cli/graph_util.rs b/cli/graph_util.rs index eba88e4d0d..95351ba86f 100644 --- a/cli/graph_util.rs +++ b/cli/graph_util.rs @@ -12,7 +12,6 @@ use crate::errors::get_error_class_name; use crate::file_fetcher::FileFetcher; use crate::npm::CliNpmResolver; use crate::resolver::CliGraphResolver; -use crate::resolver::SloppyImportsResolution; use crate::resolver::SloppyImportsResolver; use crate::tools::check; use crate::tools::check::TypeChecker; @@ -20,7 +19,6 @@ use crate::util::file_watcher::WatcherCommunicator; use crate::util::sync::TaskQueue; use crate::util::sync::TaskQueuePermit; -use deno_ast::MediaType; use deno_core::anyhow::bail; use deno_core::anyhow::Context; use deno_core::error::custom_error; @@ -61,7 +59,7 @@ pub struct GraphValidOptions { /// error statically reachable from `roots` and not a dynamic import. pub fn graph_valid_with_cli_options( graph: &ModuleGraph, - fs: &Arc, + fs: &dyn FileSystem, roots: &[ModuleSpecifier], options: &CliOptions, ) -> Result<(), AnyError> { @@ -86,7 +84,7 @@ pub fn graph_valid_with_cli_options( /// for the CLI. pub fn graph_valid( graph: &ModuleGraph, - fs: &Arc, + fs: &dyn FileSystem, roots: &[ModuleSpecifier], options: GraphValidOptions, ) -> Result<(), AnyError> { @@ -366,7 +364,7 @@ impl ModuleGraphBuilder { let graph = Arc::new(graph); graph_valid_with_cli_options( &graph, - &self.fs, + self.fs.as_ref(), &graph.roots, &self.options, )?; @@ -538,12 +536,13 @@ pub fn enhanced_resolution_error_message(error: &ResolutionError) -> String { } pub fn enhanced_module_error_message( - fs: &Arc, + fs: &dyn FileSystem, error: &ModuleError, ) -> String { let additional_message = match error { ModuleError::Missing(specifier, _) => { - maybe_sloppy_imports_suggestion_message(fs, specifier) + SloppyImportsResolver::resolve_with_fs(fs, specifier) + .as_suggestion_message() } _ => None, }; @@ -557,48 +556,6 @@ pub fn enhanced_module_error_message( } } -pub fn maybe_sloppy_imports_suggestion_message( - fs: &Arc, - original_specifier: &ModuleSpecifier, -) -> Option { - let sloppy_imports_resolver = SloppyImportsResolver::new(fs.clone()); - let resolution = sloppy_imports_resolver.resolve(original_specifier); - sloppy_import_resolution_to_suggestion_message(&resolution) -} - -fn sloppy_import_resolution_to_suggestion_message( - resolution: &SloppyImportsResolution, -) -> Option { - match resolution { - SloppyImportsResolution::None(_) => None, - SloppyImportsResolution::JsToTs(specifier) => { - let media_type = MediaType::from_specifier(specifier); - Some(format!( - "Maybe change the extension to '{}'", - media_type.as_ts_extension() - )) - } - SloppyImportsResolution::NoExtension(specifier) => { - let media_type = MediaType::from_specifier(specifier); - Some(format!( - "Maybe add a '{}' extension", - media_type.as_ts_extension() - )) - } - SloppyImportsResolution::Directory(specifier) => { - let file_name = specifier - .path() - .rsplit_once('/') - .map(|(_, file_name)| file_name) - .unwrap_or(specifier.path()); - Some(format!( - "Maybe specify path to '{}' file in directory instead", - file_name - )) - } - } -} - pub fn get_resolution_error_bare_node_specifier( error: &ResolutionError, ) -> Option<&str> { @@ -972,46 +929,4 @@ mod test { assert_eq!(get_resolution_error_bare_node_specifier(&err), output,); } } - - #[test] - fn test_sloppy_import_resolution_to_message() { - // none - let url = ModuleSpecifier::parse("file:///dir/index.js").unwrap(); - assert_eq!( - sloppy_import_resolution_to_suggestion_message( - &SloppyImportsResolution::None(&url) - ), - None, - ); - // directory - assert_eq!( - sloppy_import_resolution_to_suggestion_message( - &SloppyImportsResolution::Directory( - ModuleSpecifier::parse("file:///dir/index.js").unwrap() - ) - ) - .unwrap(), - "Maybe specify path to 'index.js' file in directory instead" - ); - // no ext - assert_eq!( - sloppy_import_resolution_to_suggestion_message( - &SloppyImportsResolution::NoExtension( - ModuleSpecifier::parse("file:///dir/index.mjs").unwrap() - ) - ) - .unwrap(), - "Maybe add a '.mjs' extension" - ); - // js to ts - assert_eq!( - sloppy_import_resolution_to_suggestion_message( - &SloppyImportsResolution::JsToTs( - ModuleSpecifier::parse("file:///dir/index.mts").unwrap() - ) - ) - .unwrap(), - "Maybe change the extension to '.mts'" - ); - } } diff --git a/cli/lsp/diagnostics.rs b/cli/lsp/diagnostics.rs index 4dbb4e1dd0..8034127e90 100644 --- a/cli/lsp/diagnostics.rs +++ b/cli/lsp/diagnostics.rs @@ -19,6 +19,8 @@ use crate::args::LintOptions; use crate::graph_util; use crate::graph_util::enhanced_resolution_error_message; use crate::lsp::lsp_custom::DiagnosticBatchNotificationParams; +use crate::resolver::SloppyImportsResolution; +use crate::resolver::SloppyImportsResolver; use crate::tools::lint::get_configured_rules; use deno_ast::MediaType; @@ -938,6 +940,13 @@ struct DiagnosticDataRedirect { pub redirect: ModuleSpecifier, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DiagnosticDataNoLocal { + pub to: ModuleSpecifier, + pub message: String, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct DiagnosticDataImportMapRemap { @@ -1084,6 +1093,32 @@ impl DenoDiagnostic { ..Default::default() } } + "no-local" => { + let data = diagnostic + .data + .clone() + .ok_or_else(|| anyhow!("Diagnostic is missing data"))?; + let data: DiagnosticDataNoLocal = serde_json::from_value(data)?; + lsp::CodeAction { + title: data.message, + kind: Some(lsp::CodeActionKind::QUICKFIX), + diagnostics: Some(vec![diagnostic.clone()]), + edit: Some(lsp::WorkspaceEdit { + changes: Some(HashMap::from([( + specifier.clone(), + vec![lsp::TextEdit { + new_text: format!( + "\"{}\"", + relative_specifier(&data.to, specifier) + ), + range: diagnostic.range, + }], + )])), + ..Default::default() + }), + ..Default::default() + } + } "redirect" => { let data = diagnostic .data @@ -1150,15 +1185,16 @@ impl DenoDiagnostic { /// diagnostic is fixable or not pub fn is_fixable(diagnostic: &lsp_types::Diagnostic) -> bool { if let Some(lsp::NumberOrString::String(code)) = &diagnostic.code { - matches!( - code.as_str(), + match code.as_str() { "import-map-remap" - | "no-cache" - | "no-cache-npm" - | "no-attribute-type" - | "redirect" - | "import-node-prefix-missing" - ) + | "no-cache" + | "no-cache-npm" + | "no-attribute-type" + | "redirect" + | "import-node-prefix-missing" => true, + "no-local" => diagnostic.data.is_some(), + _ => false, + } } else { false } @@ -1167,12 +1203,14 @@ impl DenoDiagnostic { /// Convert to an lsp Diagnostic when the range the diagnostic applies to is /// provided. pub fn to_lsp_diagnostic(&self, range: &lsp::Range) -> lsp::Diagnostic { - fn no_local_message(specifier: &ModuleSpecifier) -> String { - let fs: Arc = Arc::new(deno_fs::RealFs); + fn no_local_message( + specifier: &ModuleSpecifier, + sloppy_resolution: SloppyImportsResolution, + ) -> String { let mut message = format!("Unable to load a local module: {}\n", specifier); if let Some(additional_message) = - graph_util::maybe_sloppy_imports_suggestion_message(&fs, specifier) + sloppy_resolution.as_suggestion_message() { message.push_str(&additional_message); message.push('.'); @@ -1189,7 +1227,17 @@ impl DenoDiagnostic { Self::NoAttributeType => (lsp::DiagnosticSeverity::ERROR, "The module is a JSON module and not being imported with an import attribute. Consider adding `with { type: \"json\" }` to the import statement.".to_string(), None), Self::NoCache(specifier) => (lsp::DiagnosticSeverity::ERROR, format!("Uncached or missing remote URL: {specifier}"), Some(json!({ "specifier": specifier }))), Self::NoCacheNpm(pkg_req, specifier) => (lsp::DiagnosticSeverity::ERROR, format!("Uncached or missing npm package: {}", pkg_req), Some(json!({ "specifier": specifier }))), - Self::NoLocal(specifier) => (lsp::DiagnosticSeverity::ERROR, no_local_message(specifier), None), + Self::NoLocal(specifier) => { + let sloppy_resolution = SloppyImportsResolver::resolve_with_fs(&deno_fs::RealFs, specifier); + let data = sloppy_resolution.as_lsp_quick_fix_message().map(|message| { + json!({ + "specifier": specifier, + "to": sloppy_resolution.as_specifier(), + "message": message, + }) + }); + (lsp::DiagnosticSeverity::ERROR, no_local_message(specifier, sloppy_resolution), data) + }, Self::Redirect { from, to} => (lsp::DiagnosticSeverity::INFORMATION, format!("The import of \"{from}\" was redirected to \"{to}\"."), Some(json!({ "specifier": from, "redirect": to }))), Self::ResolutionError(err) => ( lsp::DiagnosticSeverity::ERROR, @@ -1218,21 +1266,25 @@ fn specifier_text_for_redirected( ) -> String { if redirect.scheme() == "file" && referrer.scheme() == "file" { // use a relative specifier when it's going to a file url - match referrer.make_relative(redirect) { - Some(relative) => { - if relative.starts_with('.') { - relative - } else { - format!("./{}", relative) - } - } - None => redirect.to_string(), - } + relative_specifier(redirect, referrer) } else { redirect.to_string() } } +fn relative_specifier(specifier: &lsp::Url, referrer: &lsp::Url) -> String { + match referrer.make_relative(specifier) { + Some(relative) => { + if relative.starts_with('.') { + relative + } else { + format!("./{}", relative) + } + } + None => specifier.to_string(), + } +} + fn diagnose_resolution( snapshot: &language_server::StateSnapshot, dependency_key: &str, diff --git a/cli/lsp/documents.rs b/cli/lsp/documents.rs index a341ae2079..535f32d3f5 100644 --- a/cli/lsp/documents.rs +++ b/cli/lsp/documents.rs @@ -1055,7 +1055,8 @@ impl Documents { Some( self .resolve_unstable_sloppy_import(specifier) - .into_owned_specifier(), + .into_specifier() + .into_owned(), ) } else { self.redirect_resolver.resolve(specifier) diff --git a/cli/lsp/language_server.rs b/cli/lsp/language_server.rs index bc8a102353..a893ab3a83 100644 --- a/cli/lsp/language_server.rs +++ b/cli/lsp/language_server.rs @@ -263,7 +263,7 @@ impl LanguageServer { .await?; graph_util::graph_valid( &graph, - factory.fs(), + factory.fs().as_ref(), &roots, graph_util::GraphValidOptions { is_vendoring: false, diff --git a/cli/module_loader.rs b/cli/module_loader.rs index afd2d1999d..b10b2f627f 100644 --- a/cli/module_loader.rs +++ b/cli/module_loader.rs @@ -169,7 +169,12 @@ impl ModuleLoadPreparer { ) .await?; - graph_valid_with_cli_options(graph, &self.fs, &roots, &self.options)?; + graph_valid_with_cli_options( + graph, + self.fs.as_ref(), + &roots, + &self.options, + )?; // If there is a lockfile... if let Some(lockfile) = &self.lockfile { diff --git a/cli/resolver.rs b/cli/resolver.rs index 6fd0349790..45a7e865bb 100644 --- a/cli/resolver.rs +++ b/cli/resolver.rs @@ -441,7 +441,7 @@ fn sloppy_imports_resolve( format_range_with_colors(referrer_range) }, ); - resolution.into_owned_specifier() + resolution.into_specifier().into_owned() } fn resolve_package_json_dep( @@ -562,15 +562,11 @@ impl SloppyImportsStatCache { return *entry; } - let entry = self.fs.stat_sync(path).ok().and_then(|stat| { - if stat.is_file { - Some(SloppyImportsFsEntry::File) - } else if stat.is_directory { - Some(SloppyImportsFsEntry::Dir) - } else { - None - } - }); + let entry = self + .fs + .stat_sync(path) + .ok() + .and_then(|stat| SloppyImportsFsEntry::from_fs_stat(&stat)); cache.insert(path.to_owned(), entry); entry } @@ -582,6 +578,20 @@ pub enum SloppyImportsFsEntry { Dir, } +impl SloppyImportsFsEntry { + pub fn from_fs_stat( + stat: &deno_runtime::deno_io::fs::FsStat, + ) -> Option { + if stat.is_file { + Some(SloppyImportsFsEntry::File) + } else if stat.is_directory { + Some(SloppyImportsFsEntry::Dir) + } else { + None + } + } +} + #[derive(Debug, PartialEq, Eq)] pub enum SloppyImportsResolution<'a> { /// No sloppy resolution was found. @@ -595,6 +605,15 @@ pub enum SloppyImportsResolution<'a> { } impl<'a> SloppyImportsResolution<'a> { + pub fn as_specifier(&self) -> &ModuleSpecifier { + match self { + Self::None(specifier) => specifier, + Self::JsToTs(specifier) => specifier, + Self::NoExtension(specifier) => specifier, + Self::Directory(specifier) => specifier, + } + } + pub fn into_specifier(self) -> Cow<'a, ModuleSpecifier> { match self { Self::None(specifier) => Cow::Borrowed(specifier), @@ -604,12 +623,48 @@ impl<'a> SloppyImportsResolution<'a> { } } - pub fn into_owned_specifier(self) -> ModuleSpecifier { + pub fn as_suggestion_message(&self) -> Option { + Some(format!("Maybe {}", self.as_base_message()?)) + } + + pub fn as_lsp_quick_fix_message(&self) -> Option { + let message = self.as_base_message()?; + let mut chars = message.chars(); + Some(format!( + "{}{}.", + chars.next().unwrap().to_uppercase(), + chars.as_str() + )) + } + + fn as_base_message(&self) -> Option { match self { - Self::None(specifier) => specifier.clone(), - Self::JsToTs(specifier) => specifier, - Self::NoExtension(specifier) => specifier, - Self::Directory(specifier) => specifier, + SloppyImportsResolution::None(_) => None, + SloppyImportsResolution::JsToTs(specifier) => { + let media_type = MediaType::from_specifier(specifier); + Some(format!( + "change the extension to '{}'", + media_type.as_ts_extension() + )) + } + SloppyImportsResolution::NoExtension(specifier) => { + let media_type = MediaType::from_specifier(specifier); + Some(format!( + "add a '{}' extension", + media_type.as_ts_extension() + )) + } + SloppyImportsResolution::Directory(specifier) => { + let file_name = specifier + .path() + .rsplit_once('/') + .map(|(_, file_name)| file_name) + .unwrap_or(specifier.path()); + Some(format!( + "specify path to '{}' file in directory instead", + file_name + )) + } } } } @@ -626,6 +681,17 @@ impl SloppyImportsResolver { } } + pub fn resolve_with_fs<'a>( + fs: &dyn FileSystem, + specifier: &'a ModuleSpecifier, + ) -> SloppyImportsResolution<'a> { + Self::resolve_with_stat_sync(specifier, |path| { + fs.stat_sync(path) + .ok() + .and_then(|stat| SloppyImportsFsEntry::from_fs_stat(&stat)) + }) + } + pub fn resolve_with_stat_sync( specifier: &ModuleSpecifier, stat_sync: impl Fn(&Path) -> Option, @@ -885,4 +951,41 @@ mod test { ); } } + + #[test] + fn test_sloppy_import_resolution_suggestion_message() { + // none + let url = ModuleSpecifier::parse("file:///dir/index.js").unwrap(); + assert_eq!( + SloppyImportsResolution::None(&url).as_suggestion_message(), + None, + ); + // directory + assert_eq!( + SloppyImportsResolution::Directory( + ModuleSpecifier::parse("file:///dir/index.js").unwrap() + ) + .as_suggestion_message() + .unwrap(), + "Maybe specify path to 'index.js' file in directory instead" + ); + // no ext + assert_eq!( + SloppyImportsResolution::NoExtension( + ModuleSpecifier::parse("file:///dir/index.mjs").unwrap() + ) + .as_suggestion_message() + .unwrap(), + "Maybe add a '.mjs' extension" + ); + // js to ts + assert_eq!( + SloppyImportsResolution::JsToTs( + ModuleSpecifier::parse("file:///dir/index.mts").unwrap() + ) + .as_suggestion_message() + .unwrap(), + "Maybe change the extension to '.mts'" + ); + } } diff --git a/cli/tests/integration/lsp_tests.rs b/cli/tests/integration/lsp_tests.rs index 9283b40216..98aaaebb49 100644 --- a/cli/tests/integration/lsp_tests.rs +++ b/cli/tests/integration/lsp_tests.rs @@ -10622,7 +10622,7 @@ fn lsp_sloppy_imports_warn() { ), "data": { "specifier": temp_dir.join("a").uri_file(), - "redirect": temp_dir.join("a.ts").uri_file() + "redirect": temp_dir.join("a.ts").uri_file(), }, }], "only": ["quickfix"] @@ -10713,10 +10713,84 @@ fn sloppy_imports_not_enabled() { "Unable to load a local module: {}\nMaybe add a '.ts' extension.", temp_dir.join("a").uri_file(), ), + data: Some(json!({ + "specifier": temp_dir.join("a").uri_file(), + "to": temp_dir.join("a.ts").uri_file(), + "message": "Add a '.ts' extension.", + })), ..Default::default() }], version: Some(1), } ); + let res = client.write_request( + "textDocument/codeAction", + json!({ + "textDocument": { + "uri": temp_dir.join("file.ts").uri_file() + }, + "range": { + "start": { "line": 0, "character": 19 }, + "end": { "line": 0, "character": 24 } + }, + "context": { + "diagnostics": [{ + "range": { + "start": { "line": 0, "character": 19 }, + "end": { "line": 0, "character": 24 } + }, + "severity": 3, + "code": "no-local", + "source": "deno", + "message": format!( + "Unable to load a local module: {}\nMaybe add a '.ts' extension.", + temp_dir.join("a").uri_file(), + ), + "data": { + "specifier": temp_dir.join("a").uri_file(), + "to": temp_dir.join("a.ts").uri_file(), + "message": "Add a '.ts' extension.", + }, + }], + "only": ["quickfix"] + } + }), + ); + assert_eq!( + res, + json!([{ + "title": "Add a '.ts' extension.", + "kind": "quickfix", + "diagnostics": [{ + "range": { + "start": { "line": 0, "character": 19 }, + "end": { "line": 0, "character": 24 } + }, + "severity": 3, + "code": "no-local", + "source": "deno", + "message": format!( + "Unable to load a local module: {}\nMaybe add a '.ts' extension.", + temp_dir.join("a").uri_file(), + ), + "data": { + "specifier": temp_dir.join("a").uri_file(), + "to": temp_dir.join("a.ts").uri_file(), + "message": "Add a '.ts' extension.", + }, + }], + "edit": { + "changes": { + temp_dir.join("file.ts").uri_file(): [{ + "range": { + "start": { "line": 0, "character": 19 }, + "end": { "line": 0, "character": 24 } + }, + "newText": "\"./a.ts\"" + }] + } + } + }]) + ); client.shutdown(); } diff --git a/cli/tools/bench/mod.rs b/cli/tools/bench/mod.rs index b04aa757d9..57d1484639 100644 --- a/cli/tools/bench/mod.rs +++ b/cli/tools/bench/mod.rs @@ -497,7 +497,7 @@ pub async fn run_benchmarks_with_watch( .await?; graph_valid_with_cli_options( &graph, - factory.fs(), + factory.fs().as_ref(), &bench_modules, cli_options, )?; diff --git a/cli/tools/test/mod.rs b/cli/tools/test/mod.rs index 5d943d7162..840c5ac875 100644 --- a/cli/tools/test/mod.rs +++ b/cli/tools/test/mod.rs @@ -1282,7 +1282,7 @@ pub async fn run_tests_with_watch( .await?; graph_valid_with_cli_options( &graph, - factory.fs(), + factory.fs().as_ref(), &test_modules, &cli_options, )?; diff --git a/cli/tools/vendor/build.rs b/cli/tools/vendor/build.rs index 62fc0aa9aa..4cfadb901a 100644 --- a/cli/tools/vendor/build.rs +++ b/cli/tools/vendor/build.rs @@ -135,10 +135,9 @@ pub async fn build< } // surface any errors - let fs: Arc = Arc::new(deno_fs::RealFs); graph_util::graph_valid( &graph, - &fs, + &deno_fs::RealFs, &graph.roots, graph_util::GraphValidOptions { is_vendoring: true,