Skip to content

Commit fd1fc22

Browse files
committed
feat(language_server): support workpace/diagnostic
1 parent d0fbfb4 commit fd1fc22

File tree

10 files changed

+215
-51
lines changed

10 files changed

+215
-51
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"rules": {
3+
"no-console": "error"
4+
}
5+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
console.log("Hello, world!");
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// Debugger should be shown as a warning
2+
debugger;

crates/oxc_language_server/src/capabilities.rs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
use tower_lsp_server::lsp_types::{
22
ClientCapabilities, CodeActionKind, CodeActionOptions, CodeActionProviderCapability,
3-
ExecuteCommandOptions, OneOf, SaveOptions, ServerCapabilities, TextDocumentSyncCapability,
4-
TextDocumentSyncKind, TextDocumentSyncOptions, TextDocumentSyncSaveOptions,
5-
WorkDoneProgressOptions, WorkspaceFoldersServerCapabilities, WorkspaceServerCapabilities,
3+
DiagnosticOptions, DiagnosticServerCapabilities, ExecuteCommandOptions, OneOf, SaveOptions,
4+
ServerCapabilities, TextDocumentSyncCapability, TextDocumentSyncKind, TextDocumentSyncOptions,
5+
TextDocumentSyncSaveOptions, WorkDoneProgressOptions, WorkspaceFoldersServerCapabilities,
6+
WorkspaceServerCapabilities,
67
};
78

89
use crate::{code_actions::CODE_ACTION_KIND_SOURCE_FIX_ALL_OXC, commands::FIX_ALL_COMMAND_ID};
@@ -99,6 +100,10 @@ impl From<Capabilities> for ServerCapabilities {
99100
} else {
100101
None
101102
},
103+
diagnostic_provider: Some(DiagnosticServerCapabilities::Options(DiagnosticOptions {
104+
workspace_diagnostics: true,
105+
..Default::default()
106+
})),
102107
..ServerCapabilities::default()
103108
}
104109
}

crates/oxc_language_server/src/linter/isolated_lint_handler.rs

Lines changed: 86 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,25 @@
11
use std::{
2+
ffi::OsString,
23
path::{Path, PathBuf},
34
sync::{Arc, OnceLock},
45
};
56

67
use log::debug;
7-
use rustc_hash::FxHashSet;
8+
use rustc_hash::{FxHashMap, FxHashSet};
89
use tower_lsp_server::{
910
UriExt,
1011
lsp_types::{self, DiagnosticRelatedInformation, DiagnosticSeverity, Uri},
1112
};
1213

1314
use oxc_allocator::Allocator;
14-
use oxc_linter::RuntimeFileSystem;
15+
use oxc_linter::{FileWalker, FileWalkerOptions, RuntimeFileSystem};
1516
use oxc_linter::{
1617
LINTABLE_EXTENSIONS, LintService, LintServiceOptions, Linter, MessageWithPosition,
1718
loader::Loader, read_to_string,
1819
};
1920

