Skip to content

Commit be7f7e1

Browse files
committed
feat(language_server/editor): support multi workspace folders (#10875)
Follow up from #10515 Testsetup can be found here: #10515 (comment)
1 parent 21117ac commit be7f7e1

File tree

11 files changed

+294
-119
lines changed

11 files changed

+294
-119
lines changed

crates/oxc_language_server/README.md

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

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

90+
#### [workspace/didChangeWorkspaceFolders](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_didChangeWorkspaceFolders)
91+
92+
The server expects this request when adding or removing workspace folders.
93+
The server will request the specific workspace configuration, if the client supports it.
94+
9095
#### [workspace/executeCommand](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_executeCommand)
9196

9297
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)

crates/oxc_language_server/src/main.rs

Lines changed: 79 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ use tower_lsp_server::{
1111
lsp_types::{
1212
CodeActionParams, CodeActionResponse, ConfigurationItem, Diagnostic,
1313
DidChangeConfigurationParams, DidChangeTextDocumentParams, DidChangeWatchedFilesParams,
14-
DidCloseTextDocumentParams, DidOpenTextDocumentParams, DidSaveTextDocumentParams,
15-
ExecuteCommandParams, InitializeParams, InitializeResult, InitializedParams, ServerInfo,
16-
Uri, WorkspaceEdit,
14+
DidChangeWorkspaceFoldersParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams,
15+
DidSaveTextDocumentParams, ExecuteCommandParams, InitializeParams, InitializeResult,
16+
InitializedParams, ServerInfo, Uri, WorkspaceEdit,
1717
},
1818
};
1919
// #
@@ -88,9 +88,10 @@ impl Options {
8888
}
8989

