diff --git a/apps/oxlint/src-js/js_config.ts b/apps/oxlint/src-js/js_config.ts index d6d49e12838b3..63628d744b500 100644 --- a/apps/oxlint/src-js/js_config.ts +++ b/apps/oxlint/src-js/js_config.ts @@ -1,12 +1,17 @@ +import { basename as pathBasename } from "node:path"; + import { getErrorMessage } from "./utils/utils.ts"; import { isDefineConfig } from "./package/config.ts"; import { DateNow, JSONStringify } from "./utils/globals.ts"; interface JsConfigResult { path: string; - config: unknown; // Will be validated as Oxlintrc on Rust side + config: unknown; // Will be validated as Oxlintrc on Rust side, `null` means "skip this config" } +const VITE_CONFIG_NAME = "vite.config.ts"; +const VITE_OXLINT_CONFIG_FIELD = "lint"; + type LoadJsConfigsResult = | { Success: JsConfigResult[] } | { Failures: { path: string; error: string }[] } @@ -105,19 +110,29 @@ export async function loadJsConfigs(paths: string[]): Promise { throw new Error(`Configuration file must have a default export that is an object.`); } - // Vite config files (e.g. `vite.config.ts`) are not Oxlint configs, - // so skip `defineConfig()` and `extends` validation. - // The `.lint` field extraction is handled on the Rust side. - if (!path.endsWith("/vite.config.ts")) { - if (!isDefineConfig(config)) { + // Vite config: extract `.lint` field, skip `defineConfig()` validation + if (pathBasename(path) === VITE_CONFIG_NAME) { + const lintConfig = (config as Record)[VITE_OXLINT_CONFIG_FIELD]; + // NOTE: return `null` if `.lint` is missing which signals "skip" this + if (lintConfig === undefined) { + return { path, config: null }; + } + + if (typeof lintConfig !== "object" || lintConfig === null || Array.isArray(lintConfig)) { throw new Error( - `Configuration file must wrap its default export with defineConfig() from "oxlint".`, + `The \`${VITE_OXLINT_CONFIG_FIELD}\` field in the default export must be an object.`, ); } - - validateConfigExtends(config as object); + validateConfigExtends(lintConfig as object); + return { path, config: lintConfig }; } + if (!isDefineConfig(config)) { + throw new Error( + `Configuration file must wrap its default export with defineConfig() from "oxlint".`, + ); + } + validateConfigExtends(config as object); return { path, config }; }), ); diff --git a/apps/oxlint/src/config_loader.rs b/apps/oxlint/src/config_loader.rs index 60ff1d88294d2..3deaf84dea636 100644 --- a/apps/oxlint/src/config_loader.rs +++ b/apps/oxlint/src/config_loader.rs @@ -14,11 +14,11 @@ use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet}; use crate::{ DEFAULT_JSONC_OXLINTRC_NAME, DEFAULT_OXLINTRC_NAME, DEFAULT_TS_OXLINTRC_NAME, VITE_CONFIG_NAME, - VITE_OXLINT_CONFIG_FIELD, }; #[cfg(feature = "napi")] use crate::js_config; +use crate::js_config::JsConfigResult; #[derive(Debug, Hash, PartialEq, Eq)] pub enum DiscoveredConfig { @@ -276,7 +276,7 @@ impl<'a> ConfigLoader<'a> { pub fn load_js_configs( &self, paths: &[PathBuf], - ) -> Result, Vec> { + ) -> Result, Vec> { if paths.is_empty() { return Ok(Vec::new()); } @@ -295,7 +295,7 @@ impl<'a> ConfigLoader<'a> { paths.iter().map(|p| p.to_string_lossy().to_string()).collect(); match js_config_loader(paths_as_strings) { - Ok(results) => Ok(results.into_iter().map(|c| c.config).collect()), + Ok(results) => Ok(results), Err(diagnostics) => { Err(diagnostics.into_iter().map(ConfigLoadError::Diagnostic).collect()) } @@ -368,8 +368,8 @@ impl<'a> ConfigLoader<'a> { } match self.load_js_configs(&js_configs) { - Ok(mut loaded_js_configs) => { - configs.append(&mut loaded_js_configs); + Ok(loaded_js_configs) => { + configs.extend(loaded_js_configs.into_iter().filter_map(|c| c.config)); } Err(mut js_errors) => { errors.append(&mut js_errors); @@ -480,7 +480,11 @@ impl<'a> ConfigLoader<'a> { } if ts_exists { - return self.load_root_js_config(&ts_path).map(Some); + let config = self.load_root_js_config(&ts_path)?; + // `None` is only returned for vite.config.ts without `.lint` field, + // so `oxlint.config.ts` always returns `Some` here. + debug_assert!(config.is_some(), "oxlint.config.ts should always return a config"); + return Ok(config); } if json_exists { @@ -491,12 +495,10 @@ impl<'a> ConfigLoader<'a> { } // Fallback: check for vite.config.ts with .lint field (lowest priority) - // If .lint field is missing, skip it and continue config search. + // If .lint field is missing, `load_root_js_config` returns `Ok(None)` to skip. let vite_config_path = dir.join(VITE_CONFIG_NAME); - if vite_config_path.is_file() - && let Some(config) = self.try_load_root_vite_config(&vite_config_path)? - { - return Ok(Some(config)); + if vite_config_path.is_file() { + return self.load_root_js_config(&vite_config_path); } Ok(None) @@ -508,11 +510,7 @@ impl<'a> ConfigLoader<'a> { config_path: Option<&PathBuf>, ) -> Result { if let Some(config_path) = config_path { - let full_path = cwd.join(config_path); - if is_js_config_path(&full_path) { - return self.load_root_js_config(&full_path); - } - return Oxlintrc::from_file(&full_path); + return self.load_explicit_config(cwd, config_path); } match self.try_load_config_from_dir(cwd)? { @@ -539,11 +537,7 @@ impl<'a> ConfigLoader<'a> { ) -> Result { // If an explicit config path is provided, use it directly if let Some(config_path) = config_path { - let full_path = cwd.join(config_path); - if is_js_config_path(&full_path) { - return self.load_root_js_config(&full_path); - } - return Oxlintrc::from_file(&full_path); + return self.load_explicit_config(cwd, config_path); } // Search up the directory tree for a config file @@ -560,31 +554,30 @@ impl<'a> ConfigLoader<'a> { Ok(Oxlintrc::default()) } - /// Try to load vite.config.ts, returning `Ok(None)` if `.lint` field is missing. - /// Other errors (e.g., JS runtime not available, parse errors) are propagated. - fn try_load_root_vite_config(&self, path: &Path) -> Result, OxcDiagnostic> { - match self.load_root_js_config(path) { - Ok(config) => Ok(Some(config)), - Err(diagnostic) => { - let msg = diagnostic.message.to_string(); - // NOTE: This relies on matching the error message from `parse_js_config_response` in js_config.rs. - // If that message changes, this match must be updated accordingly. - if msg.contains(&format!("Expected a `{VITE_OXLINT_CONFIG_FIELD}` field")) { - tracing::debug!( - "Skipping {} (no `{VITE_OXLINT_CONFIG_FIELD}` field), continuing config search...", - path.display(), - ); - Ok(None) - } else { - Err(diagnostic) - } - } + /// Load an explicitly specified config file (via `--config`). + /// For JS/TS configs, `None` from JS side (e.g., vite.config.ts without `.lint`) is an error. + fn load_explicit_config( + &self, + cwd: &Path, + config_path: &Path, + ) -> Result { + let full_path = cwd.join(config_path); + if is_js_config_path(&full_path) { + return self.load_root_js_config(&full_path)?.ok_or_else(|| { + OxcDiagnostic::error(format!( + "Expected a `lint` field in the default export of {}", + full_path.display() + )) + }); } + Oxlintrc::from_file(&full_path) } - fn load_root_js_config(&self, path: &Path) -> Result { + /// Load a single JS/TS config file. Returns `Ok(None)` when JS side signals "skip" + /// (e.g., vite.config.ts without `.lint` field). + fn load_root_js_config(&self, path: &Path) -> Result, OxcDiagnostic> { match self.load_js_configs(&[path.to_path_buf()]) { - Ok(mut configs) => Ok(configs.pop().unwrap_or_default()), + Ok(mut results) => Ok(results.pop().and_then(|r| r.config)), Err(errors) => { if let Some(first) = errors.into_iter().next() { match first { @@ -819,7 +812,7 @@ mod test { if let Some(config_dir) = path.parent() { config.set_config_dir(config_dir); } - JsConfigResult { path, config } + JsConfigResult { path, config: Some(config) } } #[test] @@ -1066,9 +1059,9 @@ mod test { .into_iter() .map(|path| { let path = PathBuf::from(path); - let mut config = make_js_config(path.clone(), None, None).config; + let mut config = make_js_config(path.clone(), None, None).config.unwrap(); config.options.deny_warnings = Some(true); - JsConfigResult { path, config } + JsConfigResult { path, config: Some(config) } }) .collect()) }); @@ -1096,14 +1089,14 @@ mod test { .into_iter() .map(|path| { let path = PathBuf::from(path); - let mut config = make_js_config(path.clone(), None, None).config; + let mut config = make_js_config(path.clone(), None, None).config.unwrap(); config.extends_configs = vec![ serde_json::from_value( serde_json::json!({ "options": { "typeAware": true } }), ) .unwrap(), ]; - JsConfigResult { path, config } + JsConfigResult { path, config: Some(config) } }) .collect()) }); @@ -1131,14 +1124,14 @@ mod test { .into_iter() .map(|path| { let path = PathBuf::from(path); - let mut config = make_js_config(path.clone(), None, None).config; + let mut config = make_js_config(path.clone(), None, None).config.unwrap(); config.extends_configs = vec![ serde_json::from_value( serde_json::json!({ "options": { "typeCheck": true } }), ) .unwrap(), ]; - JsConfigResult { path, config } + JsConfigResult { path, config: Some(config) } }) .collect()) }); diff --git a/apps/oxlint/src/js_config.rs b/apps/oxlint/src/js_config.rs index 722011241403c..32f02690eb357 100644 --- a/apps/oxlint/src/js_config.rs +++ b/apps/oxlint/src/js_config.rs @@ -5,17 +5,17 @@ use oxc_diagnostics::OxcDiagnostic; use oxc_linter::Oxlintrc; use crate::run::JsLoadJsConfigsCb; -use crate::{VITE_CONFIG_NAME, VITE_OXLINT_CONFIG_FIELD}; /// Callback type for loading JavaScript/TypeScript config files. pub type JsConfigLoaderCb = Box) -> Result, Vec> + Send + Sync>; /// Result of loading a single JavaScript/TypeScript config file. +/// `config` is `None` when the JS side signals "skip" (e.g., vite.config.ts without `.lint` field). #[derive(Debug, Clone)] pub struct JsConfigResult { pub path: PathBuf, - pub config: Oxlintrc, + pub config: Option, } /// Response from JS side when loading JS configs. @@ -120,28 +120,14 @@ fn parse_js_config_response(json: &str) -> Result, Vec config, Err(err) => { errors.push( @@ -165,7 +151,7 @@ fn parse_js_config_response(json: &str) -> Result, Vec