21+
use crate::uri_ext::path_to_uri;
22+
2023
use super::error_with_position::{
2124
DiagnosticReport, PossibleFixContent, message_with_position_to_lsp_diagnostic_report,
2225
};
@@ -73,49 +76,71 @@ impl IsolatedLintHandler {
7376
let allocator = Allocator::default();
7477

7578
Some(self.lint_path(&allocator, &path, content).map_or(vec![], |errors| {
76-
let mut diagnostics: Vec<DiagnosticReport> = errors
77-
.iter()
78-
.map(|e| message_with_position_to_lsp_diagnostic_report(e, uri))
79-
.collect();
80-
81-
// a diagnostics connected from related_info to original diagnostic
82-
let mut inverted_diagnostics = vec![];
83-
for d in &diagnostics {
84-
let Some(related_info) = &d.diagnostic.related_information else {
79+
Self::messages_with_position_to_diagnostics_report(&errors, uri)
80+
}))
81+
}
82+
83+
pub fn run_all(&self) -> Vec<(Uri, Vec<DiagnosticReport>)> {
84+
let allocator = Allocator::default();
85+
let mut diagnostics = Vec::new();
86+
87+
for (path, messages) in self.lint_all_files(&allocator) {
88+
let uri = path_to_uri(&path);
89+
diagnostics.push((
90+
uri.clone(),
91+
Self::messages_with_position_to_diagnostics_report(&messages, &uri),
92+
));
93+
}
94+
95+
diagnostics
96+
}
97+
98+
fn messages_with_position_to_diagnostics_report(
99+
messages: &[MessageWithPosition<'_>],
100+
uri: &Uri,
101+
) -> Vec<DiagnosticReport> {
102+
let mut diagnostics: Vec<DiagnosticReport> = messages
103+
.iter()
104+
.map(|e| message_with_position_to_lsp_diagnostic_report(e, uri))
105+
.collect();
106+
107+
// a diagnostics connected from related_info to original diagnostic
108+
let mut inverted_diagnostics = vec![];
109+
for d in &diagnostics {
110+
let Some(related_info) = &d.diagnostic.related_information else {
111+
continue;
112+
};
113+
let related_information = Some(vec![DiagnosticRelatedInformation {
114+
location: lsp_types::Location { uri: uri.clone(), range: d.diagnostic.range },
115+
message: "original diagnostic".to_string(),
116+
}]);
117+
for r in related_info {
118+
if r.location.range == d.diagnostic.range {
85119
continue;
86-
};
87-
let related_information = Some(vec![DiagnosticRelatedInformation {
88-
location: lsp_types::Location { uri: uri.clone(), range: d.diagnostic.range },
89-
message: "original diagnostic".to_string(),
90-
}]);
91-
for r in related_info {
92-
if r.location.range == d.diagnostic.range {
93-
continue;
94-
}
95-
// If there is no message content for this span, then don't produce an additional diagnostic
96-
// which also has no content. This prevents issues where editors expect diagnostics to have messages.
97-
if r.message.is_empty() {
98-
continue;
99-
}
100-
inverted_diagnostics.push(DiagnosticReport {
101-
diagnostic: lsp_types::Diagnostic {
102-
range: r.location.range,
103-
severity: Some(DiagnosticSeverity::HINT),
104-
code: None,
105-
message: r.message.clone(),
106-
source: d.diagnostic.source.clone(),
107-
code_description: None,
108-
related_information: related_information.clone(),
109-
tags: None,
110-
data: None,
111-
},
112-
fixed_content: PossibleFixContent::None,
113-
});
114120
}
121+
// If there is no message content for this span, then don't produce an additional diagnostic
122+
// which also has no content. This prevents issues where editors expect diagnostics to have messages.
123+
if r.message.is_empty() {
124+
continue;
125+
}
126+
inverted_diagnostics.push(DiagnosticReport {
127+
diagnostic: lsp_types::Diagnostic {
128+
range: r.location.range,
129+
severity: Some(DiagnosticSeverity::HINT),
130+
code: None,
131+
message: r.message.clone(),
132+
source: d.diagnostic.source.clone(),
133+
code_description: None,
134+
related_information: related_information.clone(),
135+
tags: None,
136+
data: None,
137+
},
138+
fixed_content: PossibleFixContent::None,
139+
});
115140
}
116-
diagnostics.append(&mut inverted_diagnostics);
117-
diagnostics
118-
}))
141+
}
142+
diagnostics.append(&mut inverted_diagnostics);
143+
diagnostics
119144
}
120145

121146
fn lint_path<'a>(
@@ -146,6 +171,25 @@ impl IsolatedLintHandler {
146171
result.remove(path)
147172
}
148173

174+
fn lint_all_files<'a>(
175+
&self,
176+
allocator: &'a Allocator,
177+
) -> FxHashMap<PathBuf, Vec<MessageWithPosition<'a>>> {
178+
let walker = FileWalker::new(
179+
&[self.options.root_path.clone()],
180+
&FileWalkerOptions { no_ignore: false, symlinks: false, ignore_path: OsString::new() },
181+
None,
182+
);
183+
184+
let lint_service_options =
185+
LintServiceOptions::new(self.options.root_path.clone(), walker.paths())
186+
.with_cross_module(self.options.use_cross_module);
187+
188+
let mut lint_service = LintService::new(&self.linter, lint_service_options);
189+
190+
lint_service.run_source(allocator)
191+
}
192+
149193
fn should_lint_path(path: &Path) -> bool {
150194
static WANTED_EXTENSIONS: OnceLock<FxHashSet<&'static str>> = OnceLock::new();
151195
let wanted_exts =

crates/oxc_language_server/src/linter/server_linter.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,10 @@ impl ServerLinter {
204204

205205
self.isolated_linter.run_single(uri, content)
206206
}
207+
208+
pub fn run_all(&self) -> Vec<(Uri, Vec<DiagnosticReport>)> {
209+
self.isolated_linter.run_all()
210+
}
207211
}
208212