9090
impl LanguageServer for Backend {
91-
#[expect(deprecated)] // TODO: FIXME
91+
#[expect(deprecated)] // `params.root_uri` is deprecated, we are only falling back to it if no workspace folder is provided
9292
async fn initialize(&self, params: InitializeParams) -> Result<InitializeResult> {
9393
let server_version = env!("CARGO_PKG_VERSION");
94+
// initialization_options can be anything, so we are requesting `workspace/configuration` when no initialize options are provided
9495
let options = params.initialization_options.and_then(|mut value| {
9596
// the client supports the new settings object
9697
if let Ok(new_settings) = serde_json::from_value::<Vec<WorkspaceOption>>(value.clone())
@@ -120,27 +121,43 @@ impl LanguageServer for Backend {
120121

121122
let capabilities = Capabilities::from(params.capabilities);
122123

123-
// ToDo: add support for multiple workspace folders
124-
// maybe fallback when the client does not support it
125-
let root_worker = WorkspaceWorker::new(params.root_uri.unwrap());
124+
// client sent workspace folders
125+
let workers = if let Some(workspace_folders) = &params.workspace_folders {
126+
workspace_folders
127+
.iter()
128+
.map(|workspace_folder| WorkspaceWorker::new(workspace_folder.uri.clone()))
129+
.collect()
130+
// client sent deprecated root uri
131+
} else if let Some(root_uri) = params.root_uri {
132+
vec![WorkspaceWorker::new(root_uri)]
133+
// client is in single file mode, create no workers
134+
} else {
135+
vec![]
136+
};
126137

127138
// When the client did not send our custom `initialization_options`,
128139
// or the client does not support `workspace/configuration` request,
129140
// start the linter. We do not start the linter when the client support the request,
130141
// we will init the linter after requesting for the workspace configuration.
131142
if !capabilities.workspace_configuration || options.is_some() {
132-
root_worker
133-
.init_linter(
134-
&options
135-
.unwrap_or_default()
136-
.first()
137-
.map(|workspace_options| workspace_options.options.clone())
138-
.unwrap_or_default(),
139-
)
140-
.await;
143+
for worker in &workers {
144+
worker
145+
.init_linter(
146+
&options
147+
.clone()
148+
.unwrap_or_default()
149+
.iter()
150+
.find(|workspace_option| {
151+
worker.is_responsible_for_uri(&workspace_option.workspace_uri)
152+
})
153+
.map(|workspace_options| workspace_options.options.clone())
154+
.unwrap_or_default(),
155+
)
156+
.await;
157+
}
141158
}
142159

143-
*self.workspace_workers.lock().await = vec![root_worker];
160+
*self.workspace_workers.lock().await = workers;
144161

145162
self.capabilities.set(capabilities.clone()).map_err(|err| {
146163
let message = match err {
@@ -339,6 +356,51 @@ impl LanguageServer for Backend {
339356
self.publish_all_diagnostics(x).await;
340357
}
341358

359+
async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
360+
let mut workers = self.workspace_workers.lock().await;
361+
let mut cleared_diagnostics = vec![];
362+
363+
for folder in params.event.removed {
364+
let Some((index, worker)) = workers
365+
.iter()
366+
.enumerate()
367+
.find(|(_, worker)| worker.is_responsible_for_uri(&folder.uri))
368+
else {
369+
continue;
370+
};
371+
cleared_diagnostics.extend(worker.get_clear_diagnostics());
372+
workers.remove(index);
373+
}
374+
375+
self.publish_all_diagnostics(&cleared_diagnostics).await;
376+
377+
// client support `workspace/configuration` request
378+
if self.capabilities.get().is_some_and(|capabilities| capabilities.workspace_configuration)
379+
{
380+
let configurations = self
381+
.request_workspace_configuration(
382+
params.event.added.iter().map(|w| &w.uri).collect(),
383+
)
384+
.await;
385+
386+
for (index, folder) in params.event.added.iter().enumerate() {
387+
let worker = WorkspaceWorker::new(folder.uri.clone());
388+
// get the configuration from the response and init the linter
389+
let options = configurations.get(index).unwrap_or(&None);
390+
worker.init_linter(options.as_ref().unwrap_or(&Options::default())).await;
391+
workers.push(worker);
392+
}
393+
// client does not support the request
394+
} else {
395+
for folder in params.event.added {
396+
let worker = WorkspaceWorker::new(folder.uri);
397+
// use default options
398+
worker.init_linter(&Options::default()).await;
399+
workers.push(worker);
400+
}
401+
}
402+
}
403+
342404
async fn did_save(&self, params: DidSaveTextDocumentParams) {
343405
debug!("oxc server did save");
344406
let uri = &params.text_document.uri;

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"`

editors/vscode/client/ConfigService.ts

Lines changed: 69 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,28 @@
1-
import { ConfigurationChangeEvent, workspace } from 'vscode';
1+
import { ConfigurationChangeEvent, Uri, workspace, WorkspaceFolder } from 'vscode';
22
import { IDisposable } from './types';
33
import { VSCodeConfig } from './VSCodeConfig';
4-
import { WorkspaceConfig } from './WorkspaceConfig';
4+
import { oxlintConfigFileName, WorkspaceConfig, WorkspaceConfigInterface } from './WorkspaceConfig';
55

66
export class ConfigService implements IDisposable {
77
public static readonly namespace = 'oxc';
88
private readonly _disposables: IDisposable[] = [];
99

1010
public vsCodeConfig: VSCodeConfig;
1111

12-
private _workspaceConfig: WorkspaceConfig;
12+
private workspaceConfigs: Map<string, WorkspaceConfig> = new Map();
1313

1414
public onConfigChange:
1515
| ((this: ConfigService, config: ConfigurationChangeEvent) => Promise<void>)
1616
| undefined;
1717

1818
constructor() {
19-
const conf = workspace.getConfiguration(ConfigService.namespace);
20-
this.vsCodeConfig = new VSCodeConfig(conf);
21-
this._workspaceConfig = new WorkspaceConfig(conf);
19+
this.vsCodeConfig = new VSCodeConfig();
20+
const workspaceFolders = workspace.workspaceFolders;
21+
if (workspaceFolders) {
22+
for (const folder of workspaceFolders) {
23+
this.addWorkspaceConfig(folder);
24+
}
25+
}
2226
this.onConfigChange = undefined;
2327

2428
const disposeChangeListener = workspace.onDidChangeConfiguration(
@@ -27,19 +31,71 @@ export class ConfigService implements IDisposable {
2731
this._disposables.push(disposeChangeListener);
2832
}
2933

30-
public get rootServerConfig(): WorkspaceConfig {
31-
return this._workspaceConfig;
34+
public get languageServerConfig(): { workspaceUri: string; options: WorkspaceConfigInterface }[] {
35+
return [...this.workspaceConfigs.entries()].map(([path, config]) => ({
36+
workspaceUri: Uri.file(path).toString(),
37+
options: config.toLanguageServerConfig(),
38+
}));
39+
}
40+
41+
public addWorkspaceConfig(workspace: WorkspaceFolder): WorkspaceConfig {
42+
let workspaceConfig = new WorkspaceConfig(workspace);
43+
this.workspaceConfigs.set(workspace.uri.path, workspaceConfig);
44+
return workspaceConfig;
45+
}
46+
47+
public removeWorkspaceConfig(workspace: WorkspaceFolder): void {
48+
this.workspaceConfigs.delete(workspace.uri.path);
3249
}
3350

34-
public refresh(): void {
35-
const conf = workspace.getConfiguration(ConfigService.namespace);
36-
this.vsCodeConfig.refresh(conf);
37-
this.rootServerConfig.refresh(conf);
51+
public getWorkspaceConfig(workspace: Uri): WorkspaceConfig | undefined {
52+
return this.workspaceConfigs.get(workspace.path);
53+
}
54+
55+
public effectsWorkspaceConfigChange(event: ConfigurationChangeEvent): boolean {
56+
for (const workspaceConfig of this.workspaceConfigs.values()) {
57+
if (workspaceConfig.effectsConfigChange(event)) {
58+
return true;
59+
}
60+
}
61+
return false;
62+
}
63+
64+
public effectsWorkspaceConfigPathChange(event: ConfigurationChangeEvent): boolean {
65+
for (const workspaceConfig of this.workspaceConfigs.values()) {
66+
if (workspaceConfig.effectsConfigPathChange(event)) {
67+
return true;
68+
}
69+
}
70+
return false;
71+
}
72+
73+
public getOxlintCustomConfigs(): string[] {
74+
const customConfigs: string[] = [];
75+
for (const [path, config] of this.workspaceConfigs.entries()) {
76+
if (config.configPath && config.configPath !== oxlintConfigFileName) {
77+
customConfigs.push(`${path}/${config.configPath}`);
78+
}
79+
}
80+
return customConfigs;
3881
}
3982

4083
private async onVscodeConfigChange(event: ConfigurationChangeEvent): Promise<void> {
84+
let isConfigChanged = false;
85+
4186
if (event.affectsConfiguration(ConfigService.namespace)) {
42-
this.refresh();
87+
this.vsCodeConfig.refresh();
88+
isConfigChanged = true;
89+
}
90+
91+
for (const workspaceConfig of this.workspaceConfigs.values()) {
92+
if (workspaceConfig.effectsConfigChange(event)) {
93+
workspaceConfig.refresh();
94+
isConfigChanged = true;
95+
}
96+
}
97+
98+
if (isConfigChanged) {
4399
await this.onConfigChange?.(event);
44100
}
45101
}

editors/vscode/client/VSCodeConfig.spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { VSCodeConfig } from './VSCodeConfig.js';
44

55
const conf = workspace.getConfiguration('oxc');
66

7-
suite('Config', () => {
7+
suite('VSCodeConfig', () => {
88
setup(async () => {
99
const keys = ['enable', 'trace.server', 'path.server'];
1010

@@ -20,15 +20,15 @@ suite('Config', () => {
2020
});
2121

2222
test('default values on initialization', () => {
23-
const config = new VSCodeConfig(conf);
23+
const config = new VSCodeConfig();
2424

2525
strictEqual(config.enable, true);
2626
strictEqual(config.trace, 'off');
2727
strictEqual(config.binPath, '');
2828
});
2929

3030
test('updating values updates the workspace configuration', async () => {
31-
const config = new VSCodeConfig(conf);
31+
const config = new VSCodeConfig();
3232

3333
await Promise.all([
3434
config.updateEnable(false),

editors/vscode/client/VSCodeConfig.ts

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,23 @@
1-
import { workspace, WorkspaceConfiguration } from 'vscode';
1+
import { workspace } from 'vscode';
22
import { ConfigService } from './ConfigService';
33

4-
export const oxlintConfigFileName = '.oxlintrc.json';
5-
64
export class VSCodeConfig implements VSCodeConfigInterface {
75
private _enable!: boolean;
86
private _trace!: TraceLevel;
97
private _binPath: string | undefined;
108

11-
constructor(configuration: WorkspaceConfiguration) {
12-
this.refresh(configuration);
9+
constructor() {
10+
this.refresh();
11+
}
12+
13+
private get configuration() {
14+
return workspace.getConfiguration(ConfigService.namespace);
1315
}
1416

15-
public refresh(configuration: WorkspaceConfiguration): void {
16-
this._enable = configuration.get<boolean>('enable') ?? true;
17-
this._trace = configuration.get<TraceLevel>('trace.server') || 'off';
18-
this._binPath = configuration.get<string>('path.server');
17+
public refresh(): void {
18+
this._enable = this.configuration.get<boolean>('enable') ?? true;
19+
this._trace = this.configuration.get<TraceLevel>('trace.server') || 'off';
20+
this._binPath = this.configuration.get<string>('path.server');
1921
}
2022

2123
get enable(): boolean {
@@ -24,9 +26,7 @@ export class VSCodeConfig implements VSCodeConfigInterface {
2426

2527
updateEnable(value: boolean): PromiseLike<void> {
2628
this._enable = value;
27-
return workspace
28-
.getConfiguration(ConfigService.namespace)
29-
.update('enable', value);
29+
return this.configuration.update('enable', value);
3030
}
3131

3232
get trace(): TraceLevel {
@@ -35,9 +35,7 @@ export class VSCodeConfig implements VSCodeConfigInterface {
3535

3636
updateTrace(value: TraceLevel): PromiseLike<void> {
3737
this._trace = value;
38-
return workspace
39-
.getConfiguration(ConfigService.namespace)
40-
.update('trace.server', value);
38+
return this.configuration.update('trace.server', value);
4139
}
4240

4341
get binPath(): string | undefined {
@@ -46,9 +44,7 @@ export class VSCodeConfig implements VSCodeConfigInterface {
4644

4745
updateBinPath(value: string | undefined): PromiseLike<void> {
4846
this._binPath = value;
49-
return workspace
50-
.getConfiguration(ConfigService.namespace)
51-
.update('path.server', value);
47+
return this.configuration.update('path.server', value);
5248
}
5349
}
5450

0 commit comments

Comments
 (0)