mirror of
https://github.com/denoland/deno.git
synced 2024-12-27 17:49:08 -05:00
be97170a19
This PR adds a new unstable "bring your own node_modules" (BYONM) functionality currently behind a `--unstable-byonm` flag (`"unstable": ["byonm"]` in a deno.json). This enables users to run a separate install command (ex. `npm install`, `pnpm install`) then run `deno run main.ts` and Deno will respect the layout of the node_modules directory as setup by the separate install command. It also works with npm/yarn/pnpm workspaces. For this PR, the behaviour is opted into by specifying `--unstable-byonm`/`"unstable": ["byonm"]`, but in the future we may make this the default behaviour as outlined in https://github.com/denoland/deno/issues/18967#issuecomment-1761248941 This is an extremely rough initial implementation. Errors are terrible in this and the LSP requires frequent restarts. Improvements will be done in follow up PRs.
1115 lines
32 KiB
Rust
1115 lines
32 KiB
Rust
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.
|
|
|
|
use super::diagnostics::DenoDiagnostic;
|
|
use super::diagnostics::DiagnosticSource;
|
|
use super::documents::Documents;
|
|
use super::language_server;
|
|
use super::tsc;
|
|
|
|
use crate::npm::CliNpmResolver;
|
|
use crate::tools::lint::create_linter;
|
|
|
|
use deno_ast::SourceRange;
|
|
use deno_ast::SourceRangedForSpanned;
|
|
use deno_ast::SourceTextInfo;
|
|
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::NpmResolver;
|
|
use deno_runtime::deno_node::PackageJson;
|
|
use deno_runtime::deno_node::PathClean;
|
|
use deno_semver::package::PackageReq;
|
|
use import_map::ImportMap;
|
|
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;
|
|
|
|
/// Diagnostic error codes which actually are the same, and so when grouping
|
|
/// fixes we treat them the same.
|
|
static FIX_ALL_ERROR_CODES: Lazy<HashMap<&'static str, &'static str>> =
|
|
Lazy::new(|| ([("2339", "2339"), ("2345", "2339")]).into_iter().collect());
|
|
|
|
/// Fixes which help determine if there is a preferred fix when there are
|
|
/// multiple fixes available.
|
|
static PREFERRED_FIXES: Lazy<HashMap<&'static str, (u32, bool)>> =
|
|
Lazy::new(|| {
|
|
([
|
|
("annotateWithTypeFromJSDoc", (1, false)),
|
|
("constructorForDerivedNeedSuperCall", (1, false)),
|
|
("extendsInterfaceBecomesImplements", (1, false)),
|
|
("awaitInSyncFunction", (1, false)),
|
|
("classIncorrectlyImplementsInterface", (3, false)),
|
|
("classDoesntImplementInheritedAbstractMember", (3, false)),
|
|
("unreachableCode", (1, false)),
|
|
("unusedIdentifier", (1, false)),
|
|
("forgottenThisPropertyAccess", (1, false)),
|
|
("spelling", (2, false)),
|
|
("addMissingAwait", (1, false)),
|
|
("fixImport", (0, true)),
|
|
])
|
|
.into_iter()
|
|
.collect()
|
|
});
|
|
|
|
static IMPORT_SPECIFIER_RE: Lazy<Regex> =
|
|
lazy_regex::lazy_regex!(r#"\sfrom\s+["']([^"']*)["']"#);
|
|
|
|
const SUPPORTED_EXTENSIONS: &[&str] = &[".ts", ".tsx", ".js", ".jsx", ".mjs"];
|
|
|
|
/// Category of self-generated diagnostic messages (those not coming from)
|
|
/// TypeScript.
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub enum Category {
|
|
/// A lint diagnostic, where the first element is the message.
|
|
Lint {
|
|
message: String,
|
|
code: String,
|
|
hint: Option<String>,
|
|
},
|
|
}
|
|
|
|
/// A structure to hold a reference to a diagnostic message.
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub struct Reference {
|
|
category: Category,
|
|
range: Range,
|
|
}
|
|
|
|
impl Reference {
|
|
pub fn to_diagnostic(&self) -> lsp::Diagnostic {
|
|
match &self.category {
|
|
Category::Lint {
|
|
message,
|
|
code,
|
|
hint,
|
|
} => lsp::Diagnostic {
|
|
range: self.range,
|
|
severity: Some(lsp::DiagnosticSeverity::WARNING),
|
|
code: Some(lsp::NumberOrString::String(code.to_string())),
|
|
code_description: None,
|
|
source: Some(DiagnosticSource::Lint.as_lsp_source().to_string()),
|
|
message: {
|
|
let mut msg = message.to_string();
|
|
if let Some(hint) = hint {
|
|
msg.push('\n');
|
|
msg.push_str(hint);
|
|
}
|
|
msg
|
|
},
|
|
related_information: None,
|
|
tags: None, // we should tag unused code
|
|
data: None,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
fn as_lsp_range(range: &deno_lint::diagnostic::Range) -> Range {
|
|
Range {
|
|
start: Position {
|
|
line: range.start.line_index as u32,
|
|
character: range.start.column_index as u32,
|
|
},
|
|
end: Position {
|
|
line: range.end.line_index as u32,
|
|
character: range.end.column_index as u32,
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn get_lint_references(
|
|
parsed_source: &deno_ast::ParsedSource,
|
|
lint_rules: Vec<&'static dyn LintRule>,
|
|
) -> Result<Vec<Reference>, AnyError> {
|
|
let linter = create_linter(parsed_source.media_type(), lint_rules);
|
|
let lint_diagnostics = linter.lint_with_ast(parsed_source);
|
|
|
|
Ok(
|
|
lint_diagnostics
|
|
.into_iter()
|
|
.map(|d| Reference {
|
|
category: Category::Lint {
|
|
message: d.message,
|
|
code: d.code,
|
|
hint: d.hint,
|
|
},
|
|
range: as_lsp_range(&d.range),
|
|
})
|
|
.collect(),
|
|
)
|
|
}
|
|
|
|
fn code_as_string(code: &Option<lsp::NumberOrString>) -> String {
|
|
match code {
|
|
Some(lsp::NumberOrString::String(str)) => str.clone(),
|
|
Some(lsp::NumberOrString::Number(num)) => num.to_string(),
|
|
_ => "".to_string(),
|
|
}
|
|
}
|
|
|
|
/// Rewrites imports in quick fixes and code changes to be Deno specific.
|
|
pub struct TsResponseImportMapper<'a> {
|
|
documents: &'a Documents,
|
|
maybe_import_map: Option<&'a ImportMap>,
|
|
npm_resolver: Option<&'a dyn CliNpmResolver>,
|
|
}
|
|
|
|
impl<'a> TsResponseImportMapper<'a> {
|
|
pub fn new(
|
|
documents: &'a Documents,
|
|
maybe_import_map: Option<&'a ImportMap>,
|
|
npm_resolver: Option<&'a dyn CliNpmResolver>,
|
|
) -> Self {
|
|
Self {
|
|
documents,
|
|
maybe_import_map,
|
|
npm_resolver,
|
|
}
|
|
}
|
|
|
|
pub fn check_specifier(
|
|
&self,
|
|
specifier: &ModuleSpecifier,
|
|
referrer: &ModuleSpecifier,
|
|
) -> Option<String> {
|
|
fn concat_npm_specifier(
|
|
prefix: &str,
|
|
pkg_req: &PackageReq,
|
|
sub_path: Option<&str>,
|
|
) -> String {
|
|
let result = format!("{}{}", prefix, pkg_req);
|
|
match sub_path {
|
|
Some(path) => format!("{}/{}", result, path),
|
|
None => result,
|
|
}
|
|
}
|
|
|
|
if let Some(npm_resolver) =
|
|
self.npm_resolver.as_ref().and_then(|r| r.as_managed())
|
|
{
|
|
if npm_resolver.in_npm_package(specifier) {
|
|
if let Ok(Some(pkg_id)) =
|
|
npm_resolver.resolve_pkg_id_from_specifier(specifier)
|
|
{
|
|
let pkg_reqs = npm_resolver.resolve_pkg_reqs_from_pkg_id(&pkg_id);
|
|
// check if any pkg reqs match what is found in an import map
|
|
if !pkg_reqs.is_empty() {
|
|
let sub_path = self.resolve_package_path(specifier);
|
|
if let Some(import_map) = self.maybe_import_map {
|
|
for pkg_req in &pkg_reqs {
|
|
let paths = vec![
|
|
concat_npm_specifier("npm:", pkg_req, sub_path.as_deref()),
|
|
concat_npm_specifier("npm:/", pkg_req, sub_path.as_deref()),
|
|
];
|
|
for path in paths {
|
|
if let Some(mapped_path) = ModuleSpecifier::parse(&path)
|
|
.ok()
|
|
.and_then(|s| import_map.lookup(&s, referrer))
|
|
{
|
|
return Some(mapped_path);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// if not found in the import map, return the first pkg req
|
|
if let Some(pkg_req) = pkg_reqs.first() {
|
|
return Some(concat_npm_specifier(
|
|
"npm:",
|
|
pkg_req,
|
|
sub_path.as_deref(),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// check if the import map has this specifier
|
|
if let Some(import_map) = self.maybe_import_map {
|
|
if let Some(result) = import_map.lookup(specifier, referrer) {
|
|
return Some(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
|
|
.as_ref()
|
|
.and_then(|r| r.resolve_package_folder_from_path(specifier).ok())
|
|
.flatten()?;
|
|
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_unresolved_specifier(
|
|
&self,
|
|
specifier: &str,
|
|
referrer: &ModuleSpecifier,
|
|
) -> Option<String> {
|
|
if let Ok(specifier) = referrer.join(specifier) {
|
|
if let Some(specifier) = self.check_specifier(&specifier, referrer) {
|
|
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> {
|
|
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)
|
|
}
|
|
}
|
|
|
|
/// For a set of tsc changes, can them for any that contain something that looks
|
|
/// like an import and rewrite the import specifier to include the extension
|
|
pub fn fix_ts_import_changes(
|
|
referrer: &ModuleSpecifier,
|
|
changes: &[tsc::FileTextChanges],
|
|
import_mapper: &TsResponseImportMapper,
|
|
) -> Result<Vec<tsc::FileTextChanges>, AnyError> {
|
|
let mut r = Vec::new();
|
|
for change in changes {
|
|
let mut text_changes = Vec::new();
|
|
for text_change in &change.text_changes {
|
|
let lines = text_change.new_text.split('\n');
|
|
|
|
let new_lines: Vec<String> = lines
|
|
.map(|line| {
|
|
// This assumes that there's only one import per line.
|
|
if let Some(captures) = IMPORT_SPECIFIER_RE.captures(line) {
|
|
let specifier = captures.get(1).unwrap().as_str();
|
|
if let Some(new_specifier) =
|
|
import_mapper.check_unresolved_specifier(specifier, referrer)
|
|
{
|
|
line.replace(specifier, &new_specifier)
|
|
} else {
|
|
line.to_string()
|
|
}
|
|
} else {
|
|
line.to_string()
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
text_changes.push(tsc::TextChange {
|
|
span: text_change.span.clone(),
|
|
new_text: new_lines.join("\n").to_string(),
|
|
});
|
|
}
|
|
r.push(tsc::FileTextChanges {
|
|
file_name: change.file_name.clone(),
|
|
text_changes,
|
|
is_new_file: change.is_new_file,
|
|
});
|
|
}
|
|
Ok(r)
|
|
}
|
|
|
|
/// Fix tsc import code actions so that the module specifier is correct for
|
|
/// resolution by Deno (includes the extension).
|
|
fn fix_ts_import_action(
|
|
referrer: &ModuleSpecifier,
|
|
action: &tsc::CodeFixAction,
|
|
import_mapper: &TsResponseImportMapper,
|
|
) -> Result<tsc::CodeFixAction, AnyError> {
|
|
if action.fix_name == "import" {
|
|
let change = action
|
|
.changes
|
|
.get(0)
|
|
.ok_or_else(|| anyhow!("Unexpected action changes."))?;
|
|
let text_change = change
|
|
.text_changes
|
|
.get(0)
|
|
.ok_or_else(|| anyhow!("Missing text change."))?;
|
|
if let Some(captures) = IMPORT_SPECIFIER_RE.captures(&text_change.new_text)
|
|
{
|
|
let specifier = captures
|
|
.get(1)
|
|
.ok_or_else(|| anyhow!("Missing capture."))?
|
|
.as_str();
|
|
if let Some(new_specifier) =
|
|
import_mapper.check_unresolved_specifier(specifier, referrer)
|
|
{
|
|
let description = action.description.replace(specifier, &new_specifier);
|
|
let changes = action
|
|
.changes
|
|
.iter()
|
|
.map(|c| {
|
|
let text_changes = c
|
|
.text_changes
|
|
.iter()
|
|
.map(|tc| tsc::TextChange {
|
|
span: tc.span.clone(),
|
|
new_text: tc.new_text.replace(specifier, &new_specifier),
|
|
})
|
|
.collect();
|
|
tsc::FileTextChanges {
|
|
file_name: c.file_name.clone(),
|
|
text_changes,
|
|
is_new_file: c.is_new_file,
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
return Ok(tsc::CodeFixAction {
|
|
description,
|
|
changes,
|
|
commands: None,
|
|
fix_name: action.fix_name.clone(),
|
|
fix_id: None,
|
|
fix_all_description: None,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(action.clone())
|
|
}
|
|
|
|
/// Determines if two TypeScript diagnostic codes are effectively equivalent.
|
|
fn is_equivalent_code(
|
|
a: &Option<lsp::NumberOrString>,
|
|
b: &Option<lsp::NumberOrString>,
|
|
) -> bool {
|
|
let a_code = code_as_string(a);
|
|
let b_code = code_as_string(b);
|
|
FIX_ALL_ERROR_CODES.get(a_code.as_str())
|
|
== FIX_ALL_ERROR_CODES.get(b_code.as_str())
|
|
}
|
|
|
|
/// Return a boolean flag to indicate if the specified action is the preferred
|
|
/// action for a given set of actions.
|
|
fn is_preferred(
|
|
action: &tsc::CodeFixAction,
|
|
actions: &[CodeActionKind],
|
|
fix_priority: u32,
|
|
only_one: bool,
|
|
) -> bool {
|
|
actions.iter().all(|i| {
|
|
if let CodeActionKind::Tsc(_, a) = i {
|
|
if action == a {
|
|
return true;
|
|
}
|
|
if a.fix_id.is_some() {
|
|
return true;
|
|
}
|
|
if let Some((other_fix_priority, _)) =
|
|
PREFERRED_FIXES.get(a.fix_name.as_str())
|
|
{
|
|
match other_fix_priority.cmp(&fix_priority) {
|
|
Ordering::Less => return true,
|
|
Ordering::Greater => return false,
|
|
Ordering::Equal => (),
|
|
}
|
|
if only_one && action.fix_name == a.fix_name {
|
|
return false;
|
|
}
|
|
}
|
|
true
|
|
} else {
|
|
true
|
|
}
|
|
})
|
|
}
|
|
|
|
/// Convert changes returned from a TypeScript quick fix action into edits
|
|
/// for an LSP CodeAction.
|
|
pub fn ts_changes_to_edit(
|
|
changes: &[tsc::FileTextChanges],
|
|
language_server: &language_server::Inner,
|
|
) -> Result<Option<lsp::WorkspaceEdit>, AnyError> {
|
|
let mut text_document_edits = Vec::new();
|
|
for change in changes {
|
|
let text_document_edit = change.to_text_document_edit(language_server)?;
|
|
text_document_edits.push(text_document_edit);
|
|
}
|
|
Ok(Some(lsp::WorkspaceEdit {
|
|
changes: None,
|
|
document_changes: Some(lsp::DocumentChanges::Edits(text_document_edits)),
|
|
change_annotations: None,
|
|
}))
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct CodeActionData {
|
|
pub specifier: ModuleSpecifier,
|
|
pub fix_id: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
enum CodeActionKind {
|
|
Deno(lsp::CodeAction),
|
|
DenoLint(lsp::CodeAction),
|
|
Tsc(lsp::CodeAction, tsc::CodeFixAction),
|
|
}
|
|
|
|
#[derive(Debug, Hash, PartialEq, Eq)]
|
|
enum FixAllKind {
|
|
Tsc(String),
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
pub struct CodeActionCollection {
|
|
actions: Vec<CodeActionKind>,
|
|
fix_all_actions: HashMap<FixAllKind, CodeActionKind>,
|
|
}
|
|
|
|
impl CodeActionCollection {
|
|
pub fn add_deno_fix_action(
|
|
&mut self,
|
|
specifier: &ModuleSpecifier,
|
|
diagnostic: &lsp::Diagnostic,
|
|
) -> Result<(), AnyError> {
|
|
let code_action = DenoDiagnostic::get_code_action(specifier, diagnostic)?;
|
|
self.actions.push(CodeActionKind::Deno(code_action));
|
|
Ok(())
|
|
}
|
|
|
|
pub fn add_deno_lint_ignore_action(
|
|
&mut self,
|
|
specifier: &ModuleSpecifier,
|
|
diagnostic: &lsp::Diagnostic,
|
|
maybe_text_info: Option<SourceTextInfo>,
|
|
maybe_parsed_source: Option<deno_ast::ParsedSource>,
|
|
) -> Result<(), AnyError> {
|
|
let code = diagnostic
|
|
.code
|
|
.as_ref()
|
|
.map(|v| match v {
|
|
lsp::NumberOrString::String(v) => v.to_owned(),
|
|
_ => "".to_string(),
|
|
})
|
|
.unwrap();
|
|
|
|
let line_content = maybe_text_info.map(|ti| {
|
|
ti.line_text(diagnostic.range.start.line as usize)
|
|
.to_string()
|
|
});
|
|
|
|
let mut changes = HashMap::new();
|
|
changes.insert(
|
|
specifier.clone(),
|
|
vec![lsp::TextEdit {
|
|
new_text: prepend_whitespace(
|
|
format!("// deno-lint-ignore {code}\n"),
|
|
line_content,
|
|
),
|
|
range: lsp::Range {
|
|
start: lsp::Position {
|
|
line: diagnostic.range.start.line,
|
|
character: 0,
|
|
},
|
|
end: lsp::Position {
|
|
line: diagnostic.range.start.line,
|
|
character: 0,
|
|
},
|
|
},
|
|
}],
|
|
);
|
|
let ignore_error_action = lsp::CodeAction {
|
|
title: format!("Disable {code} for this line"),
|
|
kind: Some(lsp::CodeActionKind::QUICKFIX),
|
|
diagnostics: Some(vec![diagnostic.clone()]),
|
|
command: None,
|
|
is_preferred: None,
|
|
disabled: None,
|
|
data: None,
|
|
edit: Some(lsp::WorkspaceEdit {
|
|
changes: Some(changes),
|
|
change_annotations: None,
|
|
document_changes: None,
|
|
}),
|
|
};
|
|
self
|
|
.actions
|
|
.push(CodeActionKind::DenoLint(ignore_error_action));
|
|
|
|
// Disable a lint error for the entire file.
|
|
let maybe_ignore_comment = maybe_parsed_source.clone().and_then(|ps| {
|
|
// Note: we can use ps.get_leading_comments() but it doesn't
|
|
// work when shebang is present at the top of the file.
|
|
ps.comments().get_vec().iter().find_map(|c| {
|
|
let comment_text = c.text.trim();
|
|
comment_text.split_whitespace().next().and_then(|prefix| {
|
|
if prefix == "deno-lint-ignore-file" {
|
|
Some(c.clone())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
})
|
|
});
|
|
|
|
let mut new_text = format!("// deno-lint-ignore-file {code}\n");
|
|
let mut range = lsp::Range {
|
|
start: lsp::Position {
|
|
line: 0,
|
|
character: 0,
|
|
},
|
|
end: lsp::Position {
|
|
line: 0,
|
|
character: 0,
|
|
},
|
|
};
|
|
// If ignore file comment already exists, append the lint code
|
|
// to the existing comment.
|
|
if let Some(ignore_comment) = maybe_ignore_comment {
|
|
new_text = format!(" {code}");
|
|
// Get the end position of the comment.
|
|
let line = maybe_parsed_source
|
|
.unwrap()
|
|
.text_info()
|
|
.line_and_column_index(ignore_comment.end());
|
|
let position = lsp::Position {
|
|
line: line.line_index as u32,
|
|
character: line.column_index as u32,
|
|
};
|
|
// Set the edit range to the end of the comment.
|
|
range.start = position;
|
|
range.end = position;
|
|
}
|
|
|
|
let mut changes = HashMap::new();
|
|
changes.insert(specifier.clone(), vec![lsp::TextEdit { new_text, range }]);
|
|
let ignore_file_action = lsp::CodeAction {
|
|
title: format!("Disable {code} for the entire file"),
|
|
kind: Some(lsp::CodeActionKind::QUICKFIX),
|
|
diagnostics: Some(vec![diagnostic.clone()]),
|
|
command: None,
|
|
is_preferred: None,
|
|
disabled: None,
|
|
data: None,
|
|
edit: Some(lsp::WorkspaceEdit {
|
|
changes: Some(changes),
|
|
change_annotations: None,
|
|
document_changes: None,
|
|
}),
|
|
};
|
|
self
|
|
.actions
|
|
.push(CodeActionKind::DenoLint(ignore_file_action));
|
|
|
|
let mut changes = HashMap::new();
|
|
changes.insert(
|
|
specifier.clone(),
|
|
vec![lsp::TextEdit {
|
|
new_text: "// deno-lint-ignore-file\n".to_string(),
|
|
range: lsp::Range {
|
|
start: lsp::Position {
|
|
line: 0,
|
|
character: 0,
|
|
},
|
|
end: lsp::Position {
|
|
line: 0,
|
|
character: 0,
|
|
},
|
|
},
|
|
}],
|
|
);
|
|
let ignore_file_action = lsp::CodeAction {
|
|
title: "Ignore lint errors for the entire file".to_string(),
|
|
kind: Some(lsp::CodeActionKind::QUICKFIX),
|
|
diagnostics: Some(vec![diagnostic.clone()]),
|
|
command: None,
|
|
is_preferred: None,
|
|
disabled: None,
|
|
data: None,
|
|
edit: Some(lsp::WorkspaceEdit {
|
|
changes: Some(changes),
|
|
change_annotations: None,
|
|
document_changes: None,
|
|
}),
|
|
};
|
|
self
|
|
.actions
|
|
.push(CodeActionKind::DenoLint(ignore_file_action));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Add a TypeScript code fix action to the code actions collection.
|
|
pub fn add_ts_fix_action(
|
|
&mut self,
|
|
specifier: &ModuleSpecifier,
|
|
action: &tsc::CodeFixAction,
|
|
diagnostic: &lsp::Diagnostic,
|
|
language_server: &language_server::Inner,
|
|
) -> Result<(), AnyError> {
|
|
if action.commands.is_some() {
|
|
// In theory, tsc can return actions that require "commands" to be applied
|
|
// back into TypeScript. Currently there is only one command, `install
|
|
// package` but Deno doesn't support that. The problem is that the
|
|
// `.applyCodeActionCommand()` returns a promise, and with the current way
|
|
// we wrap tsc, we can't handle the asynchronous response, so it is
|
|
// actually easier to return errors if we ever encounter one of these,
|
|
// which we really wouldn't expect from the Deno lsp.
|
|
return Err(custom_error(
|
|
"UnsupportedFix",
|
|
"The action returned from TypeScript is unsupported.",
|
|
));
|
|
}
|
|
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(),
|
|
kind: Some(lsp::CodeActionKind::QUICKFIX),
|
|
diagnostics: Some(vec![diagnostic.clone()]),
|
|
edit,
|
|
command: None,
|
|
is_preferred: None,
|
|
disabled: None,
|
|
data: None,
|
|
};
|
|
self.actions.retain(|i| match i {
|
|
CodeActionKind::Tsc(c, a) => {
|
|
!(action.fix_name == a.fix_name && code_action.edit == c.edit)
|
|
}
|
|
_ => true,
|
|
});
|
|
self
|
|
.actions
|
|
.push(CodeActionKind::Tsc(code_action, action.clone()));
|
|
|
|
if let Some(fix_id) = &action.fix_id {
|
|
if let Some(CodeActionKind::Tsc(existing_fix_all, existing_action)) =
|
|
self.fix_all_actions.get(&FixAllKind::Tsc(fix_id.clone()))
|
|
{
|
|
self.actions.retain(|i| match i {
|
|
CodeActionKind::Tsc(c, _) => c != existing_fix_all,
|
|
_ => true,
|
|
});
|
|
self.actions.push(CodeActionKind::Tsc(
|
|
existing_fix_all.clone(),
|
|
existing_action.clone(),
|
|
));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Add a TypeScript action to the actions as a "fix all" action, where it
|
|
/// will fix all occurrences of the diagnostic in the file.
|
|
pub fn add_ts_fix_all_action(
|
|
&mut self,
|
|
action: &tsc::CodeFixAction,
|
|
specifier: &ModuleSpecifier,
|
|
diagnostic: &lsp::Diagnostic,
|
|
) {
|
|
let data = Some(json!({
|
|
"specifier": specifier,
|
|
"fixId": action.fix_id,
|
|
}));
|
|
let title = if let Some(description) = &action.fix_all_description {
|
|
description.clone()
|
|
} else {
|
|
format!("{} (Fix all in file)", action.description)
|
|
};
|
|
|
|
let code_action = lsp::CodeAction {
|
|
title,
|
|
kind: Some(lsp::CodeActionKind::QUICKFIX),
|
|
diagnostics: Some(vec![diagnostic.clone()]),
|
|
edit: None,
|
|
command: None,
|
|
is_preferred: None,
|
|
disabled: None,
|
|
data,
|
|
};
|
|
if let Some(CodeActionKind::Tsc(existing, _)) = self
|
|
.fix_all_actions
|
|
.get(&FixAllKind::Tsc(action.fix_id.clone().unwrap()))
|
|
{
|
|
self.actions.retain(|i| match i {
|
|
CodeActionKind::Tsc(c, _) => c != existing,
|
|
_ => true,
|
|
});
|
|
}
|
|
self
|
|
.actions
|
|
.push(CodeActionKind::Tsc(code_action.clone(), action.clone()));
|
|
self.fix_all_actions.insert(
|
|
FixAllKind::Tsc(action.fix_id.clone().unwrap()),
|
|
CodeActionKind::Tsc(code_action, action.clone()),
|
|
);
|
|
}
|
|
|
|
/// Move out the code actions and return them as a `CodeActionResponse`.
|
|
pub fn get_response(self) -> lsp::CodeActionResponse {
|
|
// Prefer TSC fixes first, then Deno fixes, then Deno lint fixes.
|
|
let (tsc, rest): (Vec<_>, Vec<_>) = self
|
|
.actions
|
|
.into_iter()
|
|
.partition(|a| matches!(a, CodeActionKind::Tsc(..)));
|
|
let (deno, deno_lint): (Vec<_>, Vec<_>) = rest
|
|
.into_iter()
|
|
.partition(|a| matches!(a, CodeActionKind::Deno(_)));
|
|
|
|
tsc
|
|
.into_iter()
|
|
.chain(deno)
|
|
.chain(deno_lint)
|
|
.map(|k| match k {
|
|
CodeActionKind::Deno(c) => lsp::CodeActionOrCommand::CodeAction(c),
|
|
CodeActionKind::DenoLint(c) => lsp::CodeActionOrCommand::CodeAction(c),
|
|
CodeActionKind::Tsc(c, _) => lsp::CodeActionOrCommand::CodeAction(c),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Determine if a action can be converted into a "fix all" action.
|
|
pub fn is_fix_all_action(
|
|
&self,
|
|
action: &tsc::CodeFixAction,
|
|
diagnostic: &lsp::Diagnostic,
|
|
file_diagnostics: &[lsp::Diagnostic],
|
|
) -> bool {
|
|
// If the action does not have a fix id (indicating it can be "bundled up")
|
|
// or if the collection already contains a "bundled" action return false
|
|
if action.fix_id.is_none()
|
|
|| self
|
|
.fix_all_actions
|
|
.contains_key(&FixAllKind::Tsc(action.fix_id.clone().unwrap()))
|
|
{
|
|
false
|
|
} else {
|
|
// else iterate over the diagnostic in the file and see if there are any
|
|
// other diagnostics that could be bundled together in a "fix all" code
|
|
// action
|
|
file_diagnostics.iter().any(|d| {
|
|
if d == diagnostic || d.code.is_none() || diagnostic.code.is_none() {
|
|
false
|
|
} else {
|
|
d.code == diagnostic.code
|
|
|| is_equivalent_code(&d.code, &diagnostic.code)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Set the `.is_preferred` flag on code actions, this should be only executed
|
|
/// when all actions are added to the collection.
|
|
pub fn set_preferred_fixes(&mut self) {
|
|
let actions = self.actions.clone();
|
|
for entry in self.actions.iter_mut() {
|
|
if let CodeActionKind::Tsc(code_action, action) = entry {
|
|
if action.fix_id.is_some() {
|
|
continue;
|
|
}
|
|
if let Some((fix_priority, only_one)) =
|
|
PREFERRED_FIXES.get(action.fix_name.as_str())
|
|
{
|
|
code_action.is_preferred =
|
|
Some(is_preferred(action, &actions, *fix_priority, *only_one));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn add_cache_all_action(
|
|
&mut self,
|
|
specifier: &ModuleSpecifier,
|
|
diagnostics: Vec<lsp::Diagnostic>,
|
|
) {
|
|
self.actions.push(CodeActionKind::Deno(lsp::CodeAction {
|
|
title: "Cache all dependencies of this module.".to_string(),
|
|
kind: Some(lsp::CodeActionKind::QUICKFIX),
|
|
diagnostics: Some(diagnostics),
|
|
command: Some(lsp::Command {
|
|
title: "".to_string(),
|
|
command: "deno.cache".to_string(),
|
|
arguments: Some(vec![json!([]), json!(&specifier)]),
|
|
}),
|
|
..Default::default()
|
|
}));
|
|
}
|
|
}
|
|
|
|
/// Prepend the whitespace characters found at the start of line_content to content.
|
|
fn prepend_whitespace(content: String, line_content: Option<String>) -> String {
|
|
if let Some(line) = line_content {
|
|
let whitespaces =
|
|
line.chars().position(|c| !c.is_whitespace()).unwrap_or(0);
|
|
let whitespace = &line[0..whitespaces];
|
|
format!("{}{}", &whitespace, content)
|
|
} else {
|
|
content
|
|
}
|
|
}
|
|
|
|
pub fn source_range_to_lsp_range(
|
|
range: &SourceRange,
|
|
source_text_info: &SourceTextInfo,
|
|
) -> lsp::Range {
|
|
let start = source_text_info.line_and_column_index(range.start);
|
|
let end = source_text_info.line_and_column_index(range.end);
|
|
lsp::Range {
|
|
start: lsp::Position {
|
|
line: start.line_index as u32,
|
|
character: start.column_index as u32,
|
|
},
|
|
end: lsp::Position {
|
|
line: end.line_index as u32,
|
|
character: end.column_index as u32,
|
|
},
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::path::PathBuf;
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_reference_to_diagnostic() {
|
|
let range = Range {
|
|
start: Position {
|
|
line: 1,
|
|
character: 1,
|
|
},
|
|
end: Position {
|
|
line: 2,
|
|
character: 2,
|
|
},
|
|
};
|
|
|
|
let test_cases = [
|
|
(
|
|
Reference {
|
|
category: Category::Lint {
|
|
message: "message1".to_string(),
|
|
code: "code1".to_string(),
|
|
hint: None,
|
|
},
|
|
range,
|
|
},
|
|
lsp::Diagnostic {
|
|
range,
|
|
severity: Some(lsp::DiagnosticSeverity::WARNING),
|
|
code: Some(lsp::NumberOrString::String("code1".to_string())),
|
|
source: Some("deno-lint".to_string()),
|
|
message: "message1".to_string(),
|
|
..Default::default()
|
|
},
|
|
),
|
|
(
|
|
Reference {
|
|
category: Category::Lint {
|
|
message: "message2".to_string(),
|
|
code: "code2".to_string(),
|
|
hint: Some("hint2".to_string()),
|
|
},
|
|
range,
|
|
},
|
|
lsp::Diagnostic {
|
|
range,
|
|
severity: Some(lsp::DiagnosticSeverity::WARNING),
|
|
code: Some(lsp::NumberOrString::String("code2".to_string())),
|
|
source: Some("deno-lint".to_string()),
|
|
message: "message2\nhint2".to_string(),
|
|
..Default::default()
|
|
},
|
|
),
|
|
];
|
|
|
|
for (input, expected) in test_cases.iter() {
|
|
let actual = input.to_diagnostic();
|
|
assert_eq!(&actual, expected);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_as_lsp_range() {
|
|
let fixture = deno_lint::diagnostic::Range {
|
|
start: deno_lint::diagnostic::Position {
|
|
line_index: 0,
|
|
column_index: 2,
|
|
byte_index: 23,
|
|
},
|
|
end: deno_lint::diagnostic::Position {
|
|
line_index: 1,
|
|
column_index: 0,
|
|
byte_index: 33,
|
|
},
|
|
};
|
|
let actual = as_lsp_range(&fixture);
|
|
assert_eq!(
|
|
actual,
|
|
lsp::Range {
|
|
start: lsp::Position {
|
|
line: 0,
|
|
character: 2,
|
|
},
|
|
end: lsp::Position {
|
|
line: 1,
|
|
character: 0,
|
|
},
|
|
}
|
|
);
|
|
}
|
|
|
|
#[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"
|
|
);
|
|
}
|
|
}
|