209213
/// Normalize a path by removing `.` and resolving `..` components,
@@ -365,4 +369,9 @@ mod test {
365369
)
366370
.test_and_snapshot_single_file("forward_ref.ts");
367371
}
372+
373+
#[test]
374+
fn test_multi_file_analyse() {
375+
Tester::new("fixtures/linter/multi_file_analysis", None).test_and_snapshot();
376+
}
368377
}

crates/oxc_language_server/src/main.rs

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ use tower_lsp_server::{
1616
DocumentDiagnosticParams, DocumentDiagnosticReport, DocumentDiagnosticReportResult,
1717
ExecuteCommandParams, FullDocumentDiagnosticReport, InitializeParams, InitializeResult,
1818
InitializedParams, Registration, RelatedFullDocumentDiagnosticReport, ServerInfo,
19-
Unregistration, Uri, WorkspaceEdit,
19+
Unregistration, Uri, WorkspaceDiagnosticParams, WorkspaceDiagnosticReport,
20+
WorkspaceDiagnosticReportResult, WorkspaceDocumentDiagnosticReport, WorkspaceEdit,
21+
WorkspaceFullDocumentDiagnosticReport,
2022
},
2123
};
2224
// #
@@ -32,7 +34,6 @@ mod linter;
3234
mod options;
3335
#[cfg(test)]
3436
mod tester;
35-
#[cfg(test)]
3637
mod uri_ext;
3738
mod worker;
3839

@@ -630,6 +631,30 @@ impl LanguageServer for Backend {
630631

631632
Err(Error::invalid_request())
632633
}
634+
635+
async fn workspace_diagnostic(
636+
&self,
637+
_params: WorkspaceDiagnosticParams,
638+
) -> Result<WorkspaceDiagnosticReportResult> {
639+
let workers = self.workspace_workers.read().await;
640+
let mut items = Vec::new();
641+
for worker in workers.iter() {
642+
for (uri, reports) in worker.lint_workspace().await {
643+
items.push(WorkspaceDocumentDiagnosticReport::Full(
644+
WorkspaceFullDocumentDiagnosticReport {
645+
uri: uri.clone(),
646+
version: None,
647+
full_document_diagnostic_report: FullDocumentDiagnosticReport {
648+
result_id: None,
649+
items: reports.into_iter().map(|d| d.diagnostic).collect(),
650+
},
651+
},
652+
));
653+
}
654+
}
655+
656+
Ok(WorkspaceDiagnosticReportResult::Report(WorkspaceDiagnosticReport { items }))
657+
}
633658
}
634659

