Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(oxc_language_server): Support nested configs #9724

Closed

Conversation

nrayburn-tech
Copy link
Collaborator

@nrayburn-tech nrayburn-tech commented Mar 12, 2025

Just putting this up to have the idea validated before I clean it up and separate it out into different PRs.

Left comments inline to sort of communicate what I'm doing or why. I don't do much in Rust, so I think it might be helpful to communicate what I'm trying to do in case there are better alternatives.

  • There are multiple places doing .unwrap that I want to review and fix.
  • I want to split this into at least a separate PR for the language server changes and the VS Code changes. Maybe even additional PRs that aren't explicitly required for nested config support, such as the change on what files are watched from VS Code.

Copy link

graphite-app bot commented Mar 12, 2025

How to use the Graphite Merge Queue

Add either label to this PR to merge it via the merge queue:

  • 0-merge - adds this PR to the back of the merge queue
  • hotfix - for urgent hot fixes, skip the queue and merge this PR next

You must have a Graphite account in order to use the merge queue. Sign up using this link.

An organization admin has enabled the Graphite Merge Queue in this repository.

Please do not merge from GitHub as this will restart CI on PRs being processed by the merge queue.

@github-actions github-actions bot added A-linter Area - Linter A-editor Area - Editor and Language Server C-enhancement Category - New feature or request labels Mar 12, 2025
Copy link

codspeed-hq bot commented Mar 12, 2025

CodSpeed Performance Report

Merging #9724 will create unknown performance changes

Comparing nrayburn-tech:lsp-nested-configs (128680c) with main (b9ab60b)

Summary

🆕 33 new benchmarks

Benchmarks breakdown

Benchmark BASE HEAD Change
🆕 codegen[checker.ts] N/A 23.1 ms N/A
🆕 codegen_sourcemap[checker.ts] N/A 66.1 ms N/A
🆕 isolated-declarations[vue-id.ts] N/A 58.7 ms N/A
🆕 lexer[RadixUIAdoptionSection.jsx] N/A 20.8 µs N/A
🆕 lexer[antd.js] N/A 24.1 ms N/A
🆕 lexer[cal.com.tsx] N/A 5.7 ms N/A
🆕 lexer[checker.ts] N/A 14.5 ms N/A
🆕 lexer[pdf.mjs] N/A 3.8 ms N/A
🆕 linter[RadixUIAdoptionSection.jsx] N/A 2.7 ms N/A
🆕 linter[cal.com.tsx] N/A 1.2 s N/A
🆕 linter[checker.ts] N/A 2.9 s N/A
🆕 mangler[antd.js] N/A 16.1 ms N/A
🆕 mangler[react.development.js] N/A 294.5 µs N/A
🆕 mangler[typescript.js] N/A 39.7 ms N/A
🆕 minifier[antd.js] N/A 167.6 ms N/A
🆕 minifier[react.development.js] N/A 1.8 ms N/A
🆕 minifier[typescript.js] N/A 294.5 ms N/A
🆕 estree[checker.ts] N/A 88.4 ms N/A
🆕 parser[RadixUIAdoptionSection.jsx] N/A 88.8 µs N/A
🆕 parser[antd.js] N/A 112.1 ms N/A
... ... ... ... ...

ℹ️ Only the first 20 benchmarks are displayed. Go to the app to view all benchmarks.

struct Backend {
client: Client,
root_uri: OnceCell<Option<Url>>,
server_linter: RwLock<ServerLinter>,
diagnostics_report_map: ConcurrentHashMap<String, Vec<DiagnosticReport>>,
options: Mutex<Options>,
gitignore_glob: Mutex<Vec<Gitignore>>,
nested_configs: RwLock<FxHashMap<PathBuf, ConfigStore>>,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Store a map of directory to config for all nested configs in the workspace.

@@ -54,11 +56,17 @@ struct Options {
run: Run,
enable: bool,
config_path: String,
use_nested_configs: bool,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Allow opting into nested configs. If false, there shouldn't be any behavior change.

debug!("watched file did change");
if self.options.lock().await.use_nested_configs {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If using nested configs, check the watched files to determine if they are .oxlintrc.json files (which are the only nested config files that are supported to my knowledge).

This just keeps the nested config map up to date with any workspace changes.

);
}

if self.options.lock().await.use_nested_configs {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If using nested configs, use Linter::new_with_nested_configs and pass in the nested config files. Otherwise, should be no behavior change. However, I did do some refactoring here to make this easier for me to read through.

workspace.createFileSystemWatcher(oxlintConfigFileGlob),
...workspaceConfigPatterns.map((workspaceConfigPattern) => {
return workspace.createFileSystemWatcher(workspaceConfigPattern);
}),
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Watch any .oxlintrc.json file in the workspace.
Watch the file that the user has configured within their configuration.

This stops watching all variations of oxlint config files that were previously watched. Which, I think is safe.

changes: initialConfigFiles.map(file => {
return { uri: file.toString(), type: FileChangeType.Created };
}),
});
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On startup, send all found config files to the language server as "created", so that they get added to the nested config map.

struct Backend {
client: Client,
root_uri: OnceCell<Option<Url>>,
server_linter: RwLock<ServerLinter>,
diagnostics_report_map: ConcurrentHashMap<String, Vec<DiagnosticReport>>,
options: Mutex<Options>,
gitignore_glob: Mutex<Vec<Gitignore>>,
nested_configs: RwLock<FxHashMap<PathBuf, ConfigStore>>,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know if this is the correct type. RwLock<FxHashMap<PathBuf, ConfigStore>>. Maybe something else should be used, but this is the only one I could figure out how to make work.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems ok to me, but I'm not an expert in the async data structures area 😄 Might be worth looking at https://docs.rs/papaya/latest/papaya/, as I know we have been using that recently?

let existing_nested_configs = (*self.nested_configs.read().await).clone();
for (key, value) in existing_nested_configs {
config_files.insert(key.clone(), value.clone());
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am copying the map here, so I can mutate it later, before setting the value back into self.nested_configs. There might be a better way to do this that I don't know about.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be worth looking at https://doc.rust-lang.org/std/mem/fn.take.html. It still requires allocating an empty hash map, but that is much cheaper compared to cloning a full hash map. Then after you've taken the hash map, you have full ownership over it and can insert into as needed.

let existing_nested_configs = (*self.nested_configs.read().await).clone();
for (key, value) in existing_nested_configs {
config_files.insert(key.clone(), value.clone());
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copying the map so that I can pass it into Linter::new_with_nested_configs. Might be a better way to do the copy that I'm not aware of.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe std::mem::take? https://doc.rust-lang.org/std/mem/fn.take.html but not sure if that works in async context like this

@@ -19,7 +19,7 @@ impl Clone for ResolvedLinterState {
}
}

#[derive(Debug)]
#[derive(Debug, Clone)]
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needed so that I can clone my map that contains this struct.

@@ -29,7 +29,7 @@ struct Config {
}

/// Resolves a lint configuration for a given file, by applying overrides based on the file's path.
#[derive(Debug)]
#[derive(Debug, Clone)]
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needed so that I can clone my map that contains this struct.

@nrayburn-tech
Copy link
Collaborator Author

nrayburn-tech commented Mar 12, 2025

@camchenry can you review this to make sure the logic lines up with how the CLI works? (And any assistance with the code that looks odd would be helpful to, like if I should be doing something different with the maps instead of cloning.)

@nrayburn-tech
Copy link
Collaborator Author

@Sysix can you make sure this functionality makes sense to you from the language server perspective? (And any feedback with how the code is actually written would be appreciated too.)

Copy link
Member

@camchenry camchenry left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this all looks right so far. I guess one advantage of having the actual config resolution in just oxc_linter is that it should "just work". As long as the config files are found correctly and passed in, the config files should just get resolved as expected, same as in the CLI (hopefully).

let mut config_files = FxHashMap::default();
let existing_nested_configs = (*self.nested_configs.read().await).clone();
for (key, value) in existing_nested_configs {
config_files.insert(key.clone(), value.clone());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead of inserting one-by-one, maybe we can try extending the hash map all at once? this should be more efficient because it allocates the needed memory upfront: https://doc.rust-lang.org/nightly/std/collections/struct.HashMap.html#impl-Extend%3C(K,+V)%3E-for-HashMap%3CK,+V,+S%3E

let existing_nested_configs = (*self.nested_configs.read().await).clone();
for (key, value) in existing_nested_configs {
config_files.insert(key.clone(), value.clone());
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be worth looking at https://doc.rust-lang.org/std/mem/fn.take.html. It still requires allocating an empty hash map, but that is much cheaper compared to cloning a full hash map. Then after you've taken the hash map, you have full ownership over it and can insert into as needed.

Comment on lines +201 to +207
if x.uri.to_file_path().unwrap().file_name().unwrap() != OXC_CONFIG_FILE {
return;
}
// spellchecker:off -- "typ" is accurate
if x.typ == FileChangeType::CREATED || x.typ == FileChangeType::CHANGED {
// spellchecker:on
let file_path = x.uri.to_file_path().unwrap();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could avoid a duplicate unwrap here and some work by saving to_file_path():

Suggested change
if x.uri.to_file_path().unwrap().file_name().unwrap() != OXC_CONFIG_FILE {
return;
}
// spellchecker:off -- "typ" is accurate
if x.typ == FileChangeType::CREATED || x.typ == FileChangeType::CHANGED {
// spellchecker:on
let file_path = x.uri.to_file_path().unwrap();
let Some(file_path) = x.uri.to_file_path() else {
return;
};
if file_path.file_name().unwrap() != OXC_CONFIG_FILE {
return;
}
// spellchecker:off -- "typ" is accurate
if x.typ == FileChangeType::CREATED || x.typ == FileChangeType::CHANGED {
// spellchecker:on

let existing_nested_configs = (*self.nested_configs.read().await).clone();
for (key, value) in existing_nested_configs {
config_files.insert(key.clone(), value.clone());
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe std::mem::take? https://doc.rust-lang.org/std/mem/fn.take.html but not sure if that works in async context like this

@nrayburn-tech
Copy link
Collaborator Author

Closing this as it has been replaced with smaller PRs along with some of the feedback from here addressed.

#9731 - Only watch relevant config files
#9739 - Language server nested config support.
TBD - VS Code send all config files to the Language server on extension startup.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-editor Area - Editor and Language Server A-linter Area - Linter C-enhancement Category - New feature or request
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants