deno/cli/lsp/diagnostics.rs

2050 lines
64 KiB
Rust

// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
use super::analysis;
use super::cache;
use super::client::Client;
use super::config::ConfigSnapshot;
use super::documents;
use super::documents::Document;
use super::documents::DocumentsFilter;
use super::language_server;
use super::language_server::StateSnapshot;
use super::performance::Performance;
use super::tsc;
use super::tsc::TsServer;
use super::urls::LspClientUrl;
use super::urls::LspUrlMap;
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::util::path::to_percent_decoded_str;
use deno_ast::MediaType;
use deno_core::anyhow::anyhow;
use deno_core::error::AnyError;
use deno_core::parking_lot::RwLock;
use deno_core::resolve_url;
use deno_core::serde::Deserialize;
use deno_core::serde_json;
use deno_core::serde_json::json;
use deno_core::unsync::spawn;
use deno_core::unsync::spawn_blocking;
use deno_core::unsync::JoinHandle;
use deno_core::ModuleSpecifier;
use deno_graph::source::ResolutionMode;
use deno_graph::Resolution;
use deno_graph::ResolutionError;
use deno_graph::SpecifierError;
use deno_lint::rules::LintRule;
use deno_runtime::deno_fs;
use deno_runtime::deno_node;
use deno_runtime::tokio_util::create_basic_runtime;
use deno_semver::jsr::JsrPackageReqReference;
use deno_semver::npm::NpmPackageReqReference;
use deno_semver::package::PackageReq;
use import_map::ImportMap;
use log::error;
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::atomic::AtomicUsize;
use std::sync::Arc;
use std::thread;
use tokio::sync::mpsc;
use tokio::sync::Mutex;
use tokio::time::Duration;
use tokio_util::sync::CancellationToken;
use tower_lsp::lsp_types as lsp;
#[derive(Debug)]
pub struct DiagnosticServerUpdateMessage {
pub snapshot: Arc<StateSnapshot>,
pub config: Arc<ConfigSnapshot>,
pub url_map: LspUrlMap,
}
#[derive(Debug)]
struct DiagnosticRecord {
pub specifier: ModuleSpecifier,
pub versioned: VersionedDiagnostics,
}
#[derive(Clone, Default, Debug)]
struct VersionedDiagnostics {
pub version: Option<i32>,
pub diagnostics: Vec<lsp::Diagnostic>,
}
type DiagnosticVec = Vec<DiagnosticRecord>;
#[derive(Debug, Hash, PartialEq, Eq, Copy, Clone)]
pub enum DiagnosticSource {
Deno,
Lint,
Ts,
}
impl DiagnosticSource {
pub fn as_lsp_source(&self) -> &'static str {
match self {
Self::Deno => "deno",
Self::Lint => "deno-lint",
Self::Ts => "deno-ts",
}
}
}
type DiagnosticsBySource = HashMap<DiagnosticSource, VersionedDiagnostics>;
#[derive(Debug)]
struct DiagnosticsPublisher {
client: Client,
state: Arc<DiagnosticsState>,
diagnostics_by_specifier:
Mutex<HashMap<ModuleSpecifier, DiagnosticsBySource>>,
}
impl DiagnosticsPublisher {
pub fn new(client: Client, state: Arc<DiagnosticsState>) -> Self {
Self {
client,
state,
diagnostics_by_specifier: Default::default(),
}
}
pub async fn publish(
&self,
source: DiagnosticSource,
diagnostics: DiagnosticVec,
url_map: &LspUrlMap,
token: &CancellationToken,
) -> usize {
let mut diagnostics_by_specifier =
self.diagnostics_by_specifier.lock().await;
let mut seen_specifiers = HashSet::with_capacity(diagnostics.len());
let mut messages_sent = 0;
for record in diagnostics {
if token.is_cancelled() {
return messages_sent;
}
seen_specifiers.insert(record.specifier.clone());
let diagnostics_by_source = diagnostics_by_specifier
.entry(record.specifier.clone())
.or_default();
let version = record.versioned.version;
let source_diagnostics = diagnostics_by_source.entry(source).or_default();
*source_diagnostics = record.versioned;
// DO NOT filter these by version. We want to display even out
// of date diagnostics in order to prevent flickering. The user's
// lsp client will eventually catch up.
let all_specifier_diagnostics = diagnostics_by_source
.values()
.flat_map(|d| &d.diagnostics)
.cloned()
.collect::<Vec<_>>();
self
.state
.update(&record.specifier, version, &all_specifier_diagnostics);
self
.client
.when_outside_lsp_lock()
.publish_diagnostics(
url_map
.normalize_specifier(&record.specifier)
.unwrap_or(LspClientUrl::new(record.specifier)),
all_specifier_diagnostics,
version,
)
.await;
messages_sent += 1;
}
// now check all the specifiers to clean up any ones with old diagnostics
let mut specifiers_to_remove = Vec::new();
for (specifier, diagnostics_by_source) in
diagnostics_by_specifier.iter_mut()
{
if seen_specifiers.contains(specifier) {
continue;
}
if token.is_cancelled() {
break;
}
let maybe_removed_value = diagnostics_by_source.remove(&source);
if diagnostics_by_source.is_empty() {
specifiers_to_remove.push(specifier.clone());
if let Some(removed_value) = maybe_removed_value {
// clear out any diagnostics for this specifier
self.state.update(specifier, removed_value.version, &[]);
self
.client
.when_outside_lsp_lock()
.publish_diagnostics(
url_map
.normalize_specifier(specifier)
.unwrap_or_else(|_| LspClientUrl::new(specifier.clone())),
Vec::new(),
removed_value.version,
)
.await;
messages_sent += 1;
}
}
}
// clean up specifiers with no diagnostics
for specifier in specifiers_to_remove {
diagnostics_by_specifier.remove(&specifier);
}
messages_sent
}
pub async fn clear(&self) {
let mut all_diagnostics = self.diagnostics_by_specifier.lock().await;
all_diagnostics.clear();
}
}
type DiagnosticMap = HashMap<ModuleSpecifier, VersionedDiagnostics>;
#[derive(Clone, Default, Debug)]
struct TsDiagnosticsStore(Arc<deno_core::parking_lot::Mutex<DiagnosticMap>>);
impl TsDiagnosticsStore {
pub fn get(
&self,
specifier: &ModuleSpecifier,
document_version: Option<i32>,
) -> Vec<lsp::Diagnostic> {
let ts_diagnostics = self.0.lock();
if let Some(versioned) = ts_diagnostics.get(specifier) {
// only get the diagnostics if they're up to date
if document_version == versioned.version {
return versioned.diagnostics.clone();
}
}
Vec::new()
}
pub fn invalidate(&self, specifiers: &[ModuleSpecifier]) {
let mut ts_diagnostics = self.0.lock();
for specifier in specifiers {
ts_diagnostics.remove(specifier);
}
}
pub fn invalidate_all(&self) {
self.0.lock().clear();
}
fn update(&self, diagnostics: &DiagnosticVec) {
let mut stored_ts_diagnostics = self.0.lock();
*stored_ts_diagnostics = diagnostics
.iter()
.map(|record| (record.specifier.clone(), record.versioned.clone()))
.collect();
}
}
pub fn should_send_diagnostic_batch_index_notifications() -> bool {
crate::args::has_flag_env_var(
"DENO_DONT_USE_INTERNAL_LSP_DIAGNOSTIC_SYNC_FLAG",
)
}
#[derive(Clone, Debug)]
struct DiagnosticBatchCounter(Option<Arc<AtomicUsize>>);
impl Default for DiagnosticBatchCounter {
fn default() -> Self {
if should_send_diagnostic_batch_index_notifications() {
Self(Some(Default::default()))
} else {
Self(None)
}
}
}
impl DiagnosticBatchCounter {
pub fn inc(&self) -> Option<usize> {
self
.0
.as_ref()
.map(|value| value.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1)
}
pub fn get(&self) -> Option<usize> {
self
.0
.as_ref()
.map(|value| value.load(std::sync::atomic::Ordering::SeqCst))
}
}
#[derive(Debug)]
enum ChannelMessage {
Update(ChannelUpdateMessage),
Clear,
}
#[derive(Debug)]
struct ChannelUpdateMessage {
message: DiagnosticServerUpdateMessage,
batch_index: Option<usize>,
}
#[derive(Debug)]
struct SpecifierState {
version: Option<i32>,
no_cache_diagnostics: Vec<lsp::Diagnostic>,
}
#[derive(Debug, Default)]
pub struct DiagnosticsState {
specifiers: RwLock<HashMap<ModuleSpecifier, SpecifierState>>,
}
impl DiagnosticsState {
fn update(
&self,
specifier: &ModuleSpecifier,
version: Option<i32>,
diagnostics: &[lsp::Diagnostic],
) {
let mut specifiers = self.specifiers.write();
let current_version = specifiers.get(specifier).and_then(|s| s.version);
match (version, current_version) {
(Some(arg), Some(existing)) if arg < existing => return,
_ => {}
}
let mut no_cache_diagnostics = vec![];
for diagnostic in diagnostics {
if diagnostic.code
== Some(lsp::NumberOrString::String("no-cache".to_string()))
|| diagnostic.code
== Some(lsp::NumberOrString::String("no-cache-jsr".to_string()))
|| diagnostic.code
== Some(lsp::NumberOrString::String("no-cache-npm".to_string()))
{
no_cache_diagnostics.push(diagnostic.clone());
}
}
specifiers.insert(
specifier.clone(),
SpecifierState {
version,
no_cache_diagnostics,
},
);
}
pub fn clear(&self, specifier: &ModuleSpecifier) {
self.specifiers.write().remove(specifier);
}
pub fn has_no_cache_diagnostics(&self, specifier: &ModuleSpecifier) -> bool {
self
.specifiers
.read()
.get(specifier)
.map(|s| !s.no_cache_diagnostics.is_empty())
.unwrap_or(false)
}
pub fn no_cache_diagnostics(
&self,
specifier: &ModuleSpecifier,
) -> Vec<lsp::Diagnostic> {
self
.specifiers
.read()
.get(specifier)
.map(|s| s.no_cache_diagnostics.clone())
.unwrap_or_default()
}
}
#[derive(Debug)]
pub struct DiagnosticsServer {
channel: Option<mpsc::UnboundedSender<ChannelMessage>>,
ts_diagnostics: TsDiagnosticsStore,
client: Client,
performance: Arc<Performance>,
ts_server: Arc<TsServer>,
batch_counter: DiagnosticBatchCounter,
state: Arc<DiagnosticsState>,
}
impl DiagnosticsServer {
pub fn new(
client: Client,
performance: Arc<Performance>,
ts_server: Arc<TsServer>,
state: Arc<DiagnosticsState>,
) -> Self {
DiagnosticsServer {
channel: Default::default(),
ts_diagnostics: Default::default(),
client,
performance,
ts_server,
batch_counter: Default::default(),
state,
}
}
pub fn get_ts_diagnostics(
&self,
specifier: &ModuleSpecifier,
document_version: Option<i32>,
) -> Vec<lsp::Diagnostic> {
self.ts_diagnostics.get(specifier, document_version)
}
pub fn invalidate(&self, specifiers: &[ModuleSpecifier]) {
self.ts_diagnostics.invalidate(specifiers);
}
pub fn invalidate_all(&self) {
self.ts_diagnostics.invalidate_all();
if let Some(tx) = &self.channel {
let _ = tx.send(ChannelMessage::Clear);
}
}
#[allow(unused_must_use)]
pub fn start(&mut self) {
let (tx, mut rx) = mpsc::unbounded_channel::<ChannelMessage>();
self.channel = Some(tx);
let client = self.client.clone();
let state = self.state.clone();
let performance = self.performance.clone();
let ts_diagnostics_store = self.ts_diagnostics.clone();
let ts_server = self.ts_server.clone();
let _join_handle = thread::spawn(move || {
let runtime = create_basic_runtime();
runtime.block_on(async {
let mut token = CancellationToken::new();
let mut ts_handle: Option<JoinHandle<()>> = None;
let mut lint_handle: Option<JoinHandle<()>> = None;
let mut deps_handle: Option<JoinHandle<()>> = None;
let diagnostics_publisher =
Arc::new(DiagnosticsPublisher::new(client.clone(), state.clone()));
loop {
match rx.recv().await {
// channel has closed
None => break,
Some(message) => {
let message = match message {
ChannelMessage::Update(message) => message,
ChannelMessage::Clear => {
token.cancel();
token = CancellationToken::new();
diagnostics_publisher.clear().await;
continue;
}
};
let ChannelUpdateMessage {
message:
DiagnosticServerUpdateMessage {
snapshot,
config,
url_map,
},
batch_index,
} = message;
let url_map = Arc::new(url_map);
// cancel the previous run
token.cancel();
token = CancellationToken::new();
let previous_ts_handle = ts_handle.take();
ts_handle = Some(spawn({
let performance = performance.clone();
let diagnostics_publisher = diagnostics_publisher.clone();
let ts_server = ts_server.clone();
let token = token.clone();
let ts_diagnostics_store = ts_diagnostics_store.clone();
let snapshot = snapshot.clone();
let config = config.clone();
let url_map = url_map.clone();
async move {
if let Some(previous_handle) = previous_ts_handle {
// Wait on the previous run to complete in order to prevent
// multiple threads queueing up a lot of tsc requests.
// Do not race this with cancellation because we want a
// chain of events to wait for all the previous diagnostics to complete
previous_handle.await;
}
// Debounce timer delay. 150ms between keystrokes is about 45 WPM, so we
// want something that is longer than that, but not too long to
// introduce detectable UI delay; 200ms is a decent compromise.
const DELAY: Duration = Duration::from_millis(200);
tokio::select! {
_ = token.cancelled() => { return; }
_ = tokio::time::sleep(DELAY) => {}
};
let mark = performance.mark("lsp.update_diagnostics_ts");
let diagnostics = generate_ts_diagnostics(
snapshot.clone(),
&config,
&ts_server,
token.clone(),
)
.await
.map_err(|err| {
if !token.is_cancelled() {
error!(
"Error generating TypeScript diagnostics: {}",
err
);
}
})
.unwrap_or_default();
let mut messages_len = 0;
if !token.is_cancelled() {
ts_diagnostics_store.update(&diagnostics);
messages_len = diagnostics_publisher
.publish(
DiagnosticSource::Ts,
diagnostics,
&url_map,
&token,
)
.await;
if !token.is_cancelled() {
performance.measure(mark);
}
}
if let Some(batch_index) = batch_index {
diagnostics_publisher
.client
.send_diagnostic_batch_notification(
DiagnosticBatchNotificationParams {
batch_index,
messages_len,
},
);
}
}
}));
let previous_deps_handle = deps_handle.take();
deps_handle = Some(spawn({
let performance = performance.clone();
let diagnostics_publisher = diagnostics_publisher.clone();
let token = token.clone();
let snapshot = snapshot.clone();
let config = config.clone();
let url_map = url_map.clone();
async move {
if let Some(previous_handle) = previous_deps_handle {
previous_handle.await;
}
let mark = performance.mark("lsp.update_diagnostics_deps");
let diagnostics = spawn_blocking({
let token = token.clone();
move || generate_deno_diagnostics(&snapshot, &config, token)
})
.await
.unwrap();
let mut messages_len = 0;
if !token.is_cancelled() {
messages_len = diagnostics_publisher
.publish(
DiagnosticSource::Deno,
diagnostics,
&url_map,
&token,
)
.await;
if !token.is_cancelled() {
performance.measure(mark);
}
}
if let Some(batch_index) = batch_index {
diagnostics_publisher
.client
.send_diagnostic_batch_notification(
DiagnosticBatchNotificationParams {
batch_index,
messages_len,
},
);
}
}
}));
let previous_lint_handle = lint_handle.take();
lint_handle = Some(spawn({
let performance = performance.clone();
let diagnostics_publisher = diagnostics_publisher.clone();
let token = token.clone();
let snapshot = snapshot.clone();
let config = config.clone();
let url_map = url_map.clone();
async move {
if let Some(previous_handle) = previous_lint_handle {
previous_handle.await;
}
let mark = performance.mark("lsp.update_diagnostics_lint");
let diagnostics = spawn_blocking({
let token = token.clone();
move || generate_lint_diagnostics(&snapshot, &config, token)
})
.await
.unwrap();
let mut messages_len = 0;
if !token.is_cancelled() {
messages_len = diagnostics_publisher
.publish(
DiagnosticSource::Lint,
diagnostics,
&url_map,
&token,
)
.await;
if !token.is_cancelled() {
performance.measure(mark);
}
}
if let Some(batch_index) = batch_index {
diagnostics_publisher
.client
.send_diagnostic_batch_notification(
DiagnosticBatchNotificationParams {
batch_index,
messages_len,
},
);
}
}
}));
}
}
}
})
});
}
pub fn latest_batch_index(&self) -> Option<usize> {
self.batch_counter.get()
}
pub fn update(
&self,
message: DiagnosticServerUpdateMessage,
) -> Result<(), AnyError> {
// todo(dsherret): instead of queuing up messages, it would be better to
// instead only store the latest message (ex. maybe using a
// tokio::sync::watch::channel)
if let Some(tx) = &self.channel {
tx.send(ChannelMessage::Update(ChannelUpdateMessage {
message,
batch_index: self.batch_counter.inc(),
}))
.map_err(|err| err.into())
} else {
Err(anyhow!("diagnostics server not started"))
}
}
}
impl<'a> From<&'a crate::tsc::DiagnosticCategory> for lsp::DiagnosticSeverity {
fn from(category: &'a crate::tsc::DiagnosticCategory) -> Self {
match category {
crate::tsc::DiagnosticCategory::Error => lsp::DiagnosticSeverity::ERROR,
crate::tsc::DiagnosticCategory::Warning => {
lsp::DiagnosticSeverity::WARNING
}
crate::tsc::DiagnosticCategory::Suggestion => {
lsp::DiagnosticSeverity::HINT
}
crate::tsc::DiagnosticCategory::Message => {
lsp::DiagnosticSeverity::INFORMATION
}
}
}
}
impl<'a> From<&'a crate::tsc::Position> for lsp::Position {
fn from(pos: &'a crate::tsc::Position) -> Self {
Self {
line: pos.line as u32,
character: pos.character as u32,
}
}
}
fn get_diagnostic_message(diagnostic: &crate::tsc::Diagnostic) -> String {
if let Some(message) = diagnostic.message_text.clone() {
message
} else if let Some(message_chain) = diagnostic.message_chain.clone() {
message_chain.format_message(0)
} else {
"[missing message]".to_string()
}
}
fn to_lsp_range(
start: &crate::tsc::Position,
end: &crate::tsc::Position,
) -> lsp::Range {
lsp::Range {
start: start.into(),
end: end.into(),
}
}
fn to_lsp_related_information(
related_information: &Option<Vec<crate::tsc::Diagnostic>>,
) -> Option<Vec<lsp::DiagnosticRelatedInformation>> {
related_information.as_ref().map(|related| {
related
.iter()
.filter_map(|ri| {
if let (Some(file_name), Some(start), Some(end)) =
(&ri.file_name, &ri.start, &ri.end)
{
let uri = lsp::Url::parse(file_name).unwrap();
Some(lsp::DiagnosticRelatedInformation {
location: lsp::Location {
uri,
range: to_lsp_range(start, end),
},
message: get_diagnostic_message(ri),
})
} else {
None
}
})
.collect()
})
}
fn ts_json_to_diagnostics(
diagnostics: Vec<crate::tsc::Diagnostic>,
) -> Vec<lsp::Diagnostic> {
diagnostics
.iter()
.filter_map(|d| {
if let (Some(start), Some(end)) = (&d.start, &d.end) {
Some(lsp::Diagnostic {
range: to_lsp_range(start, end),
severity: Some((&d.category).into()),
code: Some(lsp::NumberOrString::Number(d.code as i32)),
code_description: None,
source: Some(DiagnosticSource::Ts.as_lsp_source().to_string()),
message: get_diagnostic_message(d),
related_information: to_lsp_related_information(
&d.related_information,
),
tags: match d.code {
// These are codes that indicate the variable is unused.
2695 | 6133 | 6138 | 6192 | 6196 | 6198 | 6199 | 6205 | 7027
| 7028 => Some(vec![lsp::DiagnosticTag::UNNECESSARY]),
// These are codes that indicated the variable is deprecated.
2789 | 6385 | 6387 => Some(vec![lsp::DiagnosticTag::DEPRECATED]),
_ => None,
},
data: None,
})
} else {
None
}
})
.collect()
}
fn generate_lint_diagnostics(
snapshot: &language_server::StateSnapshot,
config: &ConfigSnapshot,
token: CancellationToken,
) -> DiagnosticVec {
let documents = snapshot
.documents
.documents(DocumentsFilter::OpenDiagnosable);
let config_data_by_scope = config.tree.data_by_scope();
let mut diagnostics_vec = Vec::new();
for document in documents {
let settings =
config.workspace_settings_for_specifier(document.specifier());
if !settings.lint {
continue;
}
// exit early if cancelled
if token.is_cancelled() {
break;
}
// ignore any npm package files
if let Some(npm) = &snapshot.npm {
if npm.node_resolver.in_npm_package(document.specifier()) {
continue;
}
}
let version = document.maybe_lsp_version();
let (lint_options, lint_rules) = config
.tree
.scope_for_specifier(document.specifier())
.and_then(|s| config_data_by_scope.get(s))
.map(|d| (d.lint_options.clone(), d.lint_rules.clone()))
.unwrap_or_default();
diagnostics_vec.push(DiagnosticRecord {
specifier: document.specifier().clone(),
versioned: VersionedDiagnostics {
version,
diagnostics: generate_document_lint_diagnostics(
config,
&lint_options,
lint_rules.rules.clone(),
&document,
),
},
});
}
diagnostics_vec
}
fn generate_document_lint_diagnostics(
config: &ConfigSnapshot,
lint_options: &LintOptions,
lint_rules: Vec<&'static dyn LintRule>,
document: &Document,
) -> Vec<lsp::Diagnostic> {
if !config.specifier_enabled(document.specifier()) {
return Vec::new();
}
if !lint_options.files.matches_specifier(document.specifier()) {
return Vec::new();
}
match document.maybe_parsed_source() {
Some(Ok(parsed_source)) => {
if let Ok(references) =
analysis::get_lint_references(&parsed_source, lint_rules)
{
references
.into_iter()
.map(|r| r.to_diagnostic())
.collect::<Vec<_>>()
} else {
Vec::new()
}
}
Some(Err(_)) => Vec::new(),
None => {
error!("Missing file contents for: {}", document.specifier());
Vec::new()
}
}
}
async fn generate_ts_diagnostics(
snapshot: Arc<language_server::StateSnapshot>,
config: &ConfigSnapshot,
ts_server: &tsc::TsServer,
token: CancellationToken,
) -> Result<DiagnosticVec, AnyError> {
let mut diagnostics_vec = Vec::new();
let specifiers = snapshot
.documents
.documents(DocumentsFilter::OpenDiagnosable)
.into_iter()
.map(|d| d.specifier().clone());
let (enabled_specifiers, disabled_specifiers) = specifiers
.into_iter()
.partition::<Vec<_>, _>(|s| config.specifier_enabled(s));
let ts_diagnostics_map = if !enabled_specifiers.is_empty() {
ts_server
.get_diagnostics(snapshot.clone(), enabled_specifiers, token)
.await?
} else {
Default::default()
};
for (specifier_str, ts_json_diagnostics) in ts_diagnostics_map {
let specifier = resolve_url(&specifier_str)?;
let version = snapshot
.documents
.get(&specifier)
.and_then(|d| d.maybe_lsp_version());
// check if the specifier is enabled again just in case TS returns us
// diagnostics for a disabled specifier
let ts_diagnostics = if config.specifier_enabled(&specifier) {
ts_json_to_diagnostics(ts_json_diagnostics)
} else {
Vec::new()
};
diagnostics_vec.push(DiagnosticRecord {
specifier,
versioned: VersionedDiagnostics {
version,
diagnostics: ts_diagnostics,
},
});
}
// add an empty diagnostic publish for disabled specifiers in order
// to clear those diagnostics if they exist
for specifier in disabled_specifiers {
let version = snapshot
.documents
.get(&specifier)
.and_then(|d| d.maybe_lsp_version());
diagnostics_vec.push(DiagnosticRecord {
specifier,
versioned: VersionedDiagnostics {
version,
diagnostics: Vec::new(),
},
});
}
Ok(diagnostics_vec)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DiagnosticDataSpecifier {
pub specifier: ModuleSpecifier,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DiagnosticDataStrSpecifier {
pub specifier: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
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 {
pub from: String,
pub to: String,
}
/// An enum which represents diagnostic errors which originate from Deno itself.
pub enum DenoDiagnostic {
/// A `x-deno-warning` is associated with the specifier and should be displayed
/// as a warning to the user.
DenoWarn(String),
/// An informational diagnostic that indicates an existing specifier can be
/// remapped to an import map import specifier.
ImportMapRemap { from: String, to: String },
/// The import assertion type is incorrect.
InvalidAttributeType(String),
/// A module requires an attribute type to be a valid import.
NoAttributeType,
/// A remote module was not found in the cache.
NoCache(ModuleSpecifier),
/// A remote jsr package reference was not found in the cache.
NoCacheJsr(PackageReq, ModuleSpecifier),
/// A remote npm package reference was not found in the cache.
NoCacheNpm(PackageReq, ModuleSpecifier),
/// A local module was not found on the local file system.
NoLocal(ModuleSpecifier),
/// The specifier resolved to a remote specifier that was redirected to
/// another specifier.
Redirect {
from: ModuleSpecifier,
to: ModuleSpecifier,
},
/// An error occurred when resolving the specifier string.
ResolutionError(deno_graph::ResolutionError),
/// Invalid `node:` specifier.
InvalidNodeSpecifier(ModuleSpecifier),
/// Bare specifier is used for `node:` specifier
BareNodeSpecifier(String),
}
impl DenoDiagnostic {
fn code(&self) -> &str {
match self {
Self::DenoWarn(_) => "deno-warn",
Self::ImportMapRemap { .. } => "import-map-remap",
Self::InvalidAttributeType(_) => "invalid-attribute-type",
Self::NoAttributeType => "no-attribute-type",
Self::NoCache(_) => "no-cache",
Self::NoCacheJsr(_, _) => "no-cache-jsr",
Self::NoCacheNpm(_, _) => "no-cache-npm",
Self::NoLocal(_) => "no-local",
Self::Redirect { .. } => "redirect",
Self::ResolutionError(err) => {
if graph_util::get_resolution_error_bare_node_specifier(err).is_some() {
"import-node-prefix-missing"
} else {
match err {
ResolutionError::InvalidDowngrade { .. } => "invalid-downgrade",
ResolutionError::InvalidLocalImport { .. } => {
"invalid-local-import"
}
ResolutionError::InvalidSpecifier { error, .. } => match error {
SpecifierError::ImportPrefixMissing(_, _) => {
"import-prefix-missing"
}
SpecifierError::InvalidUrl(_) => "invalid-url",
},
ResolutionError::ResolverError { .. } => "resolver-error",
}
}
}
Self::InvalidNodeSpecifier(_) => "resolver-error",
Self::BareNodeSpecifier(_) => "import-node-prefix-missing",
}
}
/// A "static" method which for a diagnostic that originated from the
/// structure returns a code action which can resolve the diagnostic.
pub fn get_code_action(
specifier: &ModuleSpecifier,
diagnostic: &lsp::Diagnostic,
) -> Result<lsp::CodeAction, AnyError> {
if let Some(lsp::NumberOrString::String(code)) = &diagnostic.code {
let code_action = match code.as_str() {
"import-map-remap" => {
let data = diagnostic
.data
.clone()
.ok_or_else(|| anyhow!("Diagnostic is missing data"))?;
let DiagnosticDataImportMapRemap { from, to } =
serde_json::from_value(data)?;
lsp::CodeAction {
title: format!("Update \"{from}\" to \"{to}\" to use import map."),
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!("\"{to}\""),
range: diagnostic.range,
}],
)])),
..Default::default()
}),
..Default::default()
}
}
"no-attribute-type" => lsp::CodeAction {
title: "Insert import attribute.".to_string(),
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: " with { type: \"json\" }".to_string(),
range: lsp::Range {
start: diagnostic.range.end,
end: diagnostic.range.end,
},
}],
)])),
..Default::default()
}),
..Default::default()
},
"no-cache" | "no-cache-jsr" | "no-cache-npm" => {
let data = diagnostic
.data
.clone()
.ok_or_else(|| anyhow!("Diagnostic is missing data"))?;
let data: DiagnosticDataSpecifier = serde_json::from_value(data)?;
lsp::CodeAction {
title: format!(
"Cache \"{}\" and its dependencies.",
data.specifier
),
kind: Some(lsp::CodeActionKind::QUICKFIX),
diagnostics: Some(vec![diagnostic.clone()]),
command: Some(lsp::Command {
title: "".to_string(),
command: "deno.cache".to_string(),
arguments: Some(vec![json!([data.specifier]), json!(&specifier)]),
}),
..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
.clone()
.ok_or_else(|| anyhow!("Diagnostic is missing data"))?;
let data: DiagnosticDataRedirect = serde_json::from_value(data)?;
lsp::CodeAction {
title: "Update specifier to its redirected specifier.".to_string(),
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!(
"\"{}\"",
specifier_text_for_redirected(&data.redirect, specifier)
),
range: diagnostic.range,
}],
)])),
..Default::default()
}),
..Default::default()
}
}
"import-node-prefix-missing" => {
let data = diagnostic
.data
.clone()
.ok_or_else(|| anyhow!("Diagnostic is missing data"))?;
let data: DiagnosticDataStrSpecifier = serde_json::from_value(data)?;
lsp::CodeAction {
title: format!("Update specifier to node:{}", data.specifier),
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!("\"node:{}\"", data.specifier),
range: diagnostic.range,
}],
)])),
..Default::default()
}),
..Default::default()
}
}
_ => {
return Err(anyhow!(
"Unsupported diagnostic code (\"{}\") provided.",
code
))
}
};
Ok(code_action)
} else {
Err(anyhow!("Unsupported diagnostic code provided."))
}
}
/// Given a reference to the code from an LSP diagnostic, determine if the
/// diagnostic is fixable or not
pub fn is_fixable(diagnostic: &lsp_types::Diagnostic) -> bool {
if let Some(lsp::NumberOrString::String(code)) = &diagnostic.code {
match code.as_str() {
"import-map-remap"
| "no-cache"
| "no-cache-jsr"
| "no-cache-npm"
| "no-attribute-type"
| "redirect"
| "import-node-prefix-missing" => true,
"no-local" => diagnostic.data.is_some(),
_ => false,
}
} else {
false
}
}
/// 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,
sloppy_resolution: SloppyImportsResolution,
) -> String {
let mut message = format!(
"Unable to load a local module: {}\n",
to_percent_decoded_str(specifier.as_ref())
);
if let Some(additional_message) =
sloppy_resolution.as_suggestion_message()
{
message.push_str(&additional_message);
message.push('.');
} else {
message.push_str("Please check the file path.");
}
message
}
let (severity, message, data) = match self {
Self::DenoWarn(message) => (lsp::DiagnosticSeverity::WARNING, message.to_string(), None),
Self::ImportMapRemap { from, to } => (lsp::DiagnosticSeverity::HINT, format!("The import specifier can be remapped to \"{to}\" which will resolve it via the active import map."), Some(json!({ "from": from, "to": to }))),
Self::InvalidAttributeType(assert_type) => (lsp::DiagnosticSeverity::ERROR, format!("The module is a JSON module and expected an attribute type of \"json\". Instead got \"{assert_type}\"."), None),
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::NoCacheJsr(pkg_req, specifier) => (lsp::DiagnosticSeverity::ERROR, format!("Uncached or missing jsr package: {}", pkg_req), 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) => {
let sloppy_resolution = SloppyImportsResolver::resolve_with_fs(&deno_fs::RealFs, specifier, ResolutionMode::Execution);
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,
enhanced_resolution_error_message(err),
graph_util::get_resolution_error_bare_node_specifier(err)
.map(|specifier| json!({ "specifier": specifier }))
),
Self::InvalidNodeSpecifier(specifier) => (lsp::DiagnosticSeverity::ERROR, format!("Unknown Node built-in module: {}", specifier.path()), None),
Self::BareNodeSpecifier(specifier) => (lsp::DiagnosticSeverity::WARNING, format!("\"{}\" is resolved to \"node:{}\". If you want to use a built-in Node module, add a \"node:\" prefix.", specifier, specifier), Some(json!({ "specifier": specifier }))),
};
lsp::Diagnostic {
range: *range,
severity: Some(severity),
code: Some(lsp::NumberOrString::String(self.code().to_string())),
source: Some(DiagnosticSource::Deno.as_lsp_source().to_string()),
message,
data,
..Default::default()
}
}
}
fn specifier_text_for_redirected(
redirect: &lsp::Url,
referrer: &lsp::Url,
) -> String {
if redirect.scheme() == "file" && referrer.scheme() == "file" {
// use a relative specifier when it's going to a file url
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,
resolution: &Resolution,
is_dynamic: bool,
maybe_assert_type: Option<&str>,
import_map: Option<&ImportMap>,
) -> Vec<DenoDiagnostic> {
fn check_redirect_diagnostic(
specifier: &ModuleSpecifier,
doc: &Document,
) -> Option<DenoDiagnostic> {
let doc_specifier = doc.specifier();
// If the module was redirected, we want to issue an informational
// diagnostic that indicates this. This then allows us to issue a code
// action to replace the specifier with the final redirected one.
if specifier.scheme() == "jsr" || doc_specifier == specifier {
return None;
}
// don't bother warning about sloppy import redirects from .js to .d.ts
// because explaining how to fix this via a diagnostic involves using
// @deno-types and that's a bit complicated to explain
let is_sloppy_import_dts_redirect = doc_specifier.scheme() == "file"
&& doc.media_type().is_declaration()
&& !MediaType::from_specifier(specifier).is_declaration();
if is_sloppy_import_dts_redirect {
return None;
}
Some(DenoDiagnostic::Redirect {
from: specifier.clone(),
to: doc_specifier.clone(),
})
}
let mut diagnostics = vec![];
match resolution {
Resolution::Ok(resolved) => {
let specifier = &resolved.specifier;
// If the module is a remote module and has a `X-Deno-Warning` header, we
// want a warning diagnostic with that message.
if let Some(metadata) = snapshot.cache_metadata.get(specifier) {
if let Some(message) =
metadata.get(&cache::MetadataKey::Warning).cloned()
{
diagnostics.push(DenoDiagnostic::DenoWarn(message));
}
}
if let Some(doc) = snapshot.documents.get(specifier) {
if let Some(diagnostic) = check_redirect_diagnostic(specifier, &doc) {
diagnostics.push(diagnostic);
}
if doc.media_type() == MediaType::Json {
match maybe_assert_type {
// The module has the correct assertion type, no diagnostic
Some("json") => (),
// The dynamic import statement is missing an attribute type, which
// we might not be able to statically detect, therefore we will
// not provide a potentially incorrect diagnostic.
None if is_dynamic => (),
// The module has an incorrect assertion type, diagnostic
Some(assert_type) => diagnostics.push(
DenoDiagnostic::InvalidAttributeType(assert_type.to_string()),
),
// The module is missing an attribute type, diagnostic
None => diagnostics.push(DenoDiagnostic::NoAttributeType),
}
}
} else if let Ok(pkg_ref) =
JsrPackageReqReference::from_specifier(specifier)
{
let req = pkg_ref.into_inner().req;
diagnostics.push(DenoDiagnostic::NoCacheJsr(req, specifier.clone()));
} else if let Ok(pkg_ref) =
NpmPackageReqReference::from_specifier(specifier)
{
if let Some(npm_resolver) = snapshot
.npm
.as_ref()
.and_then(|n| n.npm_resolver.as_managed())
{
// show diagnostics for npm package references that aren't cached
let req = pkg_ref.into_inner().req;
if !npm_resolver.is_pkg_req_folder_cached(&req) {
diagnostics
.push(DenoDiagnostic::NoCacheNpm(req, specifier.clone()));
}
}
} else if let Some(module_name) = specifier.as_str().strip_prefix("node:")
{
if !deno_node::is_builtin_node_module(module_name) {
diagnostics
.push(DenoDiagnostic::InvalidNodeSpecifier(specifier.clone()));
} else if module_name == dependency_key {
let mut is_mapped = false;
if let Some(import_map) = import_map {
if let Resolution::Ok(resolved) = &resolution {
if import_map.resolve(module_name, &resolved.specifier).is_ok() {
is_mapped = true;
}
}
}
// show diagnostics for bare node specifiers that aren't mapped by import map
if !is_mapped {
diagnostics
.push(DenoDiagnostic::BareNodeSpecifier(module_name.to_string()));
}
} else if let Some(npm_resolver) = snapshot
.npm
.as_ref()
.and_then(|n| n.npm_resolver.as_managed())
{
// check that a @types/node package exists in the resolver
let types_node_req = PackageReq::from_str("@types/node").unwrap();
if !npm_resolver.is_pkg_req_folder_cached(&types_node_req) {
diagnostics.push(DenoDiagnostic::NoCacheNpm(
types_node_req,
ModuleSpecifier::parse("npm:@types/node").unwrap(),
));
}
}
} else {
// When the document is not available, it means that it cannot be found
// in the cache or locally on the disk, so we want to issue a diagnostic
// about that.
let deno_diagnostic = match specifier.scheme() {
"file" => DenoDiagnostic::NoLocal(specifier.clone()),
_ => DenoDiagnostic::NoCache(specifier.clone()),
};
diagnostics.push(deno_diagnostic);
}
}
// The specifier resolution resulted in an error, so we want to issue a
// diagnostic for that.
Resolution::Err(err) => {
diagnostics.push(DenoDiagnostic::ResolutionError(*err.clone()))
}
_ => (),
}
diagnostics
}
/// Generate diagnostics related to a dependency. The dependency is analyzed to
/// determine if it can be remapped to the active import map as well as surface
/// any diagnostics related to the resolved code or type dependency.
fn diagnose_dependency(
diagnostics: &mut Vec<lsp::Diagnostic>,
snapshot: &language_server::StateSnapshot,
referrer: &ModuleSpecifier,
dependency_key: &str,
dependency: &deno_graph::Dependency,
) {
if let Some(npm) = &snapshot.npm {
if npm.npm_resolver.in_npm_package(referrer) {
return; // ignore, surface typescript errors instead
}
}
let import_map = snapshot.config.tree.root_import_map();
if let Some(import_map) = import_map {
if let Resolution::Ok(resolved) = &dependency.maybe_code {
if let Some(to) = import_map.lookup(&resolved.specifier, referrer) {
if dependency_key != to {
diagnostics.push(
DenoDiagnostic::ImportMapRemap {
from: dependency_key.to_string(),
to,
}
.to_lsp_diagnostic(&documents::to_lsp_range(&resolved.range)),
);
}
}
}
}
let import_ranges: Vec<_> = dependency
.imports
.iter()
.map(|i| documents::to_lsp_range(&i.range))
.collect();
// TODO(nayeemrmn): This is a crude way of detecting `@deno-types` which has
// a different specifier and therefore needs a separate call to
// `diagnose_resolution()`. It would be much cleaner if that were modelled as
// a separate dependency: https://github.com/denoland/deno_graph/issues/247.
let is_types_deno_types = !dependency.maybe_type.is_none()
&& !dependency
.imports
.iter()
.any(|i| dependency.maybe_type.includes(&i.range.start).is_some());
diagnostics.extend(
diagnose_resolution(
snapshot,
dependency_key,
if dependency.maybe_code.is_none()
// If not @deno-types, diagnose the types if the code errored because
// it's likely resolving into the node_modules folder, which might be
// erroring correctly due to resolution only being for bundlers. Let this
// fail at runtime if necesarry, but don't bother erroring in the editor
|| !is_types_deno_types && matches!(dependency.maybe_type, Resolution::Ok(_))
&& matches!(dependency.maybe_code, Resolution::Err(_))
{
&dependency.maybe_type
} else {
&dependency.maybe_code
},
dependency.is_dynamic,
dependency.maybe_attribute_type.as_deref(),
import_map.map(|i| i.as_ref()),
)
.iter()
.flat_map(|diag| {
import_ranges
.iter()
.map(|range| diag.to_lsp_diagnostic(range))
}),
);
if is_types_deno_types {
let range = match &dependency.maybe_type {
Resolution::Ok(resolved) => documents::to_lsp_range(&resolved.range),
Resolution::Err(error) => documents::to_lsp_range(error.range()),
Resolution::None => unreachable!(),
};
diagnostics.extend(
diagnose_resolution(
snapshot,
dependency_key,
&dependency.maybe_type,
dependency.is_dynamic,
dependency.maybe_attribute_type.as_deref(),
import_map.map(|i| i.as_ref()),
)
.iter()
.map(|diag| diag.to_lsp_diagnostic(&range)),
);
}
}
/// Generate diagnostics that come from Deno module resolution logic (like
/// dependencies) or other Deno specific diagnostics, like the ability to use
/// an import map to shorten an URL.
fn generate_deno_diagnostics(
snapshot: &language_server::StateSnapshot,
config: &ConfigSnapshot,
token: CancellationToken,
) -> DiagnosticVec {
let mut diagnostics_vec = Vec::new();
for document in snapshot
.documents
.documents(DocumentsFilter::OpenDiagnosable)
{
if token.is_cancelled() {
break;
}
let mut diagnostics = Vec::new();
let specifier = document.specifier();
if config.specifier_enabled(specifier) {
for (dependency_key, dependency) in document.dependencies() {
diagnose_dependency(
&mut diagnostics,
snapshot,
specifier,
dependency_key,
dependency,
);
}
}
diagnostics_vec.push(DiagnosticRecord {
specifier: specifier.clone(),
versioned: VersionedDiagnostics {
version: document.maybe_lsp_version(),
diagnostics,
},
});
}
diagnostics_vec
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cache::GlobalHttpCache;
use crate::cache::RealDenoCacheEnv;
use crate::lsp::config::Config;
use crate::lsp::config::ConfigSnapshot;
use crate::lsp::config::Settings;
use crate::lsp::config::WorkspaceSettings;
use crate::lsp::documents::Documents;
use crate::lsp::documents::LanguageId;
use crate::lsp::language_server::StateSnapshot;
use deno_config::ConfigFile;
use pretty_assertions::assert_eq;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use test_util::TempDir;
async fn mock_state_snapshot(
fixtures: &[(&str, &str, i32, LanguageId)],
location: &Path,
maybe_import_map: Option<(&str, &str)>,
) -> StateSnapshot {
let cache = Arc::new(GlobalHttpCache::new(
location.to_path_buf(),
RealDenoCacheEnv,
));
let mut documents = Documents::new(cache);
for (specifier, source, version, language_id) in fixtures {
let specifier =
resolve_url(specifier).expect("failed to create specifier");
documents.open(
specifier.clone(),
*version,
*language_id,
(*source).into(),
);
}
let mut config = Config::new_with_roots([resolve_url("file:///").unwrap()]);
if let Some((base_url, json_string)) = maybe_import_map {
let base_url = resolve_url(base_url).unwrap();
let config_file = ConfigFile::new(
json_string,
base_url,
&deno_config::ParseOptions::default(),
)
.unwrap();
config.tree.inject_config_file(config_file).await;
}
StateSnapshot {
documents,
assets: Default::default(),
cache_metadata: cache::CacheMetadata::new(Arc::new(
GlobalHttpCache::new(location.to_path_buf(), RealDenoCacheEnv),
)),
config: config.snapshot(),
npm: None,
}
}
fn mock_config() -> ConfigSnapshot {
let root_uri = resolve_url("file:///").unwrap();
ConfigSnapshot {
settings: Settings {
unscoped: WorkspaceSettings {
enable: Some(true),
lint: true,
..Default::default()
},
..Default::default()
},
workspace_folders: vec![(
root_uri.clone(),
lsp::WorkspaceFolder {
uri: root_uri,
name: "".to_string(),
},
)],
..Default::default()
}
}
async fn setup(
temp_dir: &TempDir,
sources: &[(&str, &str, i32, LanguageId)],
maybe_import_map: Option<(&str, &str)>,
) -> (StateSnapshot, PathBuf) {
let location = temp_dir.path().join("deps").to_path_buf();
let state_snapshot =
mock_state_snapshot(sources, &location, maybe_import_map).await;
(state_snapshot, location)
}
#[tokio::test]
async fn test_enabled_then_disabled_specifier() {
let temp_dir = TempDir::new();
let (snapshot, cache_location) = setup(
&temp_dir,
&[(
"file:///a.ts",
r#"import * as b from "./b.ts";
let a: any = "a";
let c: number = "a";
"#,
1,
LanguageId::TypeScript,
)],
None,
)
.await;
let snapshot = Arc::new(snapshot);
let cache =
Arc::new(GlobalHttpCache::new(cache_location, RealDenoCacheEnv));
let ts_server = TsServer::new(Default::default(), cache);
ts_server.start(None);
// test enabled
{
let enabled_config = mock_config();
let diagnostics = generate_lint_diagnostics(
&snapshot,
&enabled_config,
Default::default(),
);
assert_eq!(get_diagnostics_for_single(diagnostics).len(), 6);
let diagnostics = generate_ts_diagnostics(
snapshot.clone(),
&enabled_config,
&ts_server,
Default::default(),
)
.await
.unwrap();
assert_eq!(get_diagnostics_for_single(diagnostics).len(), 4);
let diagnostics = generate_deno_diagnostics(
&snapshot,
&enabled_config,
Default::default(),
);
assert_eq!(get_diagnostics_for_single(diagnostics).len(), 1);
}
// now test disabled specifier
{
let mut disabled_config = mock_config();
disabled_config.settings.unscoped = WorkspaceSettings {
enable: Some(false),
..Default::default()
};
let diagnostics = generate_lint_diagnostics(
&snapshot,
&disabled_config,
Default::default(),
);
assert_eq!(get_diagnostics_for_single(diagnostics).len(), 0);
let diagnostics = generate_ts_diagnostics(
snapshot.clone(),
&disabled_config,
&ts_server,
Default::default(),
)
.await
.unwrap();
assert_eq!(get_diagnostics_for_single(diagnostics).len(), 0);
let diagnostics = generate_deno_diagnostics(
&snapshot,
&disabled_config,
Default::default(),
);
assert_eq!(get_diagnostics_for_single(diagnostics).len(), 0);
}
}
fn get_diagnostics_for_single(
diagnostic_vec: DiagnosticVec,
) -> Vec<lsp::Diagnostic> {
assert_eq!(diagnostic_vec.len(), 1);
diagnostic_vec
.into_iter()
.next()
.unwrap()
.versioned
.diagnostics
}
#[tokio::test]
async fn test_deno_diagnostics_with_import_map() {
let temp_dir = TempDir::new();
let (snapshot, _) = setup(
&temp_dir,
&[
(
"file:///std/assert/mod.ts",
"export function assert() {}",
1,
LanguageId::TypeScript,
),
(
"file:///a/file.ts",
"import { assert } from \"../std/assert/mod.ts\";\n\nassert();\n",
1,
LanguageId::TypeScript,
),
],
Some((
"file:///a/import-map.json",
r#"{
"imports": {
"/~/std/": "../std/"
}
}"#,
)),
)
.await;
let config = mock_config();
let token = CancellationToken::new();
let actual = generate_deno_diagnostics(&snapshot, &config, token);
assert_eq!(actual.len(), 2);
for record in actual {
match record.specifier.as_str() {
"file:///std/assert/mod.ts" => {
assert_eq!(json!(record.versioned.diagnostics), json!([]))
}
"file:///a/file.ts" => assert_eq!(
json!(record.versioned.diagnostics),
json!([
{
"range": {
"start": {
"line": 0,
"character": 23
},
"end": {
"line": 0,
"character": 45
}
},
"severity": 4,
"code": "import-map-remap",
"source": "deno",
"message": "The import specifier can be remapped to \"/~/std/assert/mod.ts\" which will resolve it via the active import map.",
"data": {
"from": "../std/assert/mod.ts",
"to": "/~/std/assert/mod.ts"
}
}
])
),
_ => unreachable!("unexpected specifier {}", record.specifier),
}
}
}
#[test]
fn test_get_code_action_import_map_remap() {
let specifier = ModuleSpecifier::parse("file:///a/file.ts").unwrap();
let result = DenoDiagnostic::get_code_action(&specifier, &lsp::Diagnostic {
range: lsp::Range {
start: lsp::Position { line: 0, character: 23 },
end: lsp::Position { line: 0, character: 50 },
},
severity: Some(lsp::DiagnosticSeverity::HINT),
code: Some(lsp::NumberOrString::String("import-map-remap".to_string())),
source: Some("deno".to_string()),
message: "The import specifier can be remapped to \"/~/std/assert/mod.ts\" which will resolve it via the active import map.".to_string(),
data: Some(json!({
"from": "../std/assert/mod.ts",
"to": "/~/std/assert/mod.ts"
})),
..Default::default()
});
assert!(result.is_ok());
let actual = result.unwrap();
assert_eq!(
json!(actual),
json!({
"title": "Update \"../std/assert/mod.ts\" to \"/~/std/assert/mod.ts\" to use import map.",
"kind": "quickfix",
"diagnostics": [
{
"range": {
"start": {
"line": 0,
"character": 23
},
"end": {
"line": 0,
"character": 50
}
},
"severity": 4,
"code": "import-map-remap",
"source": "deno",
"message": "The import specifier can be remapped to \"/~/std/assert/mod.ts\" which will resolve it via the active import map.",
"data": {
"from": "../std/assert/mod.ts",
"to": "/~/std/assert/mod.ts"
}
}
],
"edit": {
"changes": {
"file:///a/file.ts": [
{
"range": {
"start": {
"line": 0,
"character": 23
},
"end": {
"line": 0,
"character": 50
}
},
"newText": "\"/~/std/assert/mod.ts\""
}
]
}
}
})
);
}
#[tokio::test]
async fn duplicate_diagnostics_for_duplicate_imports() {
let temp_dir = TempDir::new();
let (snapshot, _) = setup(
&temp_dir,
&[(
"file:///a.ts",
r#"
// @deno-types="bad.d.ts"
import "bad.js";
import "bad.js";
"#,
1,
LanguageId::TypeScript,
)],
None,
)
.await;
let config = mock_config();
let token = CancellationToken::new();
let actual = generate_deno_diagnostics(&snapshot, &config, token);
assert_eq!(actual.len(), 1);
let record = actual.first().unwrap();
assert_eq!(
json!(record.versioned.diagnostics),
json!([
{
"range": {
"start": {
"line": 2,
"character": 15
},
"end": {
"line": 2,
"character": 23
}
},
"severity": 1,
"code": "import-prefix-missing",
"source": "deno",
"message": "Relative import path \"bad.js\" not prefixed with / or ./ or ../",
},
{
"range": {
"start": {
"line": 3,
"character": 15
},
"end": {
"line": 3,
"character": 23
}
},
"severity": 1,
"code": "import-prefix-missing",
"source": "deno",
"message": "Relative import path \"bad.js\" not prefixed with / or ./ or ../",
},
{
"range": {
"start": {
"line": 1,
"character": 23
},
"end": {
"line": 1,
"character": 33
}
},
"severity": 1,
"code": "import-prefix-missing",
"source": "deno",
"message": "Relative import path \"bad.d.ts\" not prefixed with / or ./ or ../",
},
])
);
}
#[tokio::test]
async fn unable_to_load_a_local_module() {
let temp_dir = TempDir::new();
let (snapshot, _) = setup(
&temp_dir,
&[(
"file:///a.ts",
r#"
import { 東京 } from "./🦕.ts";
"#,
1,
LanguageId::TypeScript,
)],
None,
)
.await;
let config = mock_config();
let token = CancellationToken::new();
let actual = generate_deno_diagnostics(&snapshot, &config, token);
assert_eq!(actual.len(), 1);
let record = actual.first().unwrap();
assert_eq!(
json!(record.versioned.diagnostics),
json!([
{
"range": {
"start": {
"line": 1,
"character": 27
},
"end": {
"line": 1,
"character": 35
}
},
"severity": 1,
"code": "no-local",
"source": "deno",
"message": "Unable to load a local module: file:///🦕.ts\nPlease check the file path.",
}
])
);
}
#[test]
fn test_specifier_text_for_redirected() {
#[track_caller]
fn run_test(specifier: &str, referrer: &str, expected: &str) {
let result = specifier_text_for_redirected(
&ModuleSpecifier::parse(specifier).unwrap(),
&ModuleSpecifier::parse(referrer).unwrap(),
);
assert_eq!(result, expected);
}
run_test("file:///a/a.ts", "file:///a/mod.ts", "./a.ts");
run_test("file:///a/a.ts", "file:///a/sub_dir/mod.ts", "../a.ts");
run_test(
"file:///a/sub_dir/a.ts",
"file:///a/mod.ts",
"./sub_dir/a.ts",
);
run_test(
"https://deno.land/x/example/mod.ts",
"file:///a/sub_dir/a.ts",
"https://deno.land/x/example/mod.ts",
);
}
}