635660
impl Backend {
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
source: crates/oxc_language_server/src/tester.rs
3+
---
4+
/home/sysix/dev/oxc/crates/oxc_language_server/fixtures/linter/multi_file_analysis/debugger.ts:
5+
6+
code: "eslint(no-debugger)"
7+
code_description.href: "https://oxc.rs/docs/guide/usage/linter/rules/eslint/no-debugger.html"
8+
message: "`debugger` statement is not allowed\nhelp: Remove the debugger statement"
9+
range: Range { start: Position { line: 1, character: 0 }, end: Position { line: 1, character: 9 } }
10+
related_information[0].message: ""
11+
related_information[0].location.uri: "file://<variable>/fixtures/linter/multi_file_analysis/debugger.ts"
12+
related_information[0].location.range: Range { start: Position { line: 1, character: 0 }, end: Position { line: 1, character: 9 } }
13+
severity: Some(Warning)
14+
source: Some("oxc")
15+
tags: None
16+
fixed: Single(FixedContent { message: Some("Remove the debugger statement"), code: "", range: Range { start: Position { line: 1, character: 0 }, end: Position { line: 1, character: 9 } } })
17+
18+
/home/sysix/dev/oxc/crates/oxc_language_server/fixtures/linter/multi_file_analysis/console.js:
19+
20+
code: "eslint(no-console)"
21+
code_description.href: "https://oxc.rs/docs/guide/usage/linter/rules/eslint/no-console.html"
22+
message: "Unexpected console statement.\nhelp: Delete this console statement."
23+
range: Range { start: Position { line: 0, character: 0 }, end: Position { line: 0, character: 11 } }
24+
related_information[0].message: ""
25+
related_information[0].location.uri: "file://<variable>/fixtures/linter/multi_file_analysis/console.js"
26+
related_information[0].location.range: Range { start: Position { line: 0, character: 0 }, end: Position { line: 0, character: 11 } }
27+
severity: Some(Error)
28+
source: Some("oxc")
29+
tags: None
30+
fixed: None

crates/oxc_language_server/src/tester.rs

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use std::fmt::Write;
22

3+
use cow_utils::CowUtils;
34
use tower_lsp_server::{
45
UriExt,
56
lsp_types::{CodeDescription, NumberOrString, Uri},
@@ -109,7 +110,6 @@ impl Tester<'_> {
109110

110111
/// Given a relative file path (relative to `oxc_language_server` crate root), run the linter
111112
/// and return the resulting diagnostics in a custom snapshot format.
112-
#[expect(clippy::disallowed_methods)]
113113
pub fn test_and_snapshot_single_file(&self, relative_file_path: &str) {
114114
let uri = get_file_uri(&format!("{}/{}", self.relative_root_dir, relative_file_path));
115115
let reports = tokio::runtime::Runtime::new().unwrap().block_on(async {
@@ -125,16 +125,49 @@ impl Tester<'_> {
125125
reports.iter().map(get_snapshot_from_report).collect::<Vec<_>>().join("\n")
126126
};
127127

128-
let snapshot_name = self.relative_root_dir.replace('/', "_");
128+
let snapshot_name = self.relative_root_dir.cow_replace('/', "_");
129129
let mut settings = insta::Settings::clone_current();
130130
settings.set_prepend_module_to_snapshot(false);
131131
settings.set_omit_expression(true);
132132
if let Some(path) = uri.to_file_path() {
133133
settings.set_input_file(path.as_ref());
134134
}
135-
settings.set_snapshot_suffix(relative_file_path.replace('/', "_"));
135+
settings.set_snapshot_suffix(relative_file_path.cow_replace('/', "_"));
136136
settings.bind(|| {
137-
insta::assert_snapshot!(snapshot_name, snapshot);
137+
insta::assert_snapshot!(snapshot_name.to_string(), snapshot);
138+
});
139+
}
140+
141+
pub fn test_and_snapshot(&self) {
142+
let reports = tokio::runtime::Runtime::new()
143+
.unwrap()
144+
.block_on(async { self.create_workspace_worker().await.lint_workspace().await });
145+
let snapshot = if reports.is_empty() {
146+
"No diagnostic reports".to_string()
147+
} else {
148+
reports
149+
.iter()
150+
.map(|(uri, messages)| {
151+
format!(
152+
"{}:\n{}",
153+
uri.to_file_path().unwrap_or_default().display(),
154+
messages
155+
.iter()
156+
.map(get_snapshot_from_report)
157+
.collect::<Vec<_>>()
158+
.join("\n")
159+
)
160+
})
161+
.collect::<Vec<_>>()
162+
.join("\n")
163+
};
164+
let snapshot_name = self.relative_root_dir.cow_replace('/', "_");
165+
let mut settings = insta::Settings::clone_current();
166+
settings.set_prepend_module_to_snapshot(false);
167+
settings.set_omit_expression(true);
168+
settings.set_snapshot_suffix("all");
169+
settings.bind(|| {
170+
insta::assert_snapshot!(snapshot_name.to_string(), snapshot);
138171
});
139172
}
140173
}

crates/oxc_language_server/src/worker.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,16 @@ impl WorkspaceWorker {
155155
diagnostics
156156
}
157157

158+
pub async fn lint_workspace(&self) -> Vec<(Uri, Vec<DiagnosticReport>)> {
159+
let server_linter = self.server_linter.read().await;
160+
let Some(server_linter) = &*server_linter else {
161+
debug!("no server_linter initialized in the worker");
162+
return vec![];
163+
};
164+
165+
server_linter.run_all()
166+
}
167+
158168
async fn lint_file_internal(&self, uri: &Uri) -> Option<Vec<DiagnosticReport>> {
159169
let Some(server_linter) = &*self.server_linter.read().await else {
160170
return None;

0 commit comments

Comments
 (0)