diff --git a/crates/oxc_language_server/README.md b/crates/oxc_language_server/README.md index 19ad3f6394df7..f26e54c0be5cd 100644 --- a/crates/oxc_language_server/README.md +++ b/crates/oxc_language_server/README.md @@ -19,12 +19,13 @@ This crate provides an [LSP](https://microsoft.github.io/language-server-protoco These options can be passed with [initialize](#initialize), [workspace/didChangeConfiguration](#workspace/didChangeConfiguration) and [workspace/configuration](#workspace/configuration). -| Option Key | Value(s) | Default | Description | -| ------------------------- | ------------------------------ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| `run` | `"onSave" \| "onType"` | `"onType"` | Should the server lint the files when the user is typing or saving | -| `configPath` | `` \| `null` | `null` | Path to a oxlint configuration file, passing a string will disable nested configuration | -| `unusedDisableDirectives` | `"allow" \| "warn"` \| "deny"` | `"allow"` | Define how directive comments like `// oxlint-disable-line` should be reported, when no errors would have been reported on that line anyway | -| `flags` | `Map` | `` | Special oxc language server flags, currently only one flag key is supported: `disable_nested_config` | +| Option Key | Value(s) | Default | Description | +| ------------------------- | ------------------------------ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `run` | `"onSave" \| "onType"` | `"onType"` | Should the server lint the files when the user is typing or saving | +| `configPath` | `` \| `null` | `null` | Path to a oxlint configuration file, passing a string will disable nested configuration | +| `tsConfigPath` | `` \| `null` | `null` | Path to a TypeScript configuration file. If your `tsconfig.json` is not at the root, alias paths will not be resolve correctly for the `import` plugin | +| `unusedDisableDirectives` | `"allow" \| "warn"` \| "deny"` | `"allow"` | Define how directive comments like `// oxlint-disable-line` should be reported, when no errors would have been reported on that line anyway | +| `flags` | `Map` | `` | Special oxc language server flags, currently only one flag key is supported: `disable_nested_config` | ## Supported LSP Specifications from Server @@ -40,6 +41,7 @@ The client can pass the workspace options like following: "options": { "run": "onType", "configPath": null, + "tsConfigPath": null, "unusedDisableDirectives": "allow", "flags": {} } @@ -74,6 +76,7 @@ The client can pass the workspace options like following: "options": { "run": "onType", "configPath": null, + "tsConfigPath": null, "unusedDisableDirectives": "allow", "flags": {} } @@ -161,6 +164,7 @@ The client can return a response like: [{ "run": "onType", "configPath": null, + "tsConfigPath": null, "unusedDisableDirectives": "allow", "flags": {} }] diff --git a/crates/oxc_language_server/fixtures/linter/ts_path_alias/.oxlintrc.json b/crates/oxc_language_server/fixtures/linter/ts_path_alias/.oxlintrc.json new file mode 100644 index 0000000000000..e64d00207feee --- /dev/null +++ b/crates/oxc_language_server/fixtures/linter/ts_path_alias/.oxlintrc.json @@ -0,0 +1,6 @@ +{ + "plugins": ["import"], + "rules": { + "import/no-cycle": "error" + } +} diff --git a/crates/oxc_language_server/fixtures/linter/ts_path_alias/deep/src/dep-a.ts b/crates/oxc_language_server/fixtures/linter/ts_path_alias/deep/src/dep-a.ts new file mode 100644 index 0000000000000..db553a2aa40b3 --- /dev/null +++ b/crates/oxc_language_server/fixtures/linter/ts_path_alias/deep/src/dep-a.ts @@ -0,0 +1,4 @@ +// should report cycle detected +import { b } from '@/dep-b'; + +b(); diff --git a/crates/oxc_language_server/fixtures/linter/ts_path_alias/deep/src/dep-b.ts b/crates/oxc_language_server/fixtures/linter/ts_path_alias/deep/src/dep-b.ts new file mode 100644 index 0000000000000..137d96df50b3f --- /dev/null +++ b/crates/oxc_language_server/fixtures/linter/ts_path_alias/deep/src/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/ts_path_alias/deep/tsconfig.json b/crates/oxc_language_server/fixtures/linter/ts_path_alias/deep/tsconfig.json new file mode 100644 index 0000000000000..70219df8c4a79 --- /dev/null +++ b/crates/oxc_language_server/fixtures/linter/ts_path_alias/deep/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": true, + "emitDeclarationOnly": true, + "declaration": true, + "paths": { + "@/*": ["./src/*"] + } + } +} 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 b2d8f33e0090b..f79e5406e3103 100644 --- a/crates/oxc_language_server/src/linter/isolated_lint_handler.rs +++ b/crates/oxc_language_server/src/linter/isolated_lint_handler.rs @@ -26,6 +26,7 @@ use super::error_with_position::{ pub struct IsolatedLintHandlerOptions { pub use_cross_module: bool, pub root_path: PathBuf, + pub tsconfig_path: Option, } pub struct IsolatedLintHandler { @@ -68,9 +69,13 @@ impl IsolatedLintHandler { options: &IsolatedLintHandlerOptions, ) -> Self { let linter = Linter::new(lint_options, config_store, None); - let lint_service_options = LintServiceOptions::new(options.root_path.clone()) + let mut lint_service_options = LintServiceOptions::new(options.root_path.clone()) .with_cross_module(options.use_cross_module); + if let Some(tsconfig_path) = &options.tsconfig_path { + lint_service_options = lint_service_options.with_tsconfig(tsconfig_path); + } + let service = LintService::new(linter, AllocatorPool::default(), lint_service_options); Self { service } diff --git a/crates/oxc_language_server/src/linter/server_linter.rs b/crates/oxc_language_server/src/linter/server_linter.rs index 219dd29f3b48d..880228a81d85a 100644 --- a/crates/oxc_language_server/src/linter/server_linter.rs +++ b/crates/oxc_language_server/src/linter/server_linter.rs @@ -100,7 +100,14 @@ impl ServerLinter { let isolated_linter = IsolatedLintHandler::new( lint_options, config_store, - &IsolatedLintHandlerOptions { use_cross_module, root_path: root_path.to_path_buf() }, + &IsolatedLintHandlerOptions { + use_cross_module, + root_path: root_path.to_path_buf(), + tsconfig_path: options + .ts_config_path + .as_ref() + .map(|path| Path::new(path).to_path_buf()), + }, ); Self { @@ -406,4 +413,16 @@ mod test { Tester::new("fixtures/linter/root_ignore_patterns", None) .test_and_snapshot_single_file("ignored-file.ts"); } + + #[test] + fn test_ts_alias() { + Tester::new( + "fixtures/linter/ts_path_alias", + Some(Options { + ts_config_path: Some("./deep/tsconfig.json".to_string()), + ..Default::default() + }), + ) + .test_and_snapshot_single_file("deep/src/dep-a.ts"); + } } diff --git a/crates/oxc_language_server/src/options.rs b/crates/oxc_language_server/src/options.rs index a2ac6f66262c7..d2bde02f65f40 100644 --- a/crates/oxc_language_server/src/options.rs +++ b/crates/oxc_language_server/src/options.rs @@ -27,6 +27,7 @@ pub enum UnusedDisableDirectives { pub struct Options { pub run: Run, pub config_path: Option, + pub ts_config_path: Option, pub unused_disable_directives: UnusedDisableDirectives, pub flags: FxHashMap, } @@ -99,6 +100,9 @@ impl TryFrom for Options { config_path: object .get("configPath") .and_then(|config_path| serde_json::from_value::(config_path.clone()).ok()), + ts_config_path: object + .get("tsConfigPath") + .and_then(|config_path| serde_json::from_value::(config_path.clone()).ok()), flags, }) } diff --git a/crates/oxc_language_server/src/snapshots/fixtures_linter_ts_path_alias@deep_src_dep-a.ts.snap b/crates/oxc_language_server/src/snapshots/fixtures_linter_ts_path_alias@deep_src_dep-a.ts.snap new file mode 100644 index 0000000000000..466fc3f3abb3b --- /dev/null +++ b/crates/oxc_language_server/src/snapshots/fixtures_linter_ts_path_alias@deep_src_dep-a.ts.snap @@ -0,0 +1,15 @@ +--- +source: crates/oxc_language_server/src/tester.rs +input_file: crates/oxc_language_server/fixtures/linter/ts_path_alias/deep/src/dep-a.ts +--- +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 - fixtures/linter/ts_path_alias/deep/src/dep-b.ts\n-> ./dep-a.ts - fixtures/linter/ts_path_alias/deep/src/dep-a.ts" +range: Range { start: Position { line: 1, character: 18 }, end: Position { line: 1, character: 27 } } +related_information[0].message: "" +related_information[0].location.uri: "file:///fixtures/linter/ts_path_alias/deep/src/dep-a.ts" +related_information[0].location.range: Range { start: Position { line: 1, character: 18 }, end: Position { line: 1, character: 27 } } +severity: Some(Error) +source: Some("oxc") +tags: None +fixed: None diff --git a/crates/oxc_language_server/src/worker.rs b/crates/oxc_language_server/src/worker.rs index 9d7ed7530bd5a..b9383f2d41319 100644 --- a/crates/oxc_language_server/src/worker.rs +++ b/crates/oxc_language_server/src/worker.rs @@ -125,6 +125,7 @@ impl WorkspaceWorker { fn needs_linter_restart(old_options: &Options, new_options: &Options) -> bool { old_options.config_path != new_options.config_path + || old_options.ts_config_path != new_options.ts_config_path || old_options.use_nested_configs() != new_options.use_nested_configs() || old_options.fix_kind() != new_options.fix_kind() || old_options.unused_disable_directives != new_options.unused_disable_directives diff --git a/editors/vscode/README.md b/editors/vscode/README.md index abd3e2b468d8b..feee12d07704a 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -38,12 +38,13 @@ Following configuration are supported via `settings.json` and effect the window Following configuration are supported via `settings.json` and can be changed for each workspace: -| Key | Default Value | Possible Values | Description | -| ----------------------------- | ------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `oxc.lint.run` | `onType` | `onSave` \| `onType` | Run the linter on save (onSave) or on type (onType) | -| `oxc.configPath` | `null` | `null` \| `` | Path to ESlint configuration. Keep it empty to enable nested configuration. | -| `oxc.unusedDisableDirectives` | `allow` | `allow` \| `warn` \| `deny` | Define how directive comments like `// oxlint-disable-line` should be reported, when no errors would have been reported on that line anyway. | -| `oxc.flags` | - | `Record` | Custom flags passed to the language server. | +| Key | Default Value | Possible Values | Description | +| ----------------------------- | ------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| `oxc.lint.run` | `onType` | `onSave` \| `onType` | Run the linter on save (onSave) or on type (onType) | +| `oxc.configPath` | `null` | `null` \| `` | Path to ESlint configuration. Keep it empty to enable nested configuration. | +| `oxc.tsConfigPath` | `null` | `null` \| `` | Path to TypeScript configuration. If your `tsconfig.json` is not at the root, alias paths will not be resolve correctly for the `import` plugin. | +| `oxc.unusedDisableDirectives` | `allow` | `allow` \| `warn` \| `deny` | Define how directive comments like `// oxlint-disable-line` should be reported, when no errors would have been reported on that line anyway. | +| `oxc.flags` | - | `Record` | Custom flags passed to the language server. | #### Flags diff --git a/editors/vscode/client/WorkspaceConfig.ts b/editors/vscode/client/WorkspaceConfig.ts index 8e6a8fba3598e..e86c8f247c6ff 100644 --- a/editors/vscode/client/WorkspaceConfig.ts +++ b/editors/vscode/client/WorkspaceConfig.ts @@ -19,6 +19,14 @@ export interface WorkspaceConfigInterface { * @default null */ configPath: string | null; + /** + * typescript config path + * + * `oxc.tsConfigPath` + * + * @default null + */ + tsConfigPath: string | null; /** * When to run the linter and generate diagnostics * `oxc.lint.run` @@ -47,6 +55,7 @@ export interface WorkspaceConfigInterface { export class WorkspaceConfig { private _configPath: string | null = null; + private _tsConfigPath: string | null = null; private _runTrigger: Trigger = 'onType'; private _unusedDisableDirectives: UnusedDisableDirectives = 'allow'; private _flags: Record = {}; @@ -66,6 +75,7 @@ export class WorkspaceConfig { this._runTrigger = this.configuration.get('lint.run') || 'onType'; this._configPath = this.configuration.get('configPath') || (useNestedConfigs ? null : oxlintConfigFileName); + this._tsConfigPath = this.configuration.get('tsConfigPath') ?? null; this._unusedDisableDirectives = this.configuration.get('unusedDisableDirectives') ?? 'allow'; this._flags = flags; @@ -75,6 +85,9 @@ export class WorkspaceConfig { if (event.affectsConfiguration(`${ConfigService.namespace}.configPath`, this.workspace)) { return true; } + if (event.affectsConfiguration(`${ConfigService.namespace}.tsConfigPath`, this.workspace)) { + return true; + } if (event.affectsConfiguration(`${ConfigService.namespace}.lint.run`, this.workspace)) { return true; } @@ -104,11 +117,20 @@ export class WorkspaceConfig { return this._configPath; } - updateConfigPath(value: string): PromiseLike { + updateConfigPath(value: string | null): PromiseLike { this._configPath = value; return this.configuration.update('configPath', value, ConfigurationTarget.WorkspaceFolder); } + get tsConfigPath(): string | null { + return this._tsConfigPath; + } + + updateTsConfigPath(value: string | null): PromiseLike { + this._tsConfigPath = value; + return this.configuration.update('tsConfigPath', value, ConfigurationTarget.WorkspaceFolder); + } + get unusedDisableDirectives(): UnusedDisableDirectives { return this._unusedDisableDirectives; } @@ -131,6 +153,7 @@ export class WorkspaceConfig { return { run: this.runTrigger, configPath: this.configPath ?? null, + tsConfigPath: this.tsConfigPath ?? null, unusedDisableDirectives: this.unusedDisableDirectives, flags: this.flags, }; diff --git a/editors/vscode/fixtures/changing_tsconfig_path/.oxlintrc.json b/editors/vscode/fixtures/changing_tsconfig_path/.oxlintrc.json new file mode 100644 index 0000000000000..e64d00207feee --- /dev/null +++ b/editors/vscode/fixtures/changing_tsconfig_path/.oxlintrc.json @@ -0,0 +1,6 @@ +{ + "plugins": ["import"], + "rules": { + "import/no-cycle": "error" + } +} diff --git a/editors/vscode/fixtures/changing_tsconfig_path/deep/src/dep-a.ts b/editors/vscode/fixtures/changing_tsconfig_path/deep/src/dep-a.ts new file mode 100644 index 0000000000000..db553a2aa40b3 --- /dev/null +++ b/editors/vscode/fixtures/changing_tsconfig_path/deep/src/dep-a.ts @@ -0,0 +1,4 @@ +// should report cycle detected +import { b } from '@/dep-b'; + +b(); diff --git a/editors/vscode/fixtures/changing_tsconfig_path/deep/src/dep-b.ts b/editors/vscode/fixtures/changing_tsconfig_path/deep/src/dep-b.ts new file mode 100644 index 0000000000000..137d96df50b3f --- /dev/null +++ b/editors/vscode/fixtures/changing_tsconfig_path/deep/src/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/changing_tsconfig_path/deep/tsconfig.json b/editors/vscode/fixtures/changing_tsconfig_path/deep/tsconfig.json new file mode 100644 index 0000000000000..70219df8c4a79 --- /dev/null +++ b/editors/vscode/fixtures/changing_tsconfig_path/deep/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": true, + "emitDeclarationOnly": true, + "declaration": true, + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/editors/vscode/package.json b/editors/vscode/package.json index aa837ec7c2953..a2b9a6d981d0d 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -114,7 +114,16 @@ ], "scope": "resource", "default": null, - "description": "Path to ESlint configuration. Keep it empty to enable nested configuration." + "description": "Path to oxlint configuration. Keep it empty to enable nested configuration." + }, + "oxc.tsConfigPath": { + "type": [ + "string", + "null" + ], + "scope": "resource", + "default": null, + "description": "Path to TypeScript configuration. If your `tsconfig.json` is not at the root, alias paths will not be resolve correctly for the `import` plugin." }, "oxc.unusedDisableDirectives": { "type": "string", diff --git a/editors/vscode/tests/WorkspaceConfig.spec.ts b/editors/vscode/tests/WorkspaceConfig.spec.ts index 919734efeaf05..1c5b2eafa70cb 100644 --- a/editors/vscode/tests/WorkspaceConfig.spec.ts +++ b/editors/vscode/tests/WorkspaceConfig.spec.ts @@ -7,7 +7,7 @@ suite('WorkspaceConfig', () => { setup(async () => { const workspaceConfig = workspace.getConfiguration('oxc', WORKSPACE_FOLDER); const globalConfig = workspace.getConfiguration('oxc'); - const keys = ['lint.run', 'configPath', 'flags', 'unusedDisableDirectives']; + const keys = ['lint.run', 'configPath', 'tsConfigPath', 'flags', 'unusedDisableDirectives']; await Promise.all(keys.map(key => workspaceConfig.update(key, undefined, ConfigurationTarget.WorkspaceFolder))); // VSCode will not save different workspace configuration inside a `.code-workspace` file. @@ -18,7 +18,7 @@ suite('WorkspaceConfig', () => { teardown(async () => { const workspaceConfig = workspace.getConfiguration('oxc', WORKSPACE_FOLDER); const globalConfig = workspace.getConfiguration('oxc'); - const keys = ['lint.run', 'configPath', 'flags', 'unusedDisableDirectives']; + const keys = ['lint.run', 'configPath', 'tsConfigPath', 'flags', 'unusedDisableDirectives']; await Promise.all(keys.map(key => workspaceConfig.update(key, undefined, ConfigurationTarget.WorkspaceFolder))); // VSCode will not save different workspace configuration inside a `.code-workspace` file. @@ -30,6 +30,7 @@ suite('WorkspaceConfig', () => { const config = new WorkspaceConfig(WORKSPACE_FOLDER); strictEqual(config.runTrigger, 'onType'); strictEqual(config.configPath, null); + strictEqual(config.tsConfigPath, null); strictEqual(config.unusedDisableDirectives, 'allow'); deepStrictEqual(config.flags, {}); }); @@ -62,6 +63,7 @@ suite('WorkspaceConfig', () => { await Promise.all([ config.updateRunTrigger('onSave'), config.updateConfigPath('./somewhere'), + config.updateTsConfigPath('./tsconfig.json'), config.updateUnusedDisableDirectives('deny'), config.updateFlags({ test: 'value' }), ]); @@ -70,6 +72,7 @@ suite('WorkspaceConfig', () => { strictEqual(wsConfig.get('lint.run'), 'onSave'); strictEqual(wsConfig.get('configPath'), './somewhere'); + strictEqual(wsConfig.get('tsConfigPath'), './tsconfig.json'); strictEqual(wsConfig.get('unusedDisableDirectives'), 'deny'); deepStrictEqual(wsConfig.get('flags'), { test: 'value' }); }); diff --git a/editors/vscode/tests/e2e_server.spec.ts b/editors/vscode/tests/e2e_server.spec.ts index 123fe20605aa2..517476989e3c8 100644 --- a/editors/vscode/tests/e2e_server.spec.ts +++ b/editors/vscode/tests/e2e_server.spec.ts @@ -33,6 +33,7 @@ suiteSetup(async () => { teardown(async () => { await workspace.getConfiguration('oxc').update('flags', undefined); + await workspace.getConfiguration('oxc').update('tsConfigPath', undefined); await workspace.saveAll(); }); @@ -219,6 +220,24 @@ suite('E2E Diagnostics', () => { strictEqual(secondDiagnostics[0].severity, DiagnosticSeverity.Error); }); + testSingleFolderMode('changing oxc.tsConfigPath will revalidate the diagnostics', async () => { + await loadFixture('changing_tsconfig_path'); + const firstDiagnostics = await getDiagnostics('deep/src/dep-a.ts'); + + strictEqual(firstDiagnostics.length, 0); + + await workspace.getConfiguration('oxc').update('tsConfigPath', "fixtures/deep/tsconfig.json"); + await workspace.saveAll(); + await waitForDiagnosticChange(); + + const secondDiagnostics = await getDiagnostics('deep/src/dep-a.ts'); + strictEqual(secondDiagnostics.length, 1); + assert(typeof secondDiagnostics[0].code == 'object'); + strictEqual(secondDiagnostics[0].code.target.authority, 'oxc.rs'); + assert(secondDiagnostics[0].message.startsWith("Dependency cycle detected")); + strictEqual(secondDiagnostics[0].severity, DiagnosticSeverity.Error); + }); + test('cross module', async () => { await loadFixture('cross_module'); const diagnostics = await getDiagnostics('dep-a.ts');