Skip to content

Commit b7cdaa9

Browse files
committed
feat(language_server): support multiple workspaces
1 parent 24ada6f commit b7cdaa9

12 files changed

Lines changed: 439 additions & 154 deletions

File tree

crates/oxc_language_server/README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ The server will revalidate the diagnostics for all open files and send one or mo
5353

5454
Note: When nested configuration is active, the client should send all `.oxlintrc.json` configurations to the server after the [initialized](#initialized) response.
5555

56+
#### [workspace/didChangeWorkspaceFolders](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_didChangeWorkspaceFolders)
57+
58+
The server expects this requests when adding or removing workspace folders.
59+
The server will requests the specific workspace, if the client support it.
60+
5661
#### [workspace/executeCommand](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_executeCommand)
5762

5863
Executes a [Command](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_executeCommand) if it exists. See [Server Capabilities](#server-capabilities)
@@ -86,3 +91,12 @@ Returns a list of [CodeAction](https://microsoft.github.io/language-server-proto
8691
#### [textDocument/publishDiagnostics](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_publishDiagnostics)
8792

8893
Returns a [PublishDiagnostic object](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#publishDiagnosticsParams)
94+
95+
## Optional LSP Specifications from Client
96+
97+
### Workspace
98+
99+
#### [workspace/configuration](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_configuration)
100+
101+
Will be requested some workspace configurations. The server expect the order of receiving items will match the order of the items requested.
102+
Only will be requested when the `ClientCapabilities` has `workspace.configuration` set to true.

crates/oxc_language_server/src/main.rs

Lines changed: 166 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ use tower_lsp_server::{
1313
lsp_types::{
1414
CodeActionParams, CodeActionResponse, ConfigurationItem, Diagnostic,
1515
DidChangeConfigurationParams, DidChangeTextDocumentParams, DidChangeWatchedFilesParams,
16-
DidCloseTextDocumentParams, DidOpenTextDocumentParams, DidSaveTextDocumentParams,
17-
ExecuteCommandParams, InitializeParams, InitializeResult, InitializedParams, ServerInfo,
18-
Uri, WorkspaceEdit,
16+
DidChangeWorkspaceFoldersParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams,
17+
DidSaveTextDocumentParams, ExecuteCommandParams, InitializeParams, InitializeResult,
18+
InitializedParams, ServerInfo, Uri, WorkspaceEdit,
1919
},
2020
};
2121
use worker::WorkspaceWorker;
@@ -79,26 +79,89 @@ impl Options {
7979
}
8080

8181
impl LanguageServer for Backend {
82-
#[expect(deprecated)] // TODO: FIXME
82+
#[expect(deprecated)] // `params.root_uri` is deprecated, we are only falling back to it if no workspace folder is provided
8383
async fn initialize(&self, params: InitializeParams) -> Result<InitializeResult> {
84+
// initialization_options can be anything, so we are requesting `workspace/configuration` when no initialize options are provided
8485
let options = params.initialization_options.and_then(|mut value| {
8586
let settings = value.get_mut("settings")?.take();
8687
serde_json::from_value::<Options>(settings).ok()
8788
});
8889

89-
// ToDo: add support for multiple workspace folders
90-
// maybe fallback when the client does not support it
91-
let root_worker =
92-
WorkspaceWorker::new(&params.root_uri.unwrap(), options.clone().unwrap_or_default());
93-
94-
*self.workspace_workers.lock().await = vec![root_worker];
95-
96-
if let Some(value) = options {
90+
if let Some(value) = &options {
9791
info!("initialize: {value:?}");
9892
info!("language server version: {:?}", env!("CARGO_PKG_VERSION"));
9993
}
10094

10195
let capabilities = Capabilities::from(params.capabilities);
96+
97+
if let Some(workspace_folder) = params.workspace_folders.as_ref() {
98+
if workspace_folder.is_empty() {
99+
return Err(Error::invalid_params("workspace folder is empty"));
100+
}
101+
102+
let mut workers = vec![];
103+
// when we have only one workspace folder and the client already passed the configuration
104+
if workspace_folder.len() == 1 && options.is_some() {
105+
let root_worker =
106+
WorkspaceWorker::new(&workspace_folder.first().unwrap().uri, options.unwrap());
107+
workers.push(root_worker);
108+
// else check if the client support workspace configuration requests
109+
// and we can request the configuration for each workspace folder
110+
} else if capabilities.workspace_configuration {
111+
let configs = self
112+
.request_workspace_configuration(
113+
workspace_folder.iter().map(|w| w.uri.clone()).collect(),
114+
)
115+
.await;
116+
for (index, folder) in workspace_folder.iter().enumerate() {
117+
let workspace_options = configs
118+
.get(index)
119+
// when there is no valid index fallback to the initialize options
120+
.unwrap_or(&options)
121+
.clone()
122+
// no valid index or initialize option, still fallback to default
123+
.unwrap_or_default();
124+
125+
workers.push(WorkspaceWorker::new(&folder.uri, workspace_options));
126+
}
127+
} else {
128+
for folder in workspace_folder {
129+
workers.push(WorkspaceWorker::new(
130+
&folder.uri,
131+
options.clone().unwrap_or_default(),
132+
));
133+
}
134+
}
135+
136+
*self.workspace_workers.lock().await = workers;
137+
// fallback to root uri if no workspace folder is provided
138+
} else if let Some(root_uri) = params.root_uri.as_ref() {
139+
// use the initialize options if the client does not support workspace configuration or already provided one
140+
let root_options = if options.is_some() {
141+
options.clone().unwrap()
142+
// check if the client support workspace configuration requests
143+
} else if capabilities.workspace_configuration {
144+
let configs = self.request_workspace_configuration(vec![root_uri.clone()]).await;
145+
configs
146+
.first()
147+
// options is already none, no need to pass it here
148+
.unwrap_or(&None)
149+
// no valid index or initialize option, still fallback to default
150+
.clone()
151+
.unwrap_or_default()
152+
// no initialize options provided and the client does not support workspace configuration
153+
// fallback to default
154+
} else {
155+
Options::default()
156+
};
157+
158+
let root_worker = WorkspaceWorker::new(root_uri, root_options);
159+
*self.workspace_workers.lock().await = vec![root_worker];
160+
// one of the two (workspace folder or root_uri) must be provided
161+
} else {
162+
return Err(Error::invalid_params("no workspace folder or root uri"));
163+
}
164+
102165
self.capabilities.set(capabilities.clone()).map_err(|err| {
103166
let message = match err {
104167
SetError::AlreadyInitializedError(_) => {
@@ -117,51 +180,55 @@ impl LanguageServer for Backend {
117180
})
118181
}
119182

183+
async fn initialized(&self, _params: InitializedParams) {
184+
debug!("oxc initialized.");
185+
}
186+
187+
async fn shutdown(&self) -> Result<()> {
188+
self.clear_all_diagnostics().await;
189+
Ok(())
190+
}
191+
120192
async fn did_change_configuration(&self, params: DidChangeConfigurationParams) {
121193
let workers = self.workspace_workers.lock().await;
194+
let params_options = serde_json::from_value::<Options>(params.settings).ok();
122195

123-
// when we have only workspace folder, apply to it
124-
// ToDo: check if this is really safe because the client could still pass an empty settings
125-
if workers.len() == 1 {
196+
// when we have only workspace folder and the client provided us the configuration
197+
// we can just update the worker with the new configuration
198+
if workers.len() == 1 && params_options.is_some() {
126199
let worker = workers.first().unwrap();
127-
worker.did_change_configuration(params.settings).await;
200+
worker.did_change_configuration(&params_options.unwrap()).await;
128201

129202
// else check if the client support workspace configuration requests so we can only restart only the needed workers
130203
} else if self
131204
.capabilities
132205
.get()
133206
.is_some_and(|capabilities| capabilities.workspace_configuration)
134207
{
135-
let mut config_items = vec![];
136-
for worker in workers.iter() {
137-
let Some(uri) = worker.get_root_uri() else {
138-
continue;
139-
};
140-
// ToDo: this is broken in VSCode. Check how we can get the language server configuration from the client
141-
// changing `section` to `oxc` will return the client configuration.
142-
config_items.push(ConfigurationItem {
143-
scope_uri: Some(uri),
144-
section: Some("oxc_language_server".into()),
145-
});
146-
}
147-
148-
let Ok(configs) = self.client.configuration(config_items).await else {
149-
debug!("failed to get configuration");
150-
return;
151-
};
152-
208+
let configs = self
209+
.request_workspace_configuration(
210+
workers.iter().map(worker::WorkspaceWorker::get_root_uri).collect(),
211+
)
212+
.await;
153213
// we expect that the client is sending all the configuration items in order and completed
154214
// this is a LSP specification and errors should be reported on the client side
155215
for (index, worker) in workers.iter().enumerate() {
156-
let config = &configs[index];
157-
worker.did_change_configuration(config.clone()).await;
216+
// get the index or fallback to the initialize options
217+
let config = configs.get(index).unwrap_or(&params_options);
218+
219+
// change anything
220+
let Some(config) = config else {
221+
continue;
222+
};
223+
224+
worker.did_change_configuration(config).await;
158225
}
159226

160227
// we have multiple workspace folders and the client does not support workspace configuration requests
161-
// we assume that every workspace is under effect
162-
} else {
228+
// the client must provide a configuration change or else we do not know what to do
229+
} else if params_options.is_some() {
163230
for worker in workers.iter() {
164-
worker.did_change_configuration(params.settings.clone()).await;
231+
worker.did_change_configuration(&params_options.clone().unwrap()).await;
165232
}
166233
}
167234
}
@@ -205,13 +272,36 @@ impl LanguageServer for Backend {
205272
self.publish_all_diagnostics(x).await;
206273
}
207274

208-
async fn initialized(&self, _params: InitializedParams) {
209-
debug!("oxc initialized.");
210-
}
275+
async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
276+
let workers = self.workspace_workers.lock().await;
277+
let mut cleared_diagnostics = vec![];
278+
for folder in params.event.removed {
279+
let Some((index, worker)) = workers
280+
.iter()
281+
.enumerate()
282+
.find(|(_, worker)| worker.is_responsible_for_uri(&folder.uri))
283+
else {
284+
continue;
285+
};
286+
cleared_diagnostics.extend(worker.get_clear_diagnostics().await);
287+
self.workspace_workers.lock().await.remove(index);
288+
}
211289

212-
async fn shutdown(&self) -> Result<()> {
213-
self.clear_all_diagnostics().await;
214-
Ok(())
290+
self.publish_all_diagnostics(&cleared_diagnostics).await;
291+
292+
// ToDo: check if the client support workspace configuration requests
293+
let configurations = self
294+
.request_workspace_configuration(
295+
params.event.added.iter().map(|w| w.uri.clone()).collect(),
296+
)
297+
.await;
298+
299+
for (index, folder) in params.event.added.iter().enumerate() {
300+
let option = configurations.get(index).unwrap_or(&None);
301+
let option = option.clone().unwrap_or(Options::default());
302+
303+
self.workspace_workers.lock().await.push(WorkspaceWorker::new(&folder.uri, option));
304+
}
215305
}
216306

217307
async fn did_save(&self, params: DidSaveTextDocumentParams) {
@@ -346,6 +436,37 @@ impl LanguageServer for Backend {
346436
}
347437

348438
impl Backend {
439+
/// Request the workspace configuration from the client
440+
/// and return the options for each workspace folder.
441+
/// The check if the client support workspace configuration, should be done before.
442+
async fn request_workspace_configuration(&self, uris: Vec<Uri>) -> Vec<Option<Options>> {
443+
let length = uris.len();
444+
let config_items = uris
445+
.into_iter()
446+
.map(|uri| ConfigurationItem {
447+
scope_uri: Some(uri),
448+
section: Some("oxc_language_server".into()),
449+
})
450+
.collect::<Vec<_>>();
451+
452+
let Ok(configs) = self.client.configuration(config_items).await else {
453+
debug!("failed to get configuration");
454+
// return none for each workspace folder
455+
return vec![None; length];
456+
};
457+
458+
let mut options = vec![];
459+
for config in configs {
460+
options.push(serde_json::from_value::<Options>(config).ok());
461+
}
462+
463+
debug_assert!(
464+
options.len() == length,
465+
"the number of configuration items should be the same as the number of workspace folders"
466+
);
467+
468+
options
469+
}
349470
// clears all diagnostics for workspace folders
350471
async fn clear_all_diagnostics(&self) {
351472
let mut cleared_diagnostics = vec![];

crates/oxc_language_server/src/worker.rs

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,8 @@ impl WorkspaceWorker {
5555
}
5656
}
5757

58-
pub fn get_root_uri(&self) -> Option<Uri> {
59-
self.root_uri.get().cloned()
58+
pub fn get_root_uri(&self) -> Uri {
59+
self.root_uri.get().unwrap().clone()
6060
}
6161

6262
pub fn is_responsible_for_uri(&self, uri: &Uri) -> bool {
@@ -408,27 +408,25 @@ impl WorkspaceWorker {
408408

409409
pub async fn did_change_configuration(
410410
&self,
411-
options: serde_json::value::Value,
411+
options: &Options,
412412
) -> Option<ConcurrentHashMap<String, Vec<DiagnosticReport>>> {
413-
let changed_options = serde_json::from_value::<Options>(options).unwrap_or_default();
414-
415413
let current_option = &self.options.lock().await.clone();
416414

417415
debug!(
418416
"
419417
configuration changed:
420-
incoming: {changed_options:?}
418+
incoming: {options:?}
421419
current: {current_option:?}
422420
"
423421
);
424422

425-
*self.options.lock().await = changed_options.clone();
423+
*self.options.lock().await = options.clone();
426424

427-
if changed_options.use_nested_configs() != current_option.use_nested_configs() {
425+
if options.use_nested_configs() != current_option.use_nested_configs() {
428426
self.refresh_nested_configs().await;
429427
}
430428

431-
if Self::needs_linter_restart(current_option, &changed_options) {
429+
if Self::needs_linter_restart(current_option, options) {
432430
self.refresh_linter_config().await;
433431
return Some(self.revalidate_diagnostics().await);
434432
}
@@ -466,7 +464,7 @@ mod tests {
466464
let worker =
467465
WorkspaceWorker::new(&Uri::from_str("file:///root/").unwrap(), Options::default());
468466

469-
assert_eq!(worker.get_root_uri(), Some(Uri::from_str("file:///root/").unwrap()));
467+
assert_eq!(worker.get_root_uri(), Uri::from_str("file:///root/").unwrap());
470468
}
471469

472470
#[test]

editors/vscode/README.md

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,31 @@ This is the linter for Oxc. The currently supported features are listed below.
1919
- Command to fix all auto-fixable content within the current text editor.
2020
- Support for `source.fixAll.oxc` as a code action provider. Configure this in your settings `editor.codeActionsOnSave`
2121
to automatically apply fixes when saving the file.
22+
- Support for multi root workspaces
2223

2324
## Configuration
2425

25-
Following configuration are supported via `settings.json`:
26+
### Window Configuration
27+
28+
Following configuration are supported via `settings.json` and effect the window editor:
2629

2730
| Key | Default Value | Possible Values | Description |
2831
| ------------------ | ------------- | -------------------------------- | --------------------------------------------------------------------------- |
29-
| `oxc.lint.run` | `onType` | `onSave` \| `onType` | Run the linter on save (onSave) or on type (onType) |
3032
| `oxc.enable` | `true` | `true` \| `false` | Enables the language server to receive lint diagnostics |
3133
| `oxc.trace.server` | `off` | `off` \| `messages` \| `verbose` | races the communication between VS Code and the language server. |
32-
| `oxc.configPath` | `null` | `null`\| `<string>` | Path to ESlint configuration. Keep it empty to enable nested configuration. |
3334
| `oxc.path.server` | - | `<string>` | Path to Oxc language server binary. Mostly for testing the language server. |
34-
| `oxc.flags` | - | `Record<string, string>` | Custom flags passed to the language server. |
3535

36-
### Flags
36+
### Workspace Configuration
37+
38+
Following configuration are supported via `settings.json` and can be changed for each workspace:
39+
40+
| Key | Default Value | Possible Values | Description |
41+
| ---------------- | ------------- | ------------------------ | --------------------------------------------------------------------------- |
42+
| `oxc.lint.run` | `onType` | `onSave` \| `onType` | Run the linter on save (onSave) or on type (onType) |
43+
| `oxc.configPath` | `null` | `null`\| `<string>` | Path to ESlint configuration. Keep it empty to enable nested configuration. |
44+
| `oxc.flags` | - | `Record<string, string>` | Custom flags passed to the language server. |
45+
46+
#### Flags
3747

3848
- `key: disable_nested_config`: Disabled nested configuration and searches only for `configPath`
3949
- `key: fix_kind`: default: `"safe_fix"`, possible values `"safe_fix" | "safe_fix_or_suggestion" | "dangerous_fix" | "dangerous_fix_or_suggestion" | "none" | "all"`

0 commit comments

Comments
 (0)