Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"categories": {
"correctness": "off"
},
"rules": {
"no-debugger": "error"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
debugger;
4 changes: 2 additions & 2 deletions apps/oxlint/src/command/lint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,12 @@ impl LintCommand {
#[derive(Debug, Clone, Bpaf)]
pub struct BasicOptions {
/// Oxlint configuration file
/// * `.json` config files are supported in all runtimes
/// * `.json` and `.jsonc` config files are supported in all runtimes
/// * JavaScript/TypeScript config files are experimental and require running via Node.js
/// * you can use comments in configuration files.
/// * tries to be compatible with ESLint v8's format
///
/// If not provided, Oxlint will look for a `.oxlintrc.json` or `oxlint.config.ts` file in the current working directory.
/// If not provided, Oxlint will look for a `.oxlintrc.json`, `.oxlintrc.jsonc`, or `oxlint.config.ts` file in the current working directory.
#[bpaf(long, short, argument("./.oxlintrc.json"))]
pub config: Option<PathBuf>,

Expand Down
162 changes: 143 additions & 19 deletions apps/oxlint/src/config_loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ use oxc_linter::{
};
use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet};

use crate::{DEFAULT_OXLINTRC_NAME, DEFAULT_TS_OXLINTRC_NAME};
use crate::{DEFAULT_JSONC_OXLINTRC_NAME, DEFAULT_OXLINTRC_NAME, DEFAULT_TS_OXLINTRC_NAME};

#[cfg(feature = "napi")]
use crate::js_config;

#[derive(Debug, Hash, PartialEq, Eq)]
pub enum DiscoveredConfig {
Json(PathBuf),
Jsonc(PathBuf),
Js(PathBuf),
}

Expand All @@ -30,7 +31,7 @@ pub enum DiscoveredConfig {
///
/// Example: For files `/project/src/foo.js` and `/project/src/bar/baz.js`:
/// - Checks `/project/src/bar/`, `/project/src/`, `/project/`, `/`
/// - Returns paths to any `.oxlintrc.json` files found
/// - Returns paths to any `.oxlintrc.json`, `.oxlintrc.jsonc`, or `oxlint.config.ts` files found
pub fn discover_configs_in_ancestors<P: AsRef<Path>>(
files: &[P],
) -> impl IntoIterator<Item = DiscoveredConfig> {
Expand Down Expand Up @@ -86,6 +87,10 @@ fn find_configs_in_directory(dir: &Path) -> Vec<DiscoveredConfig> {
if json_path.is_file() {
configs.push(DiscoveredConfig::Json(json_path));
}
let jsonc_path = dir.join(DEFAULT_JSONC_OXLINTRC_NAME);
if jsonc_path.is_file() {
configs.push(DiscoveredConfig::Jsonc(jsonc_path));
}

let ts_path = dir.join(DEFAULT_TS_OXLINTRC_NAME);
if ts_path.is_file() {
Expand Down Expand Up @@ -140,6 +145,8 @@ fn to_discovered_config(entry: &DirEntry) -> Option<DiscoveredConfig> {
let file_name = entry.path().file_name()?;
if file_name == DEFAULT_OXLINTRC_NAME {
Some(DiscoveredConfig::Json(entry.path().to_path_buf()))
} else if file_name == DEFAULT_JSONC_OXLINTRC_NAME {
Some(DiscoveredConfig::Jsonc(entry.path().to_path_buf()))
} else if file_name == DEFAULT_TS_OXLINTRC_NAME {
Some(DiscoveredConfig::Js(entry.path().to_path_buf()))
} else {
Expand Down Expand Up @@ -303,7 +310,8 @@ impl<'a> ConfigLoader<'a> {
let mut configs = Vec::new();
let mut errors = Vec::new();

let mut by_dir = FxHashMap::<PathBuf, (Option<PathBuf>, Option<PathBuf>)>::default();
let mut by_dir =
FxHashMap::<PathBuf, (Option<PathBuf>, Option<PathBuf>, Option<PathBuf>)>::default();

for config in paths {
match config {
Expand All @@ -313,24 +321,38 @@ impl<'a> ConfigLoader<'a> {
};
by_dir.entry(dir).or_default().0 = Some(path);
}
DiscoveredConfig::Js(path) => {
DiscoveredConfig::Jsonc(path) => {
let Some(dir) = path.parent().map(Path::to_path_buf) else {
continue;
};
by_dir.entry(dir).or_default().1 = Some(path);
}
DiscoveredConfig::Js(path) => {
let Some(dir) = path.parent().map(Path::to_path_buf) else {
continue;
};
by_dir.entry(dir).or_default().2 = Some(path);
}
}
}

let mut js_configs = Vec::new();

for (dir, (json_path, ts_path)) in by_dir {
if json_path.is_some() && ts_path.is_some() {
errors.push(ConfigLoadError::Diagnostic(config_conflict_diagnostic(&dir)));
for (dir, (json_path, jsonc_path, ts_path)) in by_dir {
let config_count = usize::from(json_path.is_some())
+ usize::from(jsonc_path.is_some())
+ usize::from(ts_path.is_some());
if config_count > 1 {
errors.push(ConfigLoadError::Diagnostic(config_conflict_diagnostic(
&dir,
json_path.is_some(),
jsonc_path.is_some(),
ts_path.is_some(),
)));
continue;
}

if let Some(path) = json_path {
if let Some(path) = json_path.or(jsonc_path) {
match Self::load(&path) {
Ok(config) => configs.push(config),
Err(e) => errors.push(e),
Expand Down Expand Up @@ -441,13 +463,17 @@ impl<'a> ConfigLoader<'a> {
/// Returns `Ok(Some(config))` if found, `Ok(None)` if not found, or `Err` on error.
fn try_load_config_from_dir(&self, dir: &Path) -> Result<Option<Oxlintrc>, OxcDiagnostic> {
let json_path = dir.join(DEFAULT_OXLINTRC_NAME);
let jsonc_path = dir.join(DEFAULT_JSONC_OXLINTRC_NAME);
let ts_path = dir.join(DEFAULT_TS_OXLINTRC_NAME);

let json_exists = json_path.is_file();
let jsonc_exists = jsonc_path.is_file();
let ts_exists = ts_path.is_file();

if json_exists && ts_exists {
return Err(config_conflict_diagnostic(dir));
let config_count =
usize::from(json_exists) + usize::from(jsonc_exists) + usize::from(ts_exists);
if config_count > 1 {
return Err(config_conflict_diagnostic(dir, json_exists, jsonc_exists, ts_exists));
}

if ts_exists {
Expand All @@ -457,6 +483,9 @@ impl<'a> ConfigLoader<'a> {
if json_exists {
return Oxlintrc::from_file(&json_path).map(Some);
}
if jsonc_exists {
return Oxlintrc::from_file(&jsonc_path).map(Some);
}

Ok(None)
}
Expand Down Expand Up @@ -625,14 +654,44 @@ pub fn build_nested_configs(
nested_configs
}

fn config_conflict_diagnostic(dir: &Path) -> OxcDiagnostic {
OxcDiagnostic::error(format!(
"Both '{}' and '{}' found in {}.",
DEFAULT_OXLINTRC_NAME,
DEFAULT_TS_OXLINTRC_NAME,
dir.display()
))
.with_note("Only `.oxlintrc.json` or `oxlint.config.ts` are allowed, not both.")
fn config_conflict_diagnostic(
dir: &Path,
has_json: bool,
has_jsonc: bool,
has_ts: bool,
) -> OxcDiagnostic {
fn format_conflicting_config_names(config_names: &[&str]) -> String {
debug_assert!(config_names.len() > 1);

let mut quoted_names =
config_names.iter().map(|name| format!("'{name}'")).collect::<Vec<_>>();
if quoted_names.len() == 2 {
return format!("{} and {}", quoted_names[0], quoted_names[1]);
}

let last = quoted_names.pop().unwrap();
format!("{}, and {last}", quoted_names.join(", "))
}
let mut config_names = Vec::with_capacity(3);
if has_json {
config_names.push(DEFAULT_OXLINTRC_NAME);
}
if has_jsonc {
config_names.push(DEFAULT_JSONC_OXLINTRC_NAME);
}
if has_ts {
config_names.push(DEFAULT_TS_OXLINTRC_NAME);
}

let config_list = format_conflicting_config_names(&config_names);
let message = if config_names.len() == 2 {
format!("Both {config_list} found in {}.", dir.display())
} else {
format!("Multiple config files found in {}: {config_list}.", dir.display())
};

OxcDiagnostic::error(message)
.with_note("Only one of `.oxlintrc.json`, `.oxlintrc.jsonc`, or `oxlint.config.ts` is allowed per directory.")
.with_help("Delete one of the configuration files.")
}

Expand All @@ -641,7 +700,7 @@ fn js_config_not_supported_diagnostic(path: &Path) -> OxcDiagnostic {
"JavaScript/TypeScript config file ({}) found but JS runtime not available.",
path.display()
))
.with_help("Run oxlint via the npm package, or use JSON config files (.oxlintrc.json).")
.with_help("Run oxlint via the npm package, or use JSON config files (.oxlintrc.json or .oxlintrc.jsonc).")
}

fn is_js_config_path(path: &Path) -> bool {
Expand Down Expand Up @@ -1055,4 +1114,69 @@ mod test {
assert_eq!(errors.len(), 1);
assert!(matches!(errors[0], ConfigLoadError::Diagnostic(_)));
}

#[test]
fn test_jsonc_config_discovery() {
let root_dir = tempfile::tempdir().unwrap();
// Create only a .oxlintrc.jsonc file
std::fs::write(root_dir.path().join(".oxlintrc.jsonc"), r#"{ /* comment */ "rules": {} }"#)
.unwrap();

let mut external_plugin_store = ExternalPluginStore::new(false);
let loader = ConfigLoader::new(None, &mut external_plugin_store, &[], None);

let result = loader.load_root_config(root_dir.path(), None);
assert!(result.is_ok(), "Expected .oxlintrc.jsonc to be discovered and loaded");
let config = result.unwrap();
assert!(
config.path.to_string_lossy().ends_with(".oxlintrc.jsonc"),
"Expected config path to end with .oxlintrc.jsonc, got: {}",
config.path.display()
);
}

#[test]
fn test_json_and_jsonc_conflict() {
let root_dir = tempfile::tempdir().unwrap();
// Create both .oxlintrc.json and .oxlintrc.jsonc
std::fs::write(root_dir.path().join(".oxlintrc.json"), r#"{ "rules": {} }"#).unwrap();
std::fs::write(root_dir.path().join(".oxlintrc.jsonc"), r#"{ /* comment */ "rules": {} }"#)
.unwrap();

let mut external_plugin_store = ExternalPluginStore::new(false);
let loader = ConfigLoader::new(None, &mut external_plugin_store, &[], None);

let result = loader.load_root_config(root_dir.path(), None);
assert!(
result.is_err(),
"Expected an error when both .oxlintrc.json and .oxlintrc.jsonc exist"
);
}

#[test]
fn test_json_and_ts_conflict() {
let root_dir = tempfile::tempdir().unwrap();
std::fs::write(root_dir.path().join(".oxlintrc.json"), r#"{ "rules": {} }"#).unwrap();
std::fs::write(root_dir.path().join("oxlint.config.ts"), "export default {};").unwrap();

let mut external_plugin_store = ExternalPluginStore::new(false);
let loader = ConfigLoader::new(None, &mut external_plugin_store, &[], None);

let result = loader.load_root_config(root_dir.path(), None);
assert!(result.is_err(), "Expected an error when both JSON and TS configs exist");
}

#[test]
fn test_jsonc_and_ts_conflict() {
let root_dir = tempfile::tempdir().unwrap();
std::fs::write(root_dir.path().join(".oxlintrc.jsonc"), r#"{ /* comment */ "rules": {} }"#)
.unwrap();
std::fs::write(root_dir.path().join("oxlint.config.ts"), "export default {};").unwrap();

let mut external_plugin_store = ExternalPluginStore::new(false);
let loader = ConfigLoader::new(None, &mut external_plugin_store, &[], None);

let result = loader.load_root_config(root_dir.path(), None);
assert!(result.is_err(), "Expected an error when both JSONC and TS configs exist");
}
}
1 change: 1 addition & 0 deletions apps/oxlint/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ mod js_plugins;
static GLOBAL: mimalloc_safe::MiMalloc = mimalloc_safe::MiMalloc;

const DEFAULT_OXLINTRC_NAME: &str = ".oxlintrc.json";
const DEFAULT_JSONC_OXLINTRC_NAME: &str = ".oxlintrc.jsonc";
const DEFAULT_TS_OXLINTRC_NAME: &str = "oxlint.config.ts";

/// Return a JSON blob containing metadata for all available oxlint rules.
Expand Down
10 changes: 9 additions & 1 deletion apps/oxlint/src/lint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ impl CliRunner {
ConfigLoadError::JsConfigFileFoundButJsRuntimeNotAvailable => {
"Error: JavaScript/TypeScript config files found but JS runtime not available.\n\
This is an experimental feature that requires running oxlint via Node.js.\n\
Please use JSON config files (.oxlintrc.json) instead, or run oxlint via the npm package.\n".to_string()
Please use JSON config files (.oxlintrc.json or .oxlintrc.jsonc) instead, or run oxlint via the npm package.\n".to_string()
}
ConfigLoadError::Diagnostic(error) => {
let report = render_report(&handler, error);
Expand Down Expand Up @@ -729,6 +729,14 @@ mod test {
Tester::new().with_cwd("fixtures/auto_config_detection".into()).test_and_snapshot(args);
}

#[test]
fn oxlint_config_auto_detection_jsonc() {
let args = &["debugger.js"];
Tester::new()
.with_cwd("fixtures/auto_config_detection_jsonc".into())
.test_and_snapshot(args);
}

#[test]
#[cfg(not(target_os = "windows"))] // Skipped on Windows due to snapshot diffs from path separators (`/` vs `\`)
fn oxlint_config_auto_detection_parse_error() {
Expand Down
Loading
Loading