Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 667768c

Browse files
committedFeb 22, 2025·
feat(linter): add support for nested config files
1 parent cded0ad commit 667768c

File tree

14 files changed

+215
-8
lines changed

14 files changed

+215
-8
lines changed
 
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"rules": {
3+
"no-console": "error",
4+
"no-debugger": "error"
5+
}
6+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
console.log("test");
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
debugger;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
// this is a nested config file, but it should use the default config which
3+
// is implicitly merged with this
4+
"rules": {}
5+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
console.log("test");
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
debugger;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
console.log("test");
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
debugger;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"rules": {
3+
// should not be used, as it is overridden by the nested config
4+
"no-console": "warn"
5+
}
6+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"rules": {
3+
"no-console": "error"
4+
}
5+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function Component() {
2+
console.log("hello");
3+
}

‎apps/oxlint/src/lint.rs

+83-4
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@ use cow_utils::CowUtils;
99
use ignore::{gitignore::Gitignore, overrides::OverrideBuilder};
1010
use oxc_diagnostics::{DiagnosticService, GraphicalReportHandler};
1111
use oxc_linter::{
12-
loader::LINT_PARTIAL_LOADER_EXT, AllowWarnDeny, ConfigStoreBuilder, InvalidFilterKind,
13-
LintFilter, LintOptions, LintService, LintServiceOptions, Linter, Oxlintrc,
12+
loader::LINT_PARTIAL_LOADER_EXT, AllowWarnDeny, ConfigStore, ConfigStoreBuilder,
13+
InvalidFilterKind, LintFilter, LintOptions, LintService, LintServiceOptions, Linter, Oxlintrc,
1414
};
1515
use oxc_span::VALID_EXTENSIONS;
16+
use rustc_hash::{FxHashMap, FxHashSet};
1617
use serde_json::Value;
1718

1819
use crate::{
@@ -55,6 +56,7 @@ impl Runner for LintRunner {
5556
fix_options,
5657
enable_plugins,
5758
misc_options,
59+
experimental_nested_config,
5860
..
5961
} = self.options;
6062

@@ -168,6 +170,56 @@ impl Runner for LintRunner {
168170

169171
let number_of_files = paths.len();
170172

173+
// TODO(perf): benchmark whether or not it is worth it to store the configurations on a
174+
// per-file or per-directory basis, to avoid calling `.parent()` on every path.
175+
let mut nested_oxlintrc = FxHashMap::<&Path, Oxlintrc>::default();
176+
let mut nested_configs = FxHashMap::<PathBuf, ConfigStore>::default();
177+
178+
if experimental_nested_config {
179+
// get all of the unique directories among the paths to use for search for
180+
// oxlint config files in those directories
181+
// e.g. `/some/file.js` and `/some/other/file.js` would both result in `/some`
182+
let mut directories = FxHashSet::default();
183+
for path in &paths {
184+
if let Some(directory) = path.parent() {
185+
// TODO(perf): benchmark whether or not it is worth it to produce the config files here without
186+
// iterating over the directories again. it might cause the cache for paths to be disrupted, but
187+
// it is unclear whether or not that is a problem in practice.
188+
directories.insert(directory);
189+
}
190+
}
191+
for directory in directories {
192+
// TODO: how should we handle a nested config error?
193+
if let Ok(config) = Self::find_oxlint_config_in_directory(directory) {
194+
nested_oxlintrc.insert(directory, config);
195+
}
196+
}
197+
198+
// iterate over each config and build the ConfigStore
199+
for (dir, oxlintrc) in nested_oxlintrc {
200+
// TODO(perf): figure out if we can avoid cloning `filter`
201+
let builder =
202+
ConfigStoreBuilder::from_oxlintrc(false, oxlintrc).with_filters(filter.clone());
203+
match builder.build() {
204+
Ok(config) => nested_configs.insert(dir.to_path_buf(), config),
205+
Err(diagnostic) => {
206+
let handler = GraphicalReportHandler::new();
207+
let mut err = String::new();
208+
handler.render_report(&mut err, &diagnostic).unwrap();
209+
stdout
210+
.write_all(
211+
format!("Failed to parse configuration file.\n{err}\n").as_bytes(),
212+
)
213+
.or_else(Self::check_for_writer_error)
214+
.unwrap();
215+
stdout.flush().unwrap();
216+
217+
return CliRunResult::InvalidOptionConfig;
218+
}
219+
};
220+
}
221+
}
222+
171223
enable_plugins.apply_overrides(&mut oxlintrc.plugins);
172224

173225
let oxlintrc_for_print = if misc_options.print_config || basic_options.init {
@@ -245,8 +297,11 @@ impl Runner for LintRunner {
245297
}
246298
};
247299

248-
let linter =
249-
Linter::new(LintOptions::default(), lint_config).with_fix(fix_options.fix_kind());
300+
let linter = if experimental_nested_config {
301+
Linter::new_with_nested_configs(LintOptions::default(), lint_config, nested_configs)
302+
} else {
303+
Linter::new(LintOptions::default(), lint_config).with_fix(fix_options.fix_kind())
304+
};
250305

251306
let tsconfig = basic_options.tsconfig;
252307
if let Some(path) = tsconfig.as_ref() {
@@ -381,6 +436,24 @@ impl LintRunner {
381436
Oxlintrc::from_file(&config_path).or_else(|_| Ok(Oxlintrc::default()))
382437
}
383438

439+
/// Looks in a directory for an oxlint config file, returns the oxlint config if it exists
440+
/// and returns `Err` if none exists or the file is invalid. Does not apply the default
441+
/// config file.
442+
fn find_oxlint_config_in_directory(dir: &Path) -> Result<Oxlintrc, String> {
443+
let possible_config_path = dir.join(Self::DEFAULT_OXLINTRC);
444+
if possible_config_path.is_file() {
445+
Oxlintrc::from_file(&possible_config_path).map_err(|e| {
446+
let handler = GraphicalReportHandler::new();
447+
let mut err = String::new();
448+
handler.render_report(&mut err, &e).unwrap();
449+
err
450+
})
451+
} else {
452+
// TODO: Better error handling here.
453+
Err("No oxlint config file found".to_string())
454+
}
455+
}
456+
384457
fn check_for_writer_error(error: std::io::Error) -> Result<(), std::io::Error> {
385458
// Do not panic when the process is killed (e.g. piping into `less`).
386459
if matches!(error.kind(), ErrorKind::Interrupted | ErrorKind::BrokenPipe) {
@@ -866,4 +939,10 @@ mod test {
866939
vec![String::from("src/target"), String::from("!src/dist"), String::from("!!src/dist")]
867940
);
868941
}
942+
943+
#[test]
944+
fn test_nested_config() {
945+
let args = &["--experimental-nested-config"];
946+
Tester::new().with_cwd("fixtures/nested_config".into()).test_and_snapshot(args);
947+
}
869948
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
source: apps/oxlint/src/tester.rs
3+
---
4+
##########
5+
arguments: --experimental-nested-config
6+
working directory: fixtures/nested_config
7+
----------
8+
9+
x ]8;;https://oxc.rs/docs/guide/usage/linter/rules/eslint/no-console.html\eslint(no-console)]8;;\: eslint(no-console): Unexpected console statement.
10+
,-[console.ts:1:1]
11+
1 | console.log("test");
12+
: ^^^^^^^^^^^
13+
`----
14+
help: Delete this console statement.
15+
16+
x ]8;;https://oxc.rs/docs/guide/usage/linter/rules/eslint/no-debugger.html\eslint(no-debugger)]8;;\: `debugger` statement is not allowed
17+
,-[debugger.js:1:1]
18+
1 | debugger;
19+
: ^^^^^^^^^
20+
`----
21+
help: Delete this code.
22+
23+
! ]8;;https://oxc.rs/docs/guide/usage/linter/rules/eslint/no-debugger.html\eslint(no-debugger)]8;;\: `debugger` statement is not allowed
24+
,-[package1-empty-config/debugger.js:1:1]
25+
1 | debugger;
26+
: ^^^^^^^^^
27+
`----
28+
help: Delete this code.
29+
30+
x ]8;;https://oxc.rs/docs/guide/usage/linter/rules/eslint/no-console.html\eslint(no-console)]8;;\: eslint(no-console): Unexpected console statement.
31+
,-[package2-no-config/console.ts:1:1]
32+
1 | console.log("test");
33+
: ^^^^^^^^^^^
34+
`----
35+
help: Delete this console statement.
36+
37+
x ]8;;https://oxc.rs/docs/guide/usage/linter/rules/eslint/no-debugger.html\eslint(no-debugger)]8;;\: `debugger` statement is not allowed
38+
,-[package2-no-config/debugger.js:1:1]
39+
1 | debugger;
40+
: ^^^^^^^^^
41+
`----
42+
help: Delete this code.
43+
44+
x ]8;;https://oxc.rs/docs/guide/usage/linter/rules/eslint/no-console.html\eslint(no-console)]8;;\: eslint(no-console): Unexpected console statement.
45+
,-[package3-deep-config/src/components/component.js:2:3]
46+
1 | export function Component() {
47+
2 | console.log("hello");
48+
: ^^^^^^^^^^^
49+
3 | }
50+
`----
51+
help: Delete this console statement.
52+
53+
Found 1 warning and 5 errors.
54+
Finished in <variable>ms on 7 files with 100 rules using 1 threads.
55+
----------
56+
CLI result: LintFoundErrors
57+
----------

‎crates/oxc_linter/src/lib.rs

+44-4
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,14 @@ pub mod loader;
2222
pub mod rules;
2323
pub mod table;
2424

25-
use std::{path::Path, rc::Rc, sync::Arc};
25+
use std::{
26+
path::{Path, PathBuf},
27+
rc::Rc,
28+
sync::Arc,
29+
};
2630

2731
use oxc_semantic::{AstNode, Semantic};
32+
use rustc_hash::FxHashMap;
2833

2934
pub use crate::{
3035
config::{
@@ -62,11 +67,23 @@ pub struct Linter {
6267
options: LintOptions,
6368
// config: Arc<LintConfig>,
6469
config: ConfigStore,
70+
// TODO(refactor): remove duplication with `config` field when nested config is
71+
// standardized, as we do not need to pass both at that point
72+
nested_configs: FxHashMap<PathBuf, ConfigStore>,
6573
}
6674

6775
impl Linter {
6876
pub fn new(options: LintOptions, config: ConfigStore) -> Self {
69-
Self { options, config }
77+
Self { options, config, nested_configs: FxHashMap::default() }
78+
}
79+
80+
// TODO(refactor); remove this when nested config is standardized
81+
pub fn new_with_nested_configs(
82+
options: LintOptions,
83+
config: ConfigStore,
84+
nested_configs: FxHashMap<PathBuf, ConfigStore>,
85+
) -> Self {
86+
Self { options, config, nested_configs }
7087
}
7188

7289
/// Set the kind of auto fixes to apply.
@@ -99,8 +116,15 @@ impl Linter {
99116
semantic: Rc<Semantic<'a>>,
100117
module_record: Arc<ModuleRecord>,
101118
) -> Vec<Message<'a>> {
102-
// Get config + rules for this file. Takes base rules and applies glob-based overrides.
103-
let ResolvedLinterState { rules, config } = self.config.resolve(path);
119+
// TODO(refactor): remove branch when nested config is standardized
120+
let ResolvedLinterState { rules, config } = if self.nested_configs.is_empty() {
121+
// Get config + rules for this file. Takes base rules and applies glob-based overrides.
122+
self.config.resolve(path)
123+
} else if let Some(nearest_config) = self.get_nearest_config(path) {
124+
nearest_config.resolve(path)
125+
} else {
126+
self.config.resolve(path)
127+
};
104128
let ctx_host =
105129
Rc::new(ContextHost::new(path, semantic, module_record, self.options, config));
106130

@@ -181,6 +205,22 @@ impl Linter {
181205

182206
ctx_host.take_diagnostics()
183207
}
208+
209+
/// Get the nearest config for the given path, in the following priority order:
210+
/// 1. config file in the same directory as the path
211+
/// 2. config file in the closest parent directory
212+
fn get_nearest_config(&self, path: &Path) -> Option<&ConfigStore> {
213+
// TODO(perf): should we cache the computed nearest config for every directory,
214+
// so we don't have to recompute it for every file?
215+
let mut current = path.parent();
216+
while let Some(dir) = current {
217+
if let Some(config_store) = self.nested_configs.get(dir) {
218+
return Some(config_store);
219+
}
220+
current = dir.parent();
221+
}
222+
None
223+
}
184224
}
185225

186226
#[cfg(test)]

0 commit comments

Comments
 (0)
Please sign in to comment.