diff --git a/Cargo.lock b/Cargo.lock index 7f5ed374c9988..d74d4e87775dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1806,7 +1806,6 @@ dependencies = [ name = "oxc_language_server" version = "0.16.6" dependencies = [ - "cow-utils", "env_logger", "futures", "globset", @@ -1814,11 +1813,8 @@ dependencies = [ "insta", "log", "oxc_allocator", - "oxc_data_structures", "oxc_diagnostics", "oxc_linter", - "oxc_parser", - "oxc_semantic", "papaya", "rustc-hash", "serde", @@ -1854,6 +1850,7 @@ dependencies = [ "oxc_ast_visit", "oxc_cfg", "oxc_codegen", + "oxc_data_structures", "oxc_diagnostics", "oxc_ecmascript", "oxc_index", diff --git a/crates/oxc_language_server/Cargo.toml b/crates/oxc_language_server/Cargo.toml index 6b84ebd5ea159..e9dc7df136195 100644 --- a/crates/oxc_language_server/Cargo.toml +++ b/crates/oxc_language_server/Cargo.toml @@ -23,13 +23,10 @@ doctest = false [dependencies] oxc_allocator = { workspace = true } -oxc_data_structures = { workspace = true, features = ["rope"] } oxc_diagnostics = { workspace = true } -oxc_linter = { workspace = true } -oxc_parser = { workspace = true } -oxc_semantic = { workspace = true } +oxc_linter = { workspace = true, features = ["language_server"] } -cow-utils = { workspace = true } +# env_logger = { workspace = true, features = ["humantime"] } futures = { workspace = true } globset = { workspace = true } diff --git a/crates/oxc_language_server/fixtures/linter/cross_module_extended_config/.oxlintrc.json b/crates/oxc_language_server/fixtures/linter/cross_module_extended_config/.oxlintrc.json new file mode 100644 index 0000000000000..82edc543b5df5 --- /dev/null +++ b/crates/oxc_language_server/fixtures/linter/cross_module_extended_config/.oxlintrc.json @@ -0,0 +1,5 @@ +{ + "extends": [ + "./config/.oxlintrc.json" + ] +} diff --git a/crates/oxc_language_server/fixtures/linter/cross_module_extended_config/config/.oxlintrc.json b/crates/oxc_language_server/fixtures/linter/cross_module_extended_config/config/.oxlintrc.json new file mode 100644 index 0000000000000..7d258733b4493 --- /dev/null +++ b/crates/oxc_language_server/fixtures/linter/cross_module_extended_config/config/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "plugins": [ + "import" + ], + "rules": { + "import/no-cycle": "error" + } +} diff --git a/crates/oxc_language_server/fixtures/linter/cross_module_extended_config/dep-a.ts b/crates/oxc_language_server/fixtures/linter/cross_module_extended_config/dep-a.ts new file mode 100644 index 0000000000000..8e71bdb54e2e8 --- /dev/null +++ b/crates/oxc_language_server/fixtures/linter/cross_module_extended_config/dep-a.ts @@ -0,0 +1,4 @@ +// should report cycle detected +import { b } from './dep-b.ts'; + +b(); diff --git a/crates/oxc_language_server/fixtures/linter/cross_module_extended_config/dep-b.ts b/crates/oxc_language_server/fixtures/linter/cross_module_extended_config/dep-b.ts new file mode 100644 index 0000000000000..137d96df50b3f --- /dev/null +++ b/crates/oxc_language_server/fixtures/linter/cross_module_extended_config/dep-b.ts @@ -0,0 +1,4 @@ +// this file is also included in dep-a.ts and dep-a.ts should report a no-cycle diagnostic +import './dep-a.ts'; + +export function b() { /* ... */ } diff --git a/crates/oxc_language_server/fixtures/linter/cross_module_nested_config/dep-a.ts b/crates/oxc_language_server/fixtures/linter/cross_module_nested_config/dep-a.ts new file mode 100644 index 0000000000000..8e71bdb54e2e8 --- /dev/null +++ b/crates/oxc_language_server/fixtures/linter/cross_module_nested_config/dep-a.ts @@ -0,0 +1,4 @@ +// should report cycle detected +import { b } from './dep-b.ts'; + +b(); diff --git a/crates/oxc_language_server/fixtures/linter/cross_module_nested_config/dep-b.ts b/crates/oxc_language_server/fixtures/linter/cross_module_nested_config/dep-b.ts new file mode 100644 index 0000000000000..137d96df50b3f --- /dev/null +++ b/crates/oxc_language_server/fixtures/linter/cross_module_nested_config/dep-b.ts @@ -0,0 +1,4 @@ +// this file is also included in dep-a.ts and dep-a.ts should report a no-cycle diagnostic +import './dep-a.ts'; + +export function b() { /* ... */ } diff --git a/crates/oxc_language_server/fixtures/linter/cross_module_nested_config/folder/.oxlintrc.json b/crates/oxc_language_server/fixtures/linter/cross_module_nested_config/folder/.oxlintrc.json new file mode 100644 index 0000000000000..7d258733b4493 --- /dev/null +++ b/crates/oxc_language_server/fixtures/linter/cross_module_nested_config/folder/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "plugins": [ + "import" + ], + "rules": { + "import/no-cycle": "error" + } +} diff --git a/crates/oxc_language_server/fixtures/linter/cross_module_nested_config/folder/folder-dep-a.ts b/crates/oxc_language_server/fixtures/linter/cross_module_nested_config/folder/folder-dep-a.ts new file mode 100644 index 0000000000000..55b132fd4d57a --- /dev/null +++ b/crates/oxc_language_server/fixtures/linter/cross_module_nested_config/folder/folder-dep-a.ts @@ -0,0 +1,4 @@ +// should report cycle detected +import { b } from './folder-dep-b.ts'; + +b(); diff --git a/crates/oxc_language_server/fixtures/linter/cross_module_nested_config/folder/folder-dep-b.ts b/crates/oxc_language_server/fixtures/linter/cross_module_nested_config/folder/folder-dep-b.ts new file mode 100644 index 0000000000000..935ceddca1e82 --- /dev/null +++ b/crates/oxc_language_server/fixtures/linter/cross_module_nested_config/folder/folder-dep-b.ts @@ -0,0 +1,4 @@ +// this file is also included in folder-dep-a.ts and folder-dep-a.ts should report a no-cycle diagnostic +import './folder-dep-a.ts'; + +export function b() { /* ... */ } diff --git a/crates/oxc_language_server/src/linter/error_with_position.rs b/crates/oxc_language_server/src/linter/error_with_position.rs index 7a344b19263e8..6a0a1c47ac39f 100644 --- a/crates/oxc_language_server/src/linter/error_with_position.rs +++ b/crates/oxc_language_server/src/linter/error_with_position.rs @@ -1,5 +1,6 @@ -use std::{path::PathBuf, str::FromStr}; +use std::{borrow::Cow, path::PathBuf, str::FromStr}; +use oxc_linter::MessageWithPosition; use tower_lsp_server::{ UriExt, lsp_types::{ @@ -7,39 +8,13 @@ use tower_lsp_server::{ }, }; -use cow_utils::CowUtils; -use oxc_diagnostics::{Error, Severity}; - -use crate::linter::offset_to_position; - -const LINT_DOC_LINK_PREFIX: &str = "https://oxc.rs/docs/guide/usage/linter/rules"; - -#[derive(Debug)] -pub struct ErrorWithPosition { - pub start_pos: Position, - pub end_pos: Position, - pub miette_err: Error, - pub fixed_content: Option, - pub labels_with_pos: Vec, -} - -#[derive(Debug)] -pub struct LabeledSpanWithPosition { - pub start_pos: Position, - pub end_pos: Position, - pub message: Option, -} +use oxc_diagnostics::Severity; #[derive(Debug, Clone)] pub struct DiagnosticReport { pub diagnostic: lsp_types::Diagnostic, pub fixed_content: Option, } -#[derive(Debug)] -pub struct ErrorReport { - pub error: Error, - pub fixed_content: Option, -} #[derive(Debug, Clone)] pub struct FixedContent { @@ -55,115 +30,96 @@ fn cmp_range(first: &Range, other: &Range) -> std::cmp::Ordering { } } -/// parse `OxcCode` to `Option<(scope, number)>` -fn parse_diagnostic_code(code: &str) -> Option<(&str, &str)> { - if !code.ends_with(')') { - return None; - } - let right_parenthesis_pos = code.rfind('(')?; - Some((&code[0..right_parenthesis_pos], &code[right_parenthesis_pos + 1..code.len() - 1])) -} - -impl ErrorWithPosition { - pub fn new( - error: Error, - text: &str, - fixed_content: Option, - start: usize, - ) -> Self { - let labels = error.labels().map_or(vec![], Iterator::collect); - let labels_with_pos: Vec = labels +fn message_with_position_to_lsp_diagnostic( + message: &MessageWithPosition<'_>, + path: &PathBuf, +) -> lsp_types::Diagnostic { + let severity = match message.severity { + Severity::Error => Some(lsp_types::DiagnosticSeverity::ERROR), + _ => Some(lsp_types::DiagnosticSeverity::WARNING), + }; + let uri = lsp_types::Uri::from_file_path(path).unwrap(); + + let related_information = message.labels.as_ref().map(|spans| { + spans .iter() - .map(|labeled_span| LabeledSpanWithPosition { - start_pos: offset_to_position(labeled_span.offset() + start, text), - end_pos: offset_to_position( - labeled_span.offset() + start + labeled_span.len(), - text, - ), - message: labeled_span.label().map(ToString::to_string), - }) - .collect(); - - let start_pos = labels_with_pos[0].start_pos; - let end_pos = labels_with_pos[labels_with_pos.len() - 1].end_pos; - - Self { miette_err: error, start_pos, end_pos, labels_with_pos, fixed_content } - } - - fn to_lsp_diagnostic(&self, path: &PathBuf) -> lsp_types::Diagnostic { - let severity = match self.miette_err.severity() { - Some(Severity::Error) => Some(lsp_types::DiagnosticSeverity::ERROR), - _ => Some(lsp_types::DiagnosticSeverity::WARNING), - }; - let related_information = Some( - self.labels_with_pos - .iter() - .map(|labeled_span| lsp_types::DiagnosticRelatedInformation { - location: lsp_types::Location { - uri: lsp_types::Uri::from_file_path(path).unwrap(), - range: lsp_types::Range { - start: lsp_types::Position { - line: labeled_span.start_pos.line, - character: labeled_span.start_pos.character, - }, - end: lsp_types::Position { - line: labeled_span.end_pos.line, - character: labeled_span.end_pos.character, - }, + .map(|span| lsp_types::DiagnosticRelatedInformation { + location: lsp_types::Location { + uri: uri.clone(), + range: lsp_types::Range { + start: lsp_types::Position { + line: span.start().line, + character: span.start().character, + }, + end: lsp_types::Position { + line: span.end().line, + character: span.end().character, }, }, - message: labeled_span.message.clone().unwrap_or_default(), - }) - .collect(), - ); - let range = related_information.as_ref().map_or( - Range { start: self.start_pos, end: self.end_pos }, - |infos: &Vec| { - let mut ret_range = Range { - start: Position { line: u32::MAX, character: u32::MAX }, - end: Position { line: u32::MAX, character: u32::MAX }, - }; - for info in infos { - if cmp_range(&ret_range, &info.location.range) == std::cmp::Ordering::Greater { - ret_range = info.location.range; - } - } - ret_range - }, - ); - let code = self.miette_err.code().map(|item| item.to_string()); - let code_description = code.as_ref().and_then(|code| { - let (scope, number) = parse_diagnostic_code(code)?; - Some(CodeDescription { - href: Uri::from_str(&format!( - "{LINT_DOC_LINK_PREFIX}/{}/{number}", - scope.strip_prefix("eslint-plugin-").unwrap_or(scope).cow_replace("-", "_") - )) - .ok()?, + }, + message: span.message().unwrap_or(&Cow::Borrowed("")).to_string(), }) - }); - let message = self.miette_err.help().map_or_else( - || self.miette_err.to_string(), - |help| format!("{}\nhelp: {}", self.miette_err, help), - ); - - lsp_types::Diagnostic { - range, - severity, - code: code.map(NumberOrString::String), - message, - source: Some("oxc".into()), - code_description, - related_information, - tags: None, - data: None, - } + .collect() + }); + + let range = related_information.as_ref().map_or( + Range { + start: Position { line: u32::MAX, character: u32::MAX }, + end: Position { line: u32::MAX, character: u32::MAX }, + }, + |infos: &Vec| { + let mut ret_range = Range { + start: Position { line: u32::MAX, character: u32::MAX }, + end: Position { line: u32::MAX, character: u32::MAX }, + }; + for info in infos { + if cmp_range(&ret_range, &info.location.range) == std::cmp::Ordering::Greater { + ret_range = info.location.range; + } + } + ret_range + }, + ); + let code = message.code.to_string(); + let code_description = + message.url.as_ref().map(|url| CodeDescription { href: Uri::from_str(url).ok().unwrap() }); + let message = message.help.as_ref().map_or_else( + || message.message.to_string(), + |help| format!("{}\nhelp: {}", message.message, help), + ); + + lsp_types::Diagnostic { + range, + severity, + code: Some(NumberOrString::String(code)), + message, + source: Some("oxc".into()), + code_description, + related_information, + tags: None, + data: None, } +} - pub fn into_diagnostic_report(self, path: &PathBuf) -> DiagnosticReport { - DiagnosticReport { - diagnostic: self.to_lsp_diagnostic(path), - fixed_content: self.fixed_content, - } +pub fn message_with_position_to_lsp_diagnostic_report( + message: &MessageWithPosition<'_>, + path: &PathBuf, +) -> DiagnosticReport { + DiagnosticReport { + diagnostic: message_with_position_to_lsp_diagnostic(message, path), + fixed_content: message.fix.as_ref().map(|infos| FixedContent { + message: infos.span.message().map(std::string::ToString::to_string), + code: infos.content.to_string(), + range: Range { + start: Position { + line: infos.span.start().line, + character: infos.span.start().character, + }, + end: Position { + line: infos.span.end().line, + character: infos.span.end().character, + }, + }, + }), } } diff --git a/crates/oxc_language_server/src/linter/isolated_lint_handler.rs b/crates/oxc_language_server/src/linter/isolated_lint_handler.rs index ca4f6533d32bc..efca77c0d6ae9 100644 --- a/crates/oxc_language_server/src/linter/isolated_lint_handler.rs +++ b/crates/oxc_language_server/src/linter/isolated_lint_handler.rs @@ -1,7 +1,6 @@ use std::{ fs, - path::Path, - rc::Rc, + path::{Path, PathBuf}, sync::{Arc, OnceLock}, }; @@ -9,30 +8,34 @@ use log::debug; use rustc_hash::FxHashSet; use tower_lsp_server::{ UriExt, - lsp_types::{self, DiagnosticRelatedInformation, DiagnosticSeverity, Range, Uri}, + lsp_types::{self, DiagnosticRelatedInformation, DiagnosticSeverity, Uri}, }; use oxc_allocator::Allocator; -use oxc_diagnostics::{Error, NamedSource}; use oxc_linter::{ - LINTABLE_EXTENSIONS, Linter, ModuleRecord, - loader::{JavaScriptSource, Loader}, + LINTABLE_EXTENSIONS, LintService, LintServiceOptions, Linter, MessageWithPosition, + loader::Loader, }; -use oxc_parser::{ParseOptions, Parser}; -use oxc_semantic::SemanticBuilder; -use crate::DiagnosticReport; -use crate::linter::error_with_position::{ErrorReport, ErrorWithPosition, FixedContent}; -use crate::linter::offset_to_position; +use super::error_with_position::{ + DiagnosticReport, message_with_position_to_lsp_diagnostic_report, +}; + +/// smaller subset of LintServiceOptions, which is used by IsolatedLintHandler +#[derive(Debug, Clone)] +pub struct IsolatedLintHandlerOptions { + pub use_cross_module: bool, + pub root_path: PathBuf, +} pub struct IsolatedLintHandler { linter: Arc, - loader: Loader, + options: Arc, } impl IsolatedLintHandler { - pub fn new(linter: Arc) -> Self { - Self { linter, loader: Loader } + pub fn new(linter: Arc, options: Arc) -> Self { + Self { linter, options } } pub fn run_single( @@ -44,11 +47,15 @@ impl IsolatedLintHandler { return None; } - Some(self.lint_path(path, content).map_or(vec![], |errors| { + let allocator = Allocator::default(); + + Some(self.lint_path(&allocator, path, content).map_or(vec![], |errors| { let path_buf = &path.to_path_buf(); - let mut diagnostics: Vec = - errors.into_iter().map(|e| e.into_diagnostic_report(path_buf)).collect(); + let mut diagnostics: Vec = errors + .iter() + .map(|e| message_with_position_to_lsp_diagnostic_report(e, path_buf)) + .collect(); // a diagnostics connected from related_info to original diagnostic let mut inverted_diagnostics = vec![]; @@ -93,99 +100,31 @@ impl IsolatedLintHandler { })) } - fn lint_path( + fn lint_path<'a>( &self, + allocator: &'a Allocator, path: &Path, source_text: Option, - ) -> Option> { + ) -> Option>> { if !Loader::can_load(path) { debug!("extension not supported yet."); return None; } let source_text = source_text.or_else(|| fs::read_to_string(path).ok())?; - let javascript_sources = match self.loader.load_str(path, &source_text) { - Ok(s) => s, - Err(e) => { - debug!("failed to load {path:?}: {e}"); - return None; - } - }; debug!("lint {path:?}"); - let mut diagnostics = vec![]; - for source in javascript_sources { - let JavaScriptSource { - source_text: javascript_source_text, source_type, start, .. - } = source; - let allocator = Allocator::default(); - let ret = Parser::new(&allocator, javascript_source_text, source_type) - .with_options(ParseOptions { - allow_return_outside_function: true, - parse_regular_expression: true, - ..ParseOptions::default() - }) - .parse(); - - if !ret.errors.is_empty() { - let reports = ret - .errors - .into_iter() - .map(|diagnostic| ErrorReport { - error: Error::from(diagnostic), - fixed_content: None, - }) - .collect(); - return Some(Self::wrap_diagnostics(path, &source_text, reports, start)); - } - - let semantic_ret = SemanticBuilder::new() - .with_cfg(true) - .with_scope_tree_child_ids(true) - .with_check_syntax_error(true) - .build(&ret.program); - - if !semantic_ret.errors.is_empty() { - let reports = semantic_ret - .errors - .into_iter() - .map(|diagnostic| ErrorReport { - error: Error::from(diagnostic), - fixed_content: None, - }) - .collect(); - return Some(Self::wrap_diagnostics(path, &source_text, reports, start)); - } - let mut semantic = semantic_ret.semantic; - semantic.set_irregular_whitespaces(ret.irregular_whitespaces); - let module_record = Arc::new(ModuleRecord::new(path, &ret.module_record, &semantic)); - let result = self.linter.run(path, Rc::new(semantic), module_record); - - let reports = result - .into_iter() - .map(|msg| { - let fixed_content = msg.fix.map(|f| FixedContent { - message: f.message.map(|m| m.to_string()), - code: f.content.to_string(), - range: Range { - start: offset_to_position( - (f.span.start + start) as usize, - source_text.as_str(), - ), - end: offset_to_position( - (f.span.end + start) as usize, - source_text.as_str(), - ), - }, - }); - - ErrorReport { error: Error::from(msg.error), fixed_content } - }) - .collect::>(); - diagnostics.extend(Self::wrap_diagnostics(path, &source_text, reports, start)); - } - - Some(diagnostics) + let lint_service_options = LintServiceOptions::new( + self.options.root_path.clone(), + vec![Arc::from(path.as_os_str())], + ) + .with_cross_module(self.options.use_cross_module); + // ToDo: do not clone the linter + let path_arc = Arc::from(path.as_os_str()); + let mut lint_service = LintService::new((*self.linter).clone(), lint_service_options); + let result = lint_service.run_source(allocator, &path_arc, &source_text); + + Some(result) } fn should_lint_path(path: &Path) -> bool { @@ -197,25 +136,4 @@ impl IsolatedLintHandler { .and_then(std::ffi::OsStr::to_str) .is_some_and(|ext| wanted_exts.contains(ext)) } - - fn wrap_diagnostics( - path: &Path, - source_text: &str, - reports: Vec, - start: u32, - ) -> Vec { - let source = Arc::new(NamedSource::new(path.to_string_lossy(), source_text.to_owned())); - - reports - .into_iter() - .map(|report| { - ErrorWithPosition::new( - report.error.with_source_code(Arc::clone(&source)), - source_text, - report.fixed_content, - start as usize, - ) - }) - .collect() - } } diff --git a/crates/oxc_language_server/src/linter/mod.rs b/crates/oxc_language_server/src/linter/mod.rs index dc06e5519e04e..6c8edc9a212f0 100644 --- a/crates/oxc_language_server/src/linter/mod.rs +++ b/crates/oxc_language_server/src/linter/mod.rs @@ -1,64 +1,7 @@ -use oxc_data_structures::rope::{Rope, get_line_column}; -use tower_lsp_server::lsp_types::Position; - pub mod config_walker; pub mod error_with_position; -mod isolated_lint_handler; +pub mod isolated_lint_handler; pub mod server_linter; #[cfg(test)] mod tester; - -#[expect(clippy::cast_possible_truncation)] -pub fn offset_to_position(offset: usize, source_text: &str) -> Position { - // TODO(perf): share a single instance of `Rope` - let rope = Rope::from_str(source_text); - let (line, column) = get_line_column(&rope, offset as u32, source_text); - Position::new(line, column) -} - -#[cfg(test)] -mod test { - use crate::linter::offset_to_position; - - #[test] - fn single_line() { - let source = "foo.bar!;"; - assert_position(source, 0, (0, 0)); - assert_position(source, 4, (0, 4)); - assert_position(source, 9, (0, 9)); - } - - #[test] - fn multi_line() { - let source = "console.log(\n foo.bar!\n);"; - assert_position(source, 0, (0, 0)); - assert_position(source, 12, (0, 12)); - assert_position(source, 13, (1, 0)); - assert_position(source, 23, (1, 10)); - assert_position(source, 24, (2, 0)); - assert_position(source, 26, (2, 2)); - } - - #[test] - fn multi_byte() { - let source = "let foo = \n '👍';"; - assert_position(source, 10, (0, 10)); - assert_position(source, 11, (1, 0)); - assert_position(source, 14, (1, 3)); - assert_position(source, 18, (1, 5)); - assert_position(source, 19, (1, 6)); - } - - #[test] - #[should_panic(expected = "out of bounds")] - fn out_of_bounds() { - offset_to_position(100, "foo"); - } - - fn assert_position(source: &str, offset: usize, expected: (u32, u32)) { - let position = offset_to_position(offset, source); - assert_eq!(position.line, expected.0); - assert_eq!(position.character, expected.1); - } -} diff --git a/crates/oxc_language_server/src/linter/server_linter.rs b/crates/oxc_language_server/src/linter/server_linter.rs index adcd8c26f921e..27ac2ea74f159 100644 --- a/crates/oxc_language_server/src/linter/server_linter.rs +++ b/crates/oxc_language_server/src/linter/server_linter.rs @@ -7,24 +7,28 @@ use oxc_linter::{ConfigStoreBuilder, FixKind, LintOptions, Linter}; use crate::linter::error_with_position::DiagnosticReport; use crate::linter::isolated_lint_handler::IsolatedLintHandler; +use super::isolated_lint_handler::IsolatedLintHandlerOptions; + +#[derive(Clone)] pub struct ServerLinter { linter: Arc, + options: Arc, } impl ServerLinter { - pub fn new() -> Self { + pub fn new(options: IsolatedLintHandlerOptions) -> Self { let config_store = ConfigStoreBuilder::default().build().expect("Failed to build config store"); let linter = Linter::new(LintOptions::default(), config_store).with_fix(FixKind::SafeFix); - Self { linter: Arc::new(linter) } + Self { linter: Arc::new(linter), options: Arc::new(options) } } - pub fn new_with_linter(linter: Linter) -> Self { - Self { linter: Arc::new(linter) } + pub fn new_with_linter(linter: Linter, options: IsolatedLintHandlerOptions) -> Self { + Self { linter: Arc::new(linter), options: Arc::new(options) } } pub fn run_single(&self, uri: &Uri, content: Option) -> Option> { - IsolatedLintHandler::new(Arc::clone(&self.linter)) + IsolatedLintHandler::new(Arc::clone(&self.linter), Arc::clone(&self.options)) .run_single(&uri.to_file_path().unwrap(), content) } } @@ -36,6 +40,7 @@ mod test { use super::*; use crate::linter::tester::Tester; use oxc_linter::{LintFilter, LintFilterKind, Oxlintrc}; + use rustc_hash::FxHashMap; #[test] fn test_no_errors() { @@ -50,7 +55,7 @@ mod test { .with_filter(&LintFilter::deny(LintFilterKind::parse("no-console".into()).unwrap())) .build() .unwrap(); - let linter = Linter::new(LintOptions::default(), config_store).with_fix(FixKind::SafeFix); + let linter = Linter::new(LintOptions::default(), config_store); Tester::new_with_linter(linter) .with_snapshot_suffix("deny_no_console") @@ -68,7 +73,7 @@ mod test { .unwrap() .build() .unwrap(); - let linter = Linter::new(LintOptions::default(), config_store).with_fix(FixKind::SafeFix); + let linter = Linter::new(LintOptions::default(), config_store); Tester::new_with_linter(linter) .test_and_snapshot_single_file("fixtures/linter/issue_9958/issue.ts"); @@ -85,7 +90,7 @@ mod test { .unwrap() .build() .unwrap(); - let linter = Linter::new(LintOptions::default(), config_store).with_fix(FixKind::SafeFix); + let linter = Linter::new(LintOptions::default(), config_store); Tester::new_with_linter(linter) .test_and_snapshot_single_file("fixtures/linter/regexp_feature/index.ts"); @@ -109,16 +114,21 @@ mod test { .build() .unwrap(); let linter = Linter::new(LintOptions::default(), config_store); - - Tester::new_with_linter(linter) + let server_linter = ServerLinter::new_with_linter( + linter, + IsolatedLintHandlerOptions { + use_cross_module: true, + root_path: std::env::current_dir().expect("could not get current dir"), + }, + ); + Tester::new_with_server_linter(server_linter) .test_and_snapshot_single_file("fixtures/linter/cross_module/debugger.ts"); } #[test] - // ToDo: only available with runtime oxc-project/oxc#10268 fn test_cross_module_no_cycle() { let config_store = ConfigStoreBuilder::from_oxlintrc( - false, + true, Oxlintrc::from_file(&PathBuf::from("fixtures/linter/cross_module/.oxlintrc.json")) .unwrap(), ) @@ -126,8 +136,106 @@ mod test { .build() .unwrap(); let linter = Linter::new(LintOptions::default(), config_store); - - Tester::new_with_linter(linter) + let server_linter = ServerLinter::new_with_linter( + linter, + IsolatedLintHandlerOptions { + use_cross_module: true, + root_path: std::env::current_dir().expect("could not get current dir"), + }, + ); + + Tester::new_with_server_linter(server_linter) .test_and_snapshot_single_file("fixtures/linter/cross_module/dep-a.ts"); } + + #[test] + fn test_cross_module_no_cycle_nested_config() { + let folder_config_path = + &PathBuf::from("fixtures/linter/cross_module_nested_config/folder/.oxlintrc.json"); + let default_store = + ConfigStoreBuilder::from_oxlintrc(false, Oxlintrc::default()).unwrap().build().unwrap(); + let folder_store = ConfigStoreBuilder::from_oxlintrc( + false, + Oxlintrc::from_file(folder_config_path).unwrap(), + ) + .unwrap() + .build() + .unwrap(); + + let folder_folder_absolute_path = + std::env::current_dir().unwrap().join(folder_config_path.parent().unwrap()); + + // do not insert the default store + let mut nested_configs = FxHashMap::default(); + nested_configs.insert(folder_folder_absolute_path, folder_store); + + let linter = + Linter::new_with_nested_configs(LintOptions::default(), default_store, nested_configs); + let server_linter = ServerLinter::new_with_linter( + linter, + IsolatedLintHandlerOptions { + use_cross_module: true, + root_path: std::env::current_dir().expect("could not get current dir"), + }, + ); + + Tester::new_with_server_linter(server_linter.clone()) + .test_and_snapshot_single_file("fixtures/linter/cross_module_nested_config/dep-a.ts"); + + Tester::new_with_server_linter(server_linter).test_and_snapshot_single_file( + "fixtures/linter/cross_module_nested_config/folder/folder-dep-a.ts", + ); + } + + #[test] + fn test_cross_module_no_cycle_extended_config() { + // ConfigStore searches for the extended config by itself + // but the LSP still finds the second config with the file walker + // to simulate the behavior, we build it like the server + let extended_config_path = + &PathBuf::from("fixtures/linter/cross_module_extended_config/.oxlintrc.json"); + let folder_config_path = + &PathBuf::from("fixtures/linter/cross_module_extended_config/config/.oxlintrc.json"); + + let default_store = + ConfigStoreBuilder::from_oxlintrc(false, Oxlintrc::default()).unwrap().build().unwrap(); + let extended_store = ConfigStoreBuilder::from_oxlintrc( + false, + Oxlintrc::from_file(extended_config_path).unwrap(), + ) + .unwrap() + .build() + .unwrap(); + let folder_store = ConfigStoreBuilder::from_oxlintrc( + false, + Oxlintrc::from_file(folder_config_path).unwrap(), + ) + .unwrap() + .build() + .unwrap(); + + let folder_folder_absolute_path = + std::env::current_dir().unwrap().join(folder_config_path.parent().unwrap()); + let extended_folder_absolute_path = + std::env::current_dir().unwrap().join(extended_config_path.parent().unwrap()); + + // do not insert the default store + let mut nested_configs = FxHashMap::default(); + nested_configs.insert(folder_folder_absolute_path, folder_store); + nested_configs.insert(extended_folder_absolute_path, extended_store); + + let linter = + Linter::new_with_nested_configs(LintOptions::default(), default_store, nested_configs); + + let server_linter = ServerLinter::new_with_linter( + linter, + IsolatedLintHandlerOptions { + use_cross_module: true, + root_path: std::env::current_dir().expect("could not get current dir"), + }, + ); + + Tester::new_with_server_linter(server_linter) + .test_and_snapshot_single_file("fixtures/linter/cross_module_extended_config/dep-a.ts"); + } } diff --git a/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_astro_debugger.astro.snap b/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_astro_debugger.astro.snap index fcf27a734d4df..16b6703bfa75d 100644 --- a/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_astro_debugger.astro.snap +++ b/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_astro_debugger.astro.snap @@ -2,7 +2,7 @@ source: crates/oxc_language_server/src/linter/tester.rs --- code: "eslint(no-debugger)" -code_description.href: "https://oxc.rs/docs/guide/usage/linter/rules/eslint/no-debugger" +code_description.href: "https://oxc.rs/docs/guide/usage/linter/rules/eslint/no-debugger.html" message: "`debugger` statement is not allowed\nhelp: Remove the debugger statement" range: Range { start: Position { line: 1, character: 0 }, end: Position { line: 1, character: 8 } } related_information[0].message: "" @@ -14,7 +14,7 @@ tags: None code: "eslint(no-debugger)" -code_description.href: "https://oxc.rs/docs/guide/usage/linter/rules/eslint/no-debugger" +code_description.href: "https://oxc.rs/docs/guide/usage/linter/rules/eslint/no-debugger.html" message: "`debugger` statement is not allowed\nhelp: Remove the debugger statement" range: Range { start: Position { line: 10, character: 2 }, end: Position { line: 10, character: 10 } } related_information[0].message: "" @@ -26,7 +26,7 @@ tags: None code: "eslint(no-debugger)" -code_description.href: "https://oxc.rs/docs/guide/usage/linter/rules/eslint/no-debugger" +code_description.href: "https://oxc.rs/docs/guide/usage/linter/rules/eslint/no-debugger.html" message: "`debugger` statement is not allowed\nhelp: Remove the debugger statement" range: Range { start: Position { line: 14, character: 2 }, end: Position { line: 14, character: 10 } } related_information[0].message: "" @@ -38,7 +38,7 @@ tags: None code: "eslint(no-debugger)" -code_description.href: "https://oxc.rs/docs/guide/usage/linter/rules/eslint/no-debugger" +code_description.href: "https://oxc.rs/docs/guide/usage/linter/rules/eslint/no-debugger.html" message: "`debugger` statement is not allowed\nhelp: Remove the debugger statement" range: Range { start: Position { line: 18, character: 2 }, end: Position { line: 18, character: 10 } } related_information[0].message: "" diff --git a/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_cross_module_debugger.ts.snap b/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_cross_module_debugger.ts.snap index 5a589eb3a7401..281ed41dc6f29 100644 --- a/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_cross_module_debugger.ts.snap +++ b/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_cross_module_debugger.ts.snap @@ -2,7 +2,7 @@ source: crates/oxc_language_server/src/linter/tester.rs --- code: "eslint(no-debugger)" -code_description.href: "https://oxc.rs/docs/guide/usage/linter/rules/eslint/no-debugger" +code_description.href: "https://oxc.rs/docs/guide/usage/linter/rules/eslint/no-debugger.html" message: "`debugger` statement is not allowed\nhelp: Remove the debugger statement" range: Range { start: Position { line: 1, character: 0 }, end: Position { line: 1, character: 9 } } related_information[0].message: "" diff --git a/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_cross_module_dep-a.ts.snap b/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_cross_module_dep-a.ts.snap index 9292d49673ab2..b2992e67fa440 100644 --- a/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_cross_module_dep-a.ts.snap +++ b/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_cross_module_dep-a.ts.snap @@ -1,4 +1,13 @@ --- source: crates/oxc_language_server/src/linter/tester.rs --- -No diagnostic reports +code: "eslint-plugin-import(no-cycle)" +code_description.href: "https://oxc.rs/docs/guide/usage/linter/rules/import/no-cycle.html" +message: "Dependency cycle detected\nhelp: These paths form a cycle: \n-> ./dep-b.ts - fixtures/linter/cross_module/dep-b.ts\n-> ./dep-a.ts - fixtures/linter/cross_module/dep-a.ts" +range: Range { start: Position { line: 1, character: 18 }, end: Position { line: 1, character: 30 } } +related_information[0].message: "" +related_information[0].location.uri: "file:///fixtures/linter/cross_module/dep-a.ts" +related_information[0].location.range: Range { start: Position { line: 1, character: 18 }, end: Position { line: 1, character: 30 } } +severity: Some(Error) +source: Some("oxc") +tags: None diff --git a/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_cross_module_extended_config_dep-a.ts.snap b/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_cross_module_extended_config_dep-a.ts.snap new file mode 100644 index 0000000000000..d791a09acbdd8 --- /dev/null +++ b/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_cross_module_extended_config_dep-a.ts.snap @@ -0,0 +1,13 @@ +--- +source: crates/oxc_language_server/src/linter/tester.rs +--- +code: "eslint-plugin-import(no-cycle)" +code_description.href: "https://oxc.rs/docs/guide/usage/linter/rules/import/no-cycle.html" +message: "Dependency cycle detected\nhelp: These paths form a cycle: \n-> ./dep-b.ts - fixtures/linter/cross_module_extended_config/dep-b.ts\n-> ./dep-a.ts - fixtures/linter/cross_module_extended_config/dep-a.ts" +range: Range { start: Position { line: 1, character: 18 }, end: Position { line: 1, character: 30 } } +related_information[0].message: "" +related_information[0].location.uri: "file:///fixtures/linter/cross_module_extended_config/dep-a.ts" +related_information[0].location.range: Range { start: Position { line: 1, character: 18 }, end: Position { line: 1, character: 30 } } +severity: Some(Error) +source: Some("oxc") +tags: None diff --git a/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_cross_module_nested_config_dep-a.ts.snap b/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_cross_module_nested_config_dep-a.ts.snap new file mode 100644 index 0000000000000..9292d49673ab2 --- /dev/null +++ b/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_cross_module_nested_config_dep-a.ts.snap @@ -0,0 +1,4 @@ +--- +source: crates/oxc_language_server/src/linter/tester.rs +--- +No diagnostic reports diff --git a/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_cross_module_nested_config_folder_folder-dep-a.ts.snap b/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_cross_module_nested_config_folder_folder-dep-a.ts.snap new file mode 100644 index 0000000000000..857400cad445f --- /dev/null +++ b/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_cross_module_nested_config_folder_folder-dep-a.ts.snap @@ -0,0 +1,13 @@ +--- +source: crates/oxc_language_server/src/linter/tester.rs +--- +code: "eslint-plugin-import(no-cycle)" +code_description.href: "https://oxc.rs/docs/guide/usage/linter/rules/import/no-cycle.html" +message: "Dependency cycle detected\nhelp: These paths form a cycle: \n-> ./folder-dep-b.ts - fixtures/linter/cross_module_nested_config/folder/folder-dep-b.ts\n-> ./folder-dep-a.ts - fixtures/linter/cross_module_nested_config/folder/folder-dep-a.ts" +range: Range { start: Position { line: 1, character: 18 }, end: Position { line: 1, character: 37 } } +related_information[0].message: "" +related_information[0].location.uri: "file:///fixtures/linter/cross_module_nested_config/folder/folder-dep-a.ts" +related_information[0].location.range: Range { start: Position { line: 1, character: 18 }, end: Position { line: 1, character: 37 } } +severity: Some(Error) +source: Some("oxc") +tags: None diff --git a/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_hello_world.js@deny_no_console.snap b/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_hello_world.js@deny_no_console.snap index f213d3797bd9d..f54366c201b40 100644 --- a/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_hello_world.js@deny_no_console.snap +++ b/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_hello_world.js@deny_no_console.snap @@ -2,7 +2,7 @@ source: crates/oxc_language_server/src/linter/tester.rs --- code: "eslint(no-console)" -code_description.href: "https://oxc.rs/docs/guide/usage/linter/rules/eslint/no-console" +code_description.href: "https://oxc.rs/docs/guide/usage/linter/rules/eslint/no-console.html" message: "eslint(no-console): Unexpected console statement.\nhelp: Delete this console statement." range: Range { start: Position { line: 0, character: 0 }, end: Position { line: 0, character: 11 } } related_information[0].message: "" diff --git a/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_issue_9958_issue.ts.snap b/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_issue_9958_issue.ts.snap index 1890a9cb181af..ff0735a66baf4 100644 --- a/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_issue_9958_issue.ts.snap +++ b/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_issue_9958_issue.ts.snap @@ -2,7 +2,7 @@ source: crates/oxc_language_server/src/linter/tester.rs --- code: "eslint(no-extra-boolean-cast)" -code_description.href: "https://oxc.rs/docs/guide/usage/linter/rules/eslint/no-extra-boolean-cast" +code_description.href: "https://oxc.rs/docs/guide/usage/linter/rules/eslint/no-extra-boolean-cast.html" message: "Redundant double negation\nhelp: Remove the double negation as it will already be coerced to a boolean" range: Range { start: Position { line: 3, character: 14 }, end: Position { line: 3, character: 17 } } related_information[0].message: "" @@ -14,7 +14,7 @@ tags: None code: "typescript-eslint(no-non-null-asserted-optional-chain)" -code_description.href: "https://oxc.rs/docs/guide/usage/linter/rules/typescript_eslint/no-non-null-asserted-optional-chain" +code_description.href: "https://oxc.rs/docs/guide/usage/linter/rules/typescript/no-non-null-asserted-optional-chain.html" message: "Optional chain expressions can return undefined by design: using a non-null assertion is unsafe and wrong.\nhelp: Remove the non-null assertion." range: Range { start: Position { line: 11, character: 18 }, end: Position { line: 11, character: 19 } } related_information[0].message: "non-null assertion made after optional chain" diff --git a/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_regexp_feature_index.ts.snap b/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_regexp_feature_index.ts.snap index 3fc2bfb5eec45..b7d78622a6422 100644 --- a/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_regexp_feature_index.ts.snap +++ b/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_regexp_feature_index.ts.snap @@ -2,7 +2,7 @@ source: crates/oxc_language_server/src/linter/tester.rs --- code: "eslint(no-control-regex)" -code_description.href: "https://oxc.rs/docs/guide/usage/linter/rules/eslint/no-control-regex" +code_description.href: "https://oxc.rs/docs/guide/usage/linter/rules/eslint/no-control-regex.html" message: "Unexpected control character\nhelp: '\\u0000' is not a valid control character." range: Range { start: Position { line: 1, character: 13 }, end: Position { line: 1, character: 32 } } related_information[0].message: "" @@ -14,7 +14,7 @@ tags: None code: "eslint(no-useless-escape)" -code_description.href: "https://oxc.rs/docs/guide/usage/linter/rules/eslint/no-useless-escape" +code_description.href: "https://oxc.rs/docs/guide/usage/linter/rules/eslint/no-useless-escape.html" message: "Unnecessary escape character '/'\nhelp: Replace `\\/` with `/`." range: Range { start: Position { line: 0, character: 16 }, end: Position { line: 0, character: 18 } } related_information[0].message: "" diff --git a/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_svelte_debugger.svelte.snap b/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_svelte_debugger.svelte.snap index d58db8b71e0c2..25c031acbbe15 100644 --- a/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_svelte_debugger.svelte.snap +++ b/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_svelte_debugger.svelte.snap @@ -2,7 +2,7 @@ source: crates/oxc_language_server/src/linter/tester.rs --- code: "eslint(no-debugger)" -code_description.href: "https://oxc.rs/docs/guide/usage/linter/rules/eslint/no-debugger" +code_description.href: "https://oxc.rs/docs/guide/usage/linter/rules/eslint/no-debugger.html" message: "`debugger` statement is not allowed\nhelp: Remove the debugger statement" range: Range { start: Position { line: 1, character: 1 }, end: Position { line: 1, character: 10 } } related_information[0].message: "" diff --git a/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_vue_debugger.vue.snap b/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_vue_debugger.vue.snap index fa546e9ee92bf..a20559e111e0d 100644 --- a/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_vue_debugger.vue.snap +++ b/crates/oxc_language_server/src/linter/snapshots/fixtures_linter_vue_debugger.vue.snap @@ -2,7 +2,7 @@ source: crates/oxc_language_server/src/linter/tester.rs --- code: "eslint(no-debugger)" -code_description.href: "https://oxc.rs/docs/guide/usage/linter/rules/eslint/no-debugger" +code_description.href: "https://oxc.rs/docs/guide/usage/linter/rules/eslint/no-debugger.html" message: "`debugger` statement is not allowed\nhelp: Remove the debugger statement" range: Range { start: Position { line: 5, character: 4 }, end: Position { line: 5, character: 12 } } related_information[0].message: "" @@ -14,7 +14,7 @@ tags: None code: "eslint(no-debugger)" -code_description.href: "https://oxc.rs/docs/guide/usage/linter/rules/eslint/no-debugger" +code_description.href: "https://oxc.rs/docs/guide/usage/linter/rules/eslint/no-debugger.html" message: "`debugger` statement is not allowed\nhelp: Remove the debugger statement" range: Range { start: Position { line: 10, character: 4 }, end: Position { line: 10, character: 13 } } related_information[0].message: "" diff --git a/crates/oxc_language_server/src/linter/tester.rs b/crates/oxc_language_server/src/linter/tester.rs index ea86ffed5aba9..74dcfb5fe593f 100644 --- a/crates/oxc_language_server/src/linter/tester.rs +++ b/crates/oxc_language_server/src/linter/tester.rs @@ -6,7 +6,10 @@ use tower_lsp_server::{ lsp_types::{CodeDescription, NumberOrString, Uri}, }; -use super::{error_with_position::DiagnosticReport, server_linter::ServerLinter}; +use super::{ + error_with_position::DiagnosticReport, isolated_lint_handler::IsolatedLintHandlerOptions, + server_linter::ServerLinter, +}; /// Given a file path relative to the crate root directory, return the URI of the file. pub fn get_file_uri(relative_file_path: &str) -> Uri { @@ -89,11 +92,27 @@ pub struct Tester<'t> { impl Tester<'_> { pub fn new() -> Self { - Self { snapshot_suffix: None, server_linter: ServerLinter::new() } + Self { + snapshot_suffix: None, + server_linter: ServerLinter::new(IsolatedLintHandlerOptions { + use_cross_module: false, + root_path: std::env::current_dir().expect("could not get current dir"), + }), + } } pub fn new_with_linter(linter: Linter) -> Self { - Self { snapshot_suffix: None, server_linter: ServerLinter::new_with_linter(linter) } + Self::new_with_server_linter(ServerLinter::new_with_linter( + linter, + IsolatedLintHandlerOptions { + use_cross_module: false, + root_path: std::env::current_dir().expect("could not get current dir"), + }, + )) + } + + pub fn new_with_server_linter(server_linter: ServerLinter) -> Self { + Self { snapshot_suffix: None, server_linter } } pub fn with_snapshot_suffix(mut self, suffix: &'static str) -> Self { diff --git a/crates/oxc_language_server/src/main.rs b/crates/oxc_language_server/src/main.rs index 997f4cb161bfa..1a3f0833adf95 100644 --- a/crates/oxc_language_server/src/main.rs +++ b/crates/oxc_language_server/src/main.rs @@ -2,7 +2,7 @@ use commands::LSP_COMMANDS; use futures::future::join_all; use globset::Glob; use ignore::gitignore::Gitignore; -use linter::config_walker::ConfigWalker; +use linter::{config_walker::ConfigWalker, isolated_lint_handler::IsolatedLintHandlerOptions}; use log::{debug, error, info}; use oxc_linter::{ConfigStore, ConfigStoreBuilder, FixKind, LintOptions, Linter, Oxlintrc}; use rustc_hash::{FxBuildHasher, FxHashMap}; @@ -570,15 +570,25 @@ impl Backend { Oxlintrc::default() }; - let config_store = ConfigStoreBuilder::from_oxlintrc(false, oxlintrc.clone()) - .expect("failed to build config") - .build() - .expect("failed to build config"); + // clone because we are returning it for ignore builder + let config_builder = + ConfigStoreBuilder::from_oxlintrc(false, oxlintrc.clone()).unwrap_or_default(); + + // TODO(refactor): pull this into a shared function, because in oxlint we have the same functionality. + let use_nested_config = self.options.lock().await.use_nested_configs(); + + let use_cross_module = if use_nested_config { + self.nested_configs.pin().values().any(|config| config.plugins().has_import()) + } else { + config_builder.plugins().has_import() + }; + + let config_store = config_builder.build().expect("Failed to build config store"); let lint_options = LintOptions { fix: self.options.lock().await.fix_kind(), ..Default::default() }; - let linter = if self.options.lock().await.use_nested_configs() { + let linter = if use_nested_config { let nested_configs = self.nested_configs.pin(); let nested_configs_copy: FxHashMap = nested_configs .iter() @@ -590,9 +600,12 @@ impl Backend { Linter::new(lint_options, config_store) }; - *self.server_linter.write().await = ServerLinter::new_with_linter(linter); + *self.server_linter.write().await = ServerLinter::new_with_linter( + linter, + IsolatedLintHandlerOptions { use_cross_module, root_path: root_path.to_path_buf() }, + ); - Some(oxlintrc.clone()) + Some(oxlintrc) } async fn handle_file_update(&self, uri: Uri, content: Option, version: Option) { @@ -644,7 +657,10 @@ async fn main() { let stdin = tokio::io::stdin(); let stdout = tokio::io::stdout(); - let server_linter = ServerLinter::new(); + let server_linter = ServerLinter::new(IsolatedLintHandlerOptions { + use_cross_module: false, + root_path: PathBuf::new(), + }); let diagnostics_report_map = ConcurrentHashMap::default(); let (service, socket) = LspService::build(|client| Backend { diff --git a/crates/oxc_linter/Cargo.toml b/crates/oxc_linter/Cargo.toml index 7fe55a80220e7..4abf5af264c52 100644 --- a/crates/oxc_linter/Cargo.toml +++ b/crates/oxc_linter/Cargo.toml @@ -16,6 +16,7 @@ description.workspace = true [features] default = [] ruledocs = ["oxc_macros/ruledocs"] # Enables the `ruledocs` feature for conditional compilation +language_server = ["oxc_data_structures/rope"] # For the Runtime to support needed information for the language server [lints] workspace = true @@ -29,6 +30,7 @@ oxc_ast = { workspace = true } oxc_ast_visit = { workspace = true } oxc_cfg = { workspace = true } oxc_codegen = { workspace = true } +oxc_data_structures = { workspace = true, optional = true } oxc_diagnostics = { workspace = true } oxc_ecmascript = { workspace = true } oxc_index = { workspace = true, features = ["serde"] } diff --git a/crates/oxc_linter/src/fixer/fix.rs b/crates/oxc_linter/src/fixer/fix.rs index 7616e0ae47fee..7ebe04eda5541 100644 --- a/crates/oxc_linter/src/fixer/fix.rs +++ b/crates/oxc_linter/src/fixer/fix.rs @@ -4,6 +4,9 @@ use bitflags::bitflags; use oxc_allocator::{Allocator, CloneIn}; use oxc_span::{GetSpan, SPAN, Span}; +#[cfg(feature = "language_server")] +use crate::service::offset_to_position::SpanPositionMessage; + bitflags! { /// Flags describing an automatic code fix. /// @@ -288,6 +291,12 @@ pub struct Fix<'a> { pub span: Span, } +#[cfg(feature = "language_server")] +pub struct FixWithPosition<'a> { + pub content: Cow<'a, str>, + pub span: SpanPositionMessage<'a>, +} + impl<'new> CloneIn<'new> for Fix<'_> { type Cloned = Fix<'new>; diff --git a/crates/oxc_linter/src/fixer/mod.rs b/crates/oxc_linter/src/fixer/mod.rs index de896aba864a3..f87a03b252f82 100644 --- a/crates/oxc_linter/src/fixer/mod.rs +++ b/crates/oxc_linter/src/fixer/mod.rs @@ -6,6 +6,13 @@ use oxc_span::{GetSpan, Span}; use crate::LintContext; +#[cfg(feature = "language_server")] +use crate::service::offset_to_position::SpanPositionMessage; +#[cfg(feature = "language_server")] +pub use fix::FixWithPosition; +#[cfg(feature = "language_server")] +use oxc_diagnostics::{OxcCode, Severity}; + mod fix; pub use fix::{CompositeFix, Fix, FixKind, RuleFix}; use oxc_allocator::{Allocator, CloneIn}; @@ -225,6 +232,32 @@ pub struct Message<'a> { fixed: bool, } +#[cfg(feature = "language_server")] +pub struct MessageWithPosition<'a> { + pub message: Cow<'a, str>, + pub labels: Option>>, + pub help: Option>, + pub severity: Severity, + pub code: OxcCode, + pub url: Option>, + pub fix: Option>, +} + +#[cfg(feature = "language_server")] +impl From for MessageWithPosition<'_> { + fn from(from: OxcDiagnostic) -> Self { + Self { + message: from.message.clone(), + labels: None, + help: from.help.clone(), + severity: from.severity, + code: from.code.clone(), + url: from.url.clone(), + fix: None, + } + } +} + impl<'new> CloneIn<'new> for Message<'_> { type Cloned = Message<'new>; diff --git a/crates/oxc_linter/src/lib.rs b/crates/oxc_linter/src/lib.rs index 95b81935d252e..66703ea0bea03 100644 --- a/crates/oxc_linter/src/lib.rs +++ b/crates/oxc_linter/src/lib.rs @@ -53,6 +53,9 @@ use crate::{ utils::iter_possible_jest_call_node, }; +#[cfg(feature = "language_server")] +pub use crate::fixer::{FixWithPosition, MessageWithPosition}; + #[cfg(target_pointer_width = "64")] #[test] fn size_asserts() { @@ -62,7 +65,7 @@ fn size_asserts() { assert_eq!(size_of::(), 16); } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct Linter { // rules: Vec, options: LintOptions, diff --git a/crates/oxc_linter/src/service/mod.rs b/crates/oxc_linter/src/service/mod.rs index c7ca1604321ca..15e5fb328d1c2 100644 --- a/crates/oxc_linter/src/service/mod.rs +++ b/crates/oxc_linter/src/service/mod.rs @@ -11,6 +11,9 @@ use crate::Linter; mod runtime; +#[cfg(feature = "language_server")] +pub mod offset_to_position; + pub struct LintServiceOptions { /// Current working directory cwd: Box, @@ -87,15 +90,25 @@ impl LintService { tx_error.send(None).unwrap(); } + #[cfg(feature = "language_server")] + pub fn run_source<'a>( + &mut self, + allocator: &'a oxc_allocator::Allocator, + path: &Arc, + source_text: &str, + ) -> Vec> { + self.runtime.run_source(allocator, path, source_text) + } + /// For tests #[cfg(test)] - pub(crate) fn run_source<'a>( + pub(crate) fn run_test_source<'a>( &mut self, allocator: &'a oxc_allocator::Allocator, source_text: &str, check_syntax_errors: bool, tx_error: &DiagnosticSender, ) -> Vec> { - self.runtime.run_source(allocator, source_text, check_syntax_errors, tx_error) + self.runtime.run_test_source(allocator, source_text, check_syntax_errors, tx_error) } } diff --git a/crates/oxc_linter/src/service/offset_to_position.rs b/crates/oxc_linter/src/service/offset_to_position.rs new file mode 100644 index 0000000000000..6a008b1e54f00 --- /dev/null +++ b/crates/oxc_linter/src/service/offset_to_position.rs @@ -0,0 +1,100 @@ +use oxc_data_structures::rope::{Rope, get_line_column}; +use std::borrow::Cow; + +#[derive(Clone)] +pub struct SpanPositionMessage<'a> { + /// A brief suggestion message describing the fix. Will be shown in + /// editors via code actions. + message: Option>, + + start: SpanPosition, + end: SpanPosition, +} + +impl<'a> SpanPositionMessage<'a> { + pub fn new(start: SpanPosition, end: SpanPosition) -> Self { + Self { start, end, message: None } + } + + pub fn with_message(mut self, message: Option>) -> Self { + self.message = message; + self + } + + pub fn start(&self) -> &SpanPosition { + &self.start + } + + pub fn end(&self) -> &SpanPosition { + &self.end + } + + pub fn message(&self) -> Option<&Cow<'a, str>> { + self.message.as_ref() + } +} + +#[derive(Clone)] +pub struct SpanPosition { + pub line: u32, + pub character: u32, +} + +impl SpanPosition { + pub fn new(line: u32, column: u32) -> Self { + Self { line, character: column } + } +} + +pub fn offset_to_position(offset: u32, source_text: &str) -> SpanPosition { + // TODO(perf): share a single instance of `Rope` + let rope = Rope::from_str(source_text); + let (line, column) = get_line_column(&rope, offset, source_text); + SpanPosition::new(line, column) +} + +#[cfg(test)] +mod test { + use super::offset_to_position; + + #[test] + fn single_line() { + let source = "foo.bar!;"; + assert_position(source, 0, (0, 0)); + assert_position(source, 4, (0, 4)); + assert_position(source, 9, (0, 9)); + } + + #[test] + fn multi_line() { + let source = "console.log(\n foo.bar!\n);"; + assert_position(source, 0, (0, 0)); + assert_position(source, 12, (0, 12)); + assert_position(source, 13, (1, 0)); + assert_position(source, 23, (1, 10)); + assert_position(source, 24, (2, 0)); + assert_position(source, 26, (2, 2)); + } + + #[test] + fn multi_byte() { + let source = "let foo = \n '👍';"; + assert_position(source, 10, (0, 10)); + assert_position(source, 11, (1, 0)); + assert_position(source, 14, (1, 3)); + assert_position(source, 18, (1, 5)); + assert_position(source, 19, (1, 6)); + } + + #[test] + #[should_panic(expected = "out of bounds")] + fn out_of_bounds() { + offset_to_position(100, "foo"); + } + + fn assert_position(source: &str, offset: u32, expected: (u32, u32)) { + let position = offset_to_position(offset, source); + assert_eq!(position.line, expected.0); + assert_eq!(position.character, expected.1); + } +} diff --git a/crates/oxc_linter/src/service/runtime.rs b/crates/oxc_linter/src/service/runtime.rs index aa961511a704a..2579a81fb12b6 100644 --- a/crates/oxc_linter/src/service/runtime.rs +++ b/crates/oxc_linter/src/service/runtime.rs @@ -22,6 +22,9 @@ use oxc_resolver::Resolver; use oxc_semantic::{Semantic, SemanticBuilder}; use oxc_span::{CompactStr, SourceType, VALID_EXTENSIONS}; +#[cfg(feature = "language_server")] +use oxc_allocator::CloneIn; + use super::LintServiceOptions; use crate::{ Fixer, Linter, Message, @@ -30,6 +33,11 @@ use crate::{ utils::read_to_string, }; +#[cfg(feature = "language_server")] +use crate::fixer::{FixWithPosition, MessageWithPosition}; +#[cfg(feature = "language_server")] +use crate::service::offset_to_position::{SpanPositionMessage, offset_to_position}; + pub struct Runtime { cwd: Box, /// All paths to lint @@ -37,6 +45,11 @@ pub struct Runtime { pub(super) linter: Linter, resolver: Option, + // The language server uses more up to date source_text provided by `workspace/didChange` request. + // This is required to support `run: "onType"` configuration + #[cfg(feature = "language_server")] + source_text_cache: FxHashMap, String>, + #[cfg(test)] pub(super) test_source: std::sync::RwLock>, } @@ -137,6 +150,8 @@ impl Runtime { paths: options.paths.iter().cloned().collect(), linter, resolver, + #[cfg(feature = "language_server")] + source_text_cache: FxHashMap::default(), #[cfg(test)] test_source: std::sync::RwLock::new(None), } @@ -167,7 +182,6 @@ impl Runtime { }) } - #[cfg_attr(not(test), expect(clippy::unused_self))] fn get_source_type_and_text( &self, path: &Path, @@ -187,6 +201,14 @@ impl Runtime { { return Some(Ok((source_type, test_source.clone()))); } + + // The language server uses more up to date source_text provided by `workspace/didChange` request. + // This is required to support `run: "onType"` configuration + #[cfg(feature = "language_server")] + if let Some(source_text) = self.source_text_cache.get(path.as_os_str()) { + return Some(Ok((source_type, source_text.clone()))); + } + let file_result = read_to_string(path).map_err(|e| { Error::new(OxcDiagnostic::error(format!( "Failed to open file {path:?} with error \"{e}\"" @@ -503,8 +525,129 @@ impl Runtime { }); } - #[cfg(test)] + // clippy: the source field is checked and assumed to be less than 4GB, and + // we assume that the fix offset will not exceed 2GB in either direction + // language_server: the language server needs line and character position + // the struct not using `oxc_diagnostic::Error, because we are just collecting information + // and returning it to the client to let him display it. + #[expect(clippy::cast_possible_truncation)] + #[cfg(feature = "language_server")] pub(super) fn run_source<'a>( + &mut self, + allocator: &'a oxc_allocator::Allocator, + path: &Arc, + source_text: &str, + ) -> Vec> { + use std::sync::Mutex; + + // the language server can have more up to date source_text then the filesystem + #[cfg(feature = "language_server")] + { + self.source_text_cache.insert(Arc::clone(path), source_text.to_owned()); + } + + let messages = Mutex::new(Vec::>::new()); + let (sender, _receiver) = mpsc::channel(); + rayon::scope(|scope| { + self.resolve_modules(scope, true, &sender, |me, mut module| { + module.content.with_dependent_mut(|_owner, dependent| { + assert_eq!(module.section_module_records.len(), dependent.len()); + + for (record_result, section) in + module.section_module_records.into_iter().zip(dependent.drain(..)) + { + match record_result { + Err(diagnostics) => { + messages + .lock() + .unwrap() + .extend(diagnostics.into_iter().map(std::convert::Into::into)); + } + Ok(module_record) => { + let section_message = me.linter.run( + Path::new(&module.path), + Rc::new(section.semantic.unwrap()), + Arc::clone(&module_record), + ); + + messages.lock().unwrap().extend(section_message.iter().map( + |message| { + let message = message.clone_in(allocator); + + let labels = &message.error.labels.clone().map(|labels| { + labels + .into_iter() + .map(|labeled_span| { + let offset = labeled_span.offset() as u32; + let start_position = offset_to_position( + offset + section.source.start, + source_text, + ); + let end_position = offset_to_position( + offset + + section.source.start + + labeled_span.len() as u32, + source_text, + ); + let message = labeled_span + .label() + .map(|label| Cow::Owned(label.to_string())); + + SpanPositionMessage::new( + start_position, + end_position, + ) + .with_message(message) + }) + .collect::>() + }); + + MessageWithPosition { + message: message.error.message.clone(), + severity: message.error.severity, + help: message.error.help.clone(), + url: message.error.url.clone(), + code: message.error.code.clone(), + labels: labels.clone(), + fix: message.fix.map(|fix| FixWithPosition { + content: fix.content, + span: SpanPositionMessage::new( + offset_to_position(fix.span.start, source_text), + offset_to_position(fix.span.end, source_text), + ) + .with_message( + fix.message + .as_ref() + .map(|label| Cow::Owned(label.to_string())), + ), + }), + } + }, + )); + } + } + } + }); + }); + }); + + // ToDo: oxc_diagnostic::Error is not compatible with MessageWithPosition + // send use OxcDiagnostic or even better the MessageWithPosition struct + // while let Ok(diagnostics) = receiver.recv() { + // if let Some(diagnostics) = diagnostics { + // messages.lock().unwrap().extend( + // diagnostics.1 + // .into_iter() + // .map(|report| MessageWithPosition::from(report)) + // ); + // } + // } + + messages.into_inner().unwrap() + } + + #[cfg(test)] + pub(super) fn run_test_source<'a>( &mut self, allocator: &'a Allocator, source_text: &str, diff --git a/crates/oxc_linter/src/tester.rs b/crates/oxc_linter/src/tester.rs index 64faa86d75faf..3a9d1fd71d0ea 100644 --- a/crates/oxc_linter/src/tester.rs +++ b/crates/oxc_linter/src/tester.rs @@ -486,7 +486,7 @@ impl Tester { LintServiceOptions::new(cwd, paths).with_cross_module(self.plugins.has_import()); let mut lint_service = LintService::from_linter(linter, options); let (sender, _receiver) = mpsc::channel(); - let result = lint_service.run_source(&allocator, source_text, false, &sender); + let result = lint_service.run_test_source(&allocator, source_text, false, &sender); if result.is_empty() { return TestResult::Passed; diff --git a/editors/vscode/client/extension.spec.ts b/editors/vscode/client/extension.spec.ts index c6e71a853dd8a..83b4ccabfe2dd 100644 --- a/editors/vscode/client/extension.spec.ts +++ b/editors/vscode/client/extension.spec.ts @@ -161,6 +161,59 @@ suite('E2E Diagnostics', () => { strictEqual(diagnostics[0].range.end.character, 17); }); + test('cross module', async () => { + await loadFixture('cross_module'); + const diagnostics = await getDiagnostics('dep-a.ts'); + + strictEqual(diagnostics.length, 1); + assert(typeof diagnostics[0].code == 'object'); + strictEqual(diagnostics[0].code.target.authority, 'oxc.rs'); + strictEqual( + diagnostics[0].message, + 'Dependency cycle detected\nhelp: These paths form a cycle: \n-> ./dep-b.ts - diagnostic/dep-b.ts\n-> ./dep-a.ts - diagnostic/dep-a.ts', + ); + strictEqual(diagnostics[0].severity, DiagnosticSeverity.Error); + strictEqual(diagnostics[0].range.start.line, 1); + strictEqual(diagnostics[0].range.start.character, 18); + strictEqual(diagnostics[0].range.end.line, 1); + strictEqual(diagnostics[0].range.end.character, 30); + }); + + test('cross module with nested config', async () => { + await loadFixture('cross_module_nested_config'); + const diagnostics = await getDiagnostics('folder/folder-dep-a.ts'); + + strictEqual(diagnostics.length, 1); + assert(typeof diagnostics[0].code == 'object'); + strictEqual(diagnostics[0].code.target.authority, 'oxc.rs'); + strictEqual( + diagnostics[0].message, + 'Dependency cycle detected\nhelp: These paths form a cycle: \n-> ./folder-dep-b.ts - diagnostic/folder/folder-dep-b.ts\n-> ./folder-dep-a.ts - diagnostic/folder/folder-dep-a.ts', + ); + strictEqual(diagnostics[0].severity, DiagnosticSeverity.Error); + strictEqual(diagnostics[0].range.start.line, 1); + strictEqual(diagnostics[0].range.start.character, 18); + strictEqual(diagnostics[0].range.end.line, 1); + strictEqual(diagnostics[0].range.end.character, 37); + }); + + test('cross module with extended config', async () => { + await loadFixture('cross_module_extended_config'); + const diagnostics = await getDiagnostics('dep-a.ts'); + + assert(typeof diagnostics[0].code == 'object'); + strictEqual(diagnostics[0].code.target.authority, 'oxc.rs'); + strictEqual( + diagnostics[0].message, + 'Dependency cycle detected\nhelp: These paths form a cycle: \n-> ./dep-b.ts - diagnostic/dep-b.ts\n-> ./dep-a.ts - diagnostic/dep-a.ts', + ); + strictEqual(diagnostics[0].severity, DiagnosticSeverity.Error); + strictEqual(diagnostics[0].range.start.line, 1); + strictEqual(diagnostics[0].range.start.character, 18); + strictEqual(diagnostics[0].range.end.line, 1); + strictEqual(diagnostics[0].range.end.character, 30); + }); + test('setting rule to error, will report the diagnostic as error', async () => { const edit = new WorkspaceEdit(); edit.createFile(fileUri, { diff --git a/editors/vscode/fixtures/cross_module/.oxlintrc.json b/editors/vscode/fixtures/cross_module/.oxlintrc.json new file mode 100644 index 0000000000000..7d258733b4493 --- /dev/null +++ b/editors/vscode/fixtures/cross_module/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "plugins": [ + "import" + ], + "rules": { + "import/no-cycle": "error" + } +} diff --git a/editors/vscode/fixtures/cross_module/debugger.ts b/editors/vscode/fixtures/cross_module/debugger.ts new file mode 100644 index 0000000000000..0e88c30d1bd87 --- /dev/null +++ b/editors/vscode/fixtures/cross_module/debugger.ts @@ -0,0 +1,2 @@ +// Debugger should be shown as a warning +debugger; diff --git a/editors/vscode/fixtures/cross_module/dep-a.ts b/editors/vscode/fixtures/cross_module/dep-a.ts new file mode 100644 index 0000000000000..8e71bdb54e2e8 --- /dev/null +++ b/editors/vscode/fixtures/cross_module/dep-a.ts @@ -0,0 +1,4 @@ +// should report cycle detected +import { b } from './dep-b.ts'; + +b(); diff --git a/editors/vscode/fixtures/cross_module/dep-b.ts b/editors/vscode/fixtures/cross_module/dep-b.ts new file mode 100644 index 0000000000000..137d96df50b3f --- /dev/null +++ b/editors/vscode/fixtures/cross_module/dep-b.ts @@ -0,0 +1,4 @@ +// this file is also included in dep-a.ts and dep-a.ts should report a no-cycle diagnostic +import './dep-a.ts'; + +export function b() { /* ... */ } diff --git a/editors/vscode/fixtures/cross_module_extended_config/.oxlintrc.json b/editors/vscode/fixtures/cross_module_extended_config/.oxlintrc.json new file mode 100644 index 0000000000000..82edc543b5df5 --- /dev/null +++ b/editors/vscode/fixtures/cross_module_extended_config/.oxlintrc.json @@ -0,0 +1,5 @@ +{ + "extends": [ + "./config/.oxlintrc.json" + ] +} diff --git a/editors/vscode/fixtures/cross_module_extended_config/config/.oxlintrc.json b/editors/vscode/fixtures/cross_module_extended_config/config/.oxlintrc.json new file mode 100644 index 0000000000000..7d258733b4493 --- /dev/null +++ b/editors/vscode/fixtures/cross_module_extended_config/config/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "plugins": [ + "import" + ], + "rules": { + "import/no-cycle": "error" + } +} diff --git a/editors/vscode/fixtures/cross_module_extended_config/dep-a.ts b/editors/vscode/fixtures/cross_module_extended_config/dep-a.ts new file mode 100644 index 0000000000000..8e71bdb54e2e8 --- /dev/null +++ b/editors/vscode/fixtures/cross_module_extended_config/dep-a.ts @@ -0,0 +1,4 @@ +// should report cycle detected +import { b } from './dep-b.ts'; + +b(); diff --git a/editors/vscode/fixtures/cross_module_extended_config/dep-b.ts b/editors/vscode/fixtures/cross_module_extended_config/dep-b.ts new file mode 100644 index 0000000000000..137d96df50b3f --- /dev/null +++ b/editors/vscode/fixtures/cross_module_extended_config/dep-b.ts @@ -0,0 +1,4 @@ +// this file is also included in dep-a.ts and dep-a.ts should report a no-cycle diagnostic +import './dep-a.ts'; + +export function b() { /* ... */ } diff --git a/editors/vscode/fixtures/cross_module_nested_config/dep-a.ts b/editors/vscode/fixtures/cross_module_nested_config/dep-a.ts new file mode 100644 index 0000000000000..8e71bdb54e2e8 --- /dev/null +++ b/editors/vscode/fixtures/cross_module_nested_config/dep-a.ts @@ -0,0 +1,4 @@ +// should report cycle detected +import { b } from './dep-b.ts'; + +b(); diff --git a/editors/vscode/fixtures/cross_module_nested_config/dep-b.ts b/editors/vscode/fixtures/cross_module_nested_config/dep-b.ts new file mode 100644 index 0000000000000..137d96df50b3f --- /dev/null +++ b/editors/vscode/fixtures/cross_module_nested_config/dep-b.ts @@ -0,0 +1,4 @@ +// this file is also included in dep-a.ts and dep-a.ts should report a no-cycle diagnostic +import './dep-a.ts'; + +export function b() { /* ... */ } diff --git a/editors/vscode/fixtures/cross_module_nested_config/folder/.oxlintrc.json b/editors/vscode/fixtures/cross_module_nested_config/folder/.oxlintrc.json new file mode 100644 index 0000000000000..7d258733b4493 --- /dev/null +++ b/editors/vscode/fixtures/cross_module_nested_config/folder/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "plugins": [ + "import" + ], + "rules": { + "import/no-cycle": "error" + } +} diff --git a/editors/vscode/fixtures/cross_module_nested_config/folder/folder-dep-a.ts b/editors/vscode/fixtures/cross_module_nested_config/folder/folder-dep-a.ts new file mode 100644 index 0000000000000..55b132fd4d57a --- /dev/null +++ b/editors/vscode/fixtures/cross_module_nested_config/folder/folder-dep-a.ts @@ -0,0 +1,4 @@ +// should report cycle detected +import { b } from './folder-dep-b.ts'; + +b(); diff --git a/editors/vscode/fixtures/cross_module_nested_config/folder/folder-dep-b.ts b/editors/vscode/fixtures/cross_module_nested_config/folder/folder-dep-b.ts new file mode 100644 index 0000000000000..935ceddca1e82 --- /dev/null +++ b/editors/vscode/fixtures/cross_module_nested_config/folder/folder-dep-b.ts @@ -0,0 +1,4 @@ +// this file is also included in folder-dep-a.ts and folder-dep-a.ts should report a no-cycle diagnostic +import './folder-dep-a.ts'; + +export function b() { /* ... */ }