Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/oxc_language_server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ These options can be passed with [initialize](#initialize), [workspace/didChange
| `configPath` | `<string>` \| `null` | `null` | Path to a oxlint configuration file, passing a string will disable nested configuration |
| `tsConfigPath` | `<string>` \| `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 |
| `typeAware` | `true` \| `false` | `false` | Enables type-aware linting |
| `flags` | `Map<string, string>` | `<empty>` | Special oxc language server flags, currently only one flag key is supported: `disable_nested_config` |

## Supported LSP Specifications from Server
Expand All @@ -43,6 +44,7 @@ The client can pass the workspace options like following:
"configPath": null,
"tsConfigPath": null,
"unusedDisableDirectives": "allow",
"typeAware": false,
"flags": {}
}
}]
Expand Down Expand Up @@ -78,6 +80,7 @@ The client can pass the workspace options like following:
"configPath": null,
"tsConfigPath": null,
"unusedDisableDirectives": "allow",
"typeAware": false,
"flags": {}
}
}]
Expand Down Expand Up @@ -166,6 +169,7 @@ The client can return a response like:
"configPath": null,
"tsConfigPath": null,
"unusedDisableDirectives": "allow",
"typeAware": false,
"flags": {}
}]
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
const promise = new Promise((resolve, _reject) => resolve("value"));
promise;

async function returnsPromise() {
return "value";
}

returnsPromise().then(() => {});

Promise.reject("value").catch();

Promise.reject("value").finally();

[1, 2, 3].map(async (x) => x + 1);
1 change: 1 addition & 0 deletions crates/oxc_language_server/src/linter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ pub mod config_walker;
pub mod error_with_position;
pub mod isolated_lint_handler;
pub mod server_linter;
pub mod tsgo_linter;
32 changes: 30 additions & 2 deletions crates/oxc_language_server/src/linter/server_linter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use tower_lsp_server::UriExt;
use crate::linter::{
error_with_position::DiagnosticReport,
isolated_lint_handler::{IsolatedLintHandler, IsolatedLintHandlerOptions},
tsgo_linter::TsgoLinter,
};
use crate::options::UnusedDisableDirectives;
use crate::{ConcurrentHashMap, OXC_CONFIG_FILE, Options};
Expand All @@ -24,6 +25,7 @@ use super::config_walker::ConfigWalker;

pub struct ServerLinter {
isolated_linter: Arc<Mutex<IsolatedLintHandler>>,
tsgo_linter: Arc<Option<TsgoLinter>>,
gitignore_glob: Vec<Gitignore>,
pub extended_paths: Vec<PathBuf>,
}
Expand Down Expand Up @@ -98,7 +100,7 @@ impl ServerLinter {

let isolated_linter = IsolatedLintHandler::new(
lint_options,
config_store,
config_store.clone(), // clone because tsgo linter needs it
&IsolatedLintHandlerOptions {
use_cross_module,
root_path: root_path.to_path_buf(),
Expand All @@ -113,6 +115,11 @@ impl ServerLinter {
isolated_linter: Arc::new(Mutex::new(isolated_linter)),
gitignore_glob: Self::create_ignore_glob(&root_path),
extended_paths,
tsgo_linter: if options.type_aware {
Arc::new(Some(TsgoLinter::new(&root_path, config_store)))
} else {
Arc::new(None)
},
}
}

Expand Down Expand Up @@ -221,7 +228,19 @@ impl ServerLinter {
return None;
}

self.isolated_linter.lock().await.run_single(uri, content)
// when `IsolatedLintHandler` returns `None`, it means it does not want to lint.
// Do not try `tsgolint` because it could be ignored or is not supported.
let mut reports = self.isolated_linter.lock().await.run_single(uri, content.clone())?;

let Some(tsgo_linter) = &*self.tsgo_linter else {
return Some(reports);
};

if let Some(tsgo_reports) = tsgo_linter.lint_file(uri, content) {
reports.extend(tsgo_reports);
}

Some(reports)
}
}

Expand Down Expand Up @@ -411,4 +430,13 @@ mod test {
)
.test_and_snapshot_single_file("deep/src/dep-a.ts");
}

#[test]
fn test_tsgo_lint() {
let tester = Tester::new(
"fixtures/linter/tsgolint",
Some(Options { type_aware: true, ..Default::default() }),
);
tester.test_and_snapshot_single_file("no-floating-promises/index.ts");
}
}
60 changes: 60 additions & 0 deletions crates/oxc_language_server/src/linter/tsgo_linter.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
use std::{
path::Path,
sync::{Arc, OnceLock},
};

use oxc_linter::{
ConfigStore, LINTABLE_EXTENSIONS, TsGoLintState, loader::LINT_PARTIAL_LOADER_EXTENSIONS,
read_to_string,
};
use rustc_hash::FxHashSet;
use tower_lsp_server::{UriExt, lsp_types::Uri};

use crate::linter::error_with_position::{
DiagnosticReport, message_with_position_to_lsp_diagnostic_report,
};

pub struct TsgoLinter {
state: TsGoLintState,
}

impl TsgoLinter {
pub fn new(root_uri: &Path, config_store: ConfigStore) -> Self {
let state = TsGoLintState::new(root_uri, config_store);
Self { state }
}

pub fn lint_file(&self, uri: &Uri, content: Option<String>) -> Option<Vec<DiagnosticReport>> {
let path = uri.to_file_path()?;

if !Self::should_lint_path(&path) {
return None;
}

let source_text = content.or_else(|| read_to_string(&path).ok())?;

let messages = self.state.lint_source(&Arc::from(path.as_os_str()), source_text).ok()?;

Some(
messages
.iter()
.map(|e| message_with_position_to_lsp_diagnostic_report(e, uri))
.collect(),
)
}

fn should_lint_path(path: &Path) -> bool {
static WANTED_EXTENSIONS: OnceLock<FxHashSet<&'static str>> = OnceLock::new();
let wanted_exts = WANTED_EXTENSIONS.get_or_init(|| {
LINTABLE_EXTENSIONS
.iter()
.filter(|ext| !LINT_PARTIAL_LOADER_EXTENSIONS.contains(ext))
.copied()
.collect()
});

path.extension()
.and_then(std::ffi::OsStr::to_str)
.is_some_and(|ext| wanted_exts.contains(ext))
}
}
7 changes: 7 additions & 0 deletions crates/oxc_language_server/src/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ pub struct Options {
pub config_path: Option<String>,
pub ts_config_path: Option<String>,
pub unused_disable_directives: UnusedDisableDirectives,
pub type_aware: bool,
pub flags: FxHashMap<String, String>,
}

Expand Down Expand Up @@ -103,6 +104,9 @@ impl TryFrom<Value> for Options {
ts_config_path: object
.get("tsConfigPath")
.and_then(|config_path| serde_json::from_value::<String>(config_path.clone()).ok()),
type_aware: object
.get("typeAware")
.is_some_and(|key| serde_json::from_value::<bool>(key.clone()).unwrap_or_default()),
flags,
})
}
Expand All @@ -128,6 +132,7 @@ mod test {
"run": "onSave",
"configPath": "./custom.json",
"unusedDisableDirectives": "warn",
"typeAware": true,
"flags": {
"disable_nested_config": "true",
"fix_kind": "dangerous_fix"
Expand All @@ -138,6 +143,7 @@ mod test {
assert_eq!(options.run, Run::OnSave);
assert_eq!(options.config_path, Some("./custom.json".into()));
assert_eq!(options.unused_disable_directives, UnusedDisableDirectives::Warn);
assert!(options.type_aware);
assert_eq!(options.flags.get("disable_nested_config"), Some(&"true".to_string()));
assert_eq!(options.flags.get("fix_kind"), Some(&"dangerous_fix".to_string()));
}
Expand All @@ -150,6 +156,7 @@ mod test {
assert_eq!(options.run, Run::OnType);
assert_eq!(options.config_path, None);
assert_eq!(options.unused_disable_directives, UnusedDisableDirectives::Allow);
assert!(!options.type_aware);
assert!(options.flags.is_empty());
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
---
source: crates/oxc_language_server/src/tester.rs
input_file: crates/oxc_language_server/fixtures/linter/tsgolint/no-floating-promises/index.ts
---
code: "typescript-eslint(no-confusing-void-expression)"
code_description.href: "None"
message: "Returning a void expression from an arrow function shorthand is forbidden. Please add braces to the arrow function."
range: Range { start: Position { line: 0, character: 50 }, end: Position { line: 0, character: 66 } }
related_information[0].message: ""
related_information[0].location.uri: "file://<variable>/fixtures/linter/tsgolint/no-floating-promises/index.ts"
related_information[0].location.range: Range { start: Position { line: 0, character: 50 }, end: Position { line: 0, character: 66 } }
severity: Some(Warning)
source: Some("oxc")
tags: None
fixed: Single(FixedContent { message: None, code: "{ resolve(\"value\"); }", range: Range { start: Position { line: 0, character: 49 }, end: Position { line: 0, character: 66 } } })


code: "typescript-eslint(no-floating-promises)"
code_description.href: "None"
message: "Promises must be awaited.\nhelp: The promise must end with a call to .catch, or end with a call to .then with a rejection handler, or be explicitly marked as ignored with the `void` operator."
range: Range { start: Position { line: 1, character: 0 }, end: Position { line: 1, character: 8 } }
related_information[0].message: ""
related_information[0].location.uri: "file://<variable>/fixtures/linter/tsgolint/no-floating-promises/index.ts"
related_information[0].location.range: Range { start: Position { line: 1, character: 0 }, end: Position { line: 1, character: 8 } }
severity: Some(Warning)
source: Some("oxc")
tags: None
fixed: Multiple([FixedContent { message: Some("Promises must be awaited."), code: "void ", range: Range { start: Position { line: 1, character: 0 }, end: Position { line: 1, character: 0 } } }, FixedContent { message: Some("Promises must be awaited."), code: "await ", range: Range { start: Position { line: 1, character: 0 }, end: Position { line: 1, character: 0 } } }])


code: "typescript-eslint(no-floating-promises)"
code_description.href: "None"
message: "Promises must be awaited.\nhelp: The promise must end with a call to .catch, or end with a call to .then with a rejection handler, or be explicitly marked as ignored with the `void` operator."
range: Range { start: Position { line: 7, character: 0 }, end: Position { line: 7, character: 32 } }
related_information[0].message: ""
related_information[0].location.uri: "file://<variable>/fixtures/linter/tsgolint/no-floating-promises/index.ts"
related_information[0].location.range: Range { start: Position { line: 7, character: 0 }, end: Position { line: 7, character: 32 } }
severity: Some(Warning)
source: Some("oxc")
tags: None
fixed: Multiple([FixedContent { message: Some("Promises must be awaited."), code: "void ", range: Range { start: Position { line: 7, character: 0 }, end: Position { line: 7, character: 0 } } }, FixedContent { message: Some("Promises must be awaited."), code: "await ", range: Range { start: Position { line: 7, character: 0 }, end: Position { line: 7, character: 0 } } }])


code: "typescript-eslint(no-floating-promises)"
code_description.href: "None"
message: "Promises must be awaited.\nhelp: The promise must end with a call to .catch, or end with a call to .then with a rejection handler, or be explicitly marked as ignored with the `void` operator."
range: Range { start: Position { line: 9, character: 0 }, end: Position { line: 9, character: 32 } }
related_information[0].message: ""
related_information[0].location.uri: "file://<variable>/fixtures/linter/tsgolint/no-floating-promises/index.ts"
related_information[0].location.range: Range { start: Position { line: 9, character: 0 }, end: Position { line: 9, character: 32 } }
severity: Some(Warning)
source: Some("oxc")
tags: None
fixed: Multiple([FixedContent { message: Some("Promises must be awaited."), code: "void ", range: Range { start: Position { line: 9, character: 0 }, end: Position { line: 9, character: 0 } } }, FixedContent { message: Some("Promises must be awaited."), code: "await ", range: Range { start: Position { line: 9, character: 0 }, end: Position { line: 9, character: 0 } } }])


code: "typescript-eslint(no-floating-promises)"
code_description.href: "None"
message: "Promises must be awaited.\nhelp: The promise must end with a call to .catch, or end with a call to .then with a rejection handler, or be explicitly marked as ignored with the `void` operator."
range: Range { start: Position { line: 11, character: 0 }, end: Position { line: 11, character: 34 } }
related_information[0].message: ""
related_information[0].location.uri: "file://<variable>/fixtures/linter/tsgolint/no-floating-promises/index.ts"
related_information[0].location.range: Range { start: Position { line: 11, character: 0 }, end: Position { line: 11, character: 34 } }
severity: Some(Warning)
source: Some("oxc")
tags: None
fixed: Multiple([FixedContent { message: Some("Promises must be awaited."), code: "void ", range: Range { start: Position { line: 11, character: 0 }, end: Position { line: 11, character: 0 } } }, FixedContent { message: Some("Promises must be awaited."), code: "await ", range: Range { start: Position { line: 11, character: 0 }, end: Position { line: 11, character: 0 } } }])


code: "typescript-eslint(no-floating-promises)"
code_description.href: "None"
message: "An array of Promises may be unintentional.\nhelp: Consider handling the promises' fulfillment or rejection with Promise.all or similar, or explicitly marking the expression as ignored with the `void` operator."
range: Range { start: Position { line: 13, character: 0 }, end: Position { line: 13, character: 34 } }
related_information[0].message: ""
related_information[0].location.uri: "file://<variable>/fixtures/linter/tsgolint/no-floating-promises/index.ts"
related_information[0].location.range: Range { start: Position { line: 13, character: 0 }, end: Position { line: 13, character: 34 } }
severity: Some(Warning)
source: Some("oxc")
tags: None
fixed: None
17 changes: 14 additions & 3 deletions crates/oxc_language_server/src/worker.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use std::{str::FromStr, sync::Arc, vec};

use log::debug;
use log::{debug, warn};
use rustc_hash::FxBuildHasher;
use tokio::sync::{Mutex, RwLock};
use tower_lsp_server::{
Expand Down Expand Up @@ -129,12 +129,23 @@ impl WorkspaceWorker {
|| 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
// TODO: only the TsgoLinter needs to be dropped or created
|| old_options.type_aware != new_options.type_aware
}

pub async fn should_lint_on_run_type(&self, current_run: Run) -> bool {
let run_level = { self.options.lock().await.run };
let options = self.options.lock().await;
// `tsgolint` only supported the os file system. We can not run it on memory file system.
if options.type_aware {
if options.run == Run::OnType {
warn!(
"Linting with type aware is only supported with the OS file system. Change your settings to use onSave."
);
}
return current_run == Run::OnSave;
}

run_level == current_run
options.run == current_run
}

pub async fn lint_file(
Expand Down
Loading
Loading