Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/oxlint/src/command/lint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,15 @@ impl LintCommand {
}
}

const CONFIG_FIELD_ERROR_MESSAGE: &str = "--config-field requires --config to be specified";

fn validate_basic_options(opts: &BasicOptions) -> bool {
opts.config_field.is_none() || opts.config.is_some()
}

/// Basic Configuration
#[derive(Debug, Clone, Bpaf)]
#[bpaf(guard(validate_basic_options, CONFIG_FIELD_ERROR_MESSAGE))]
pub struct BasicOptions {
/// Oxlint configuration file
/// * `.json` and `.jsonc` config files are supported in all runtimes
Expand All @@ -123,6 +130,10 @@ pub struct BasicOptions {
#[bpaf(long, short, argument("./.oxlintrc.json"))]
pub config: Option<PathBuf>,

/// Top-level key to extract from the config object (requires --config)
#[bpaf(long, argument("KEY"))]
pub config_field: Option<String>,

/// TypeScript `tsconfig.json` path for reading path alias and project references for import plugin.
/// If not provided, will look for `tsconfig.json` in the current working directory.
#[bpaf(argument("./tsconfig.json"), hide_usage)]
Expand Down
184 changes: 121 additions & 63 deletions apps/oxlint/src/config_loader.rs

Large diffs are not rendered by default.

76 changes: 31 additions & 45 deletions apps/oxlint/src/js_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,19 @@ use oxc_linter::Oxlintrc;
use crate::run::JsLoadJsConfigsCb;

/// Callback type for loading JavaScript/TypeScript config files.
///
/// Returns raw `serde_json::Value` per config file. The caller is responsible
/// for any field extraction (`--config-field`) and deserialization into `Oxlintrc`.
pub type JsConfigLoaderCb =
Box<dyn Fn(Vec<String>) -> Result<Vec<JsConfigResult>, Vec<OxcDiagnostic>> + Send + Sync>;
Box<dyn Fn(Vec<String>) -> Result<Vec<JsRawConfigResult>, Vec<OxcDiagnostic>> + Send + Sync>;

/// Result of loading a single JavaScript/TypeScript config file.
///
/// Contains the raw JSON value before deserialization into `Oxlintrc`.
#[derive(Debug, Clone)]
pub struct JsConfigResult {
pub struct JsRawConfigResult {
pub path: PathBuf,
pub config: Oxlintrc,
pub value: serde_json::Value,
}

/// Response from JS side when loading JS configs.
Expand Down Expand Up @@ -68,7 +73,14 @@ pub fn create_js_config_loader(cb: JsLoadJsConfigsCb) -> JsConfigLoaderCb {
})
}

fn parse_js_oxlintrc(mut value: serde_json::Value) -> Result<Oxlintrc, OxcDiagnostic> {
/// Deserialize a raw JS config `serde_json::Value` into `Oxlintrc`.
///
/// Handles the JS-config-specific `extends` field (which contains inline config
/// objects rather than file paths).
pub(crate) fn deserialize_js_config(
mut value: serde_json::Value,
path: &Path,
) -> Result<Oxlintrc, OxcDiagnostic> {
let Some(map) = value.as_object_mut() else {
return Err(OxcDiagnostic::error(
"Configuration file must have a default export that is an object.",
Expand All @@ -92,7 +104,7 @@ fn parse_js_oxlintrc(mut value: serde_json::Value) -> Result<Oxlintrc, OxcDiagno
"`extends[{idx}]` must be a config object (strings/paths are not supported).",
)));
}
parse_js_oxlintrc(item)
deserialize_js_config(item, path)
})
.collect::<Result<Vec<_>, _>>()?
} else {
Expand All @@ -102,54 +114,28 @@ fn parse_js_oxlintrc(mut value: serde_json::Value) -> Result<Oxlintrc, OxcDiagno
let mut oxlintrc: Oxlintrc =
serde_json::from_value(value).map_err(|err| OxcDiagnostic::error(err.to_string()))?;
oxlintrc.extends_configs = extends_configs;

oxlintrc.path = path.to_path_buf();
if let Some(config_dir) = path.parent() {
oxlintrc.set_config_dir(&config_dir.to_path_buf());
}

Ok(oxlintrc)
}

/// Parse the JSON response from JS side into `JsConfigResult` structs.
fn parse_js_config_response(json: &str) -> Result<Vec<JsConfigResult>, Vec<OxcDiagnostic>> {
use std::path::Path;

/// Parse the JSON response from JS side into raw config results.
fn parse_js_config_response(json: &str) -> Result<Vec<JsRawConfigResult>, Vec<OxcDiagnostic>> {
let response: LoadJsConfigsResponse = serde_json::from_str(json).map_err(|e| {
vec![OxcDiagnostic::error(format!("Failed to parse JS config response: {e}"))]
})?;

match response {
LoadJsConfigsResponse::Success { success } => {
let count = success.len();
let (configs, errors) = success.into_iter().fold(
(Vec::with_capacity(count), Vec::new()),
|(mut configs, mut errors), entry| {
let path = PathBuf::from(&entry.path);
let mut oxlintrc = match parse_js_oxlintrc(entry.config) {
Ok(config) => config,
Err(err) => {
errors.push(
OxcDiagnostic::error(format!(
"Failed to parse config from {}",
entry.path
))
.with_note(err.to_string()),
);
return (configs, errors);
}
};
oxlintrc.path.clone_from(&path);

let Some(config_dir_parent) = oxlintrc.path.parent() else {
errors.push(OxcDiagnostic::error(format!(
"Config path has no parent directory: {}",
entry.path
)));
return (configs, errors);
};
let config_dir = config_dir_parent.to_path_buf();
oxlintrc.set_config_dir(&config_dir);
configs.push(JsConfigResult { path, config: oxlintrc });

(configs, errors)
},
);

if errors.is_empty() { Ok(configs) } else { Err(errors) }
}
LoadJsConfigsResponse::Success { success } => Ok(success
.into_iter()
.map(|entry| JsRawConfigResult { path: PathBuf::from(entry.path), value: entry.config })
.collect()),
LoadJsConfigsResponse::Failure { failures } => Err(failures
.into_iter()
.map(|failure| {
Expand Down
1 change: 1 addition & 0 deletions apps/oxlint/src/lint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ impl CliRunner {
config_loader.load_root_and_nested(
&self.cwd,
basic_options.config.as_ref(),
basic_options.config_field.as_deref(),
&paths,
search_for_nested_configs,
)
Expand Down
5 changes: 5 additions & 0 deletions apps/oxlint/src/lsp/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ pub struct LintOptions {
#[serde(skip_serializing_if = "Option::is_none")]
pub config_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub config_field: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ts_config_path: Option<String>,
pub unused_disable_directives: Option<UnusedDisableDirectives>,
pub type_aware: Option<bool>,
Expand Down Expand Up @@ -109,6 +111,9 @@ impl TryFrom<Value> for LintOptions {
config_path: object
.get("configPath")
.and_then(|config_path| serde_json::from_value::<String>(config_path.clone()).ok()),
config_field: object.get("configField").and_then(|config_field| {
serde_json::from_value::<String>(config_field.clone()).ok()
}),
ts_config_path: object
.get("tsConfigPath")
.and_then(|config_path| serde_json::from_value::<String>(config_path.clone()).ok()),
Expand Down
21 changes: 13 additions & 8 deletions apps/oxlint/src/lsp/server_linter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ impl ServerLinterBuilder {
};

let config_path = options.config_path.as_ref().filter(|p| !p.is_empty()).map(PathBuf::from);
let config_field = options.config_field.as_deref().filter(|s| !s.is_empty());
let loader = ConfigLoader::new(
external_linter,
&mut external_plugin_store,
Expand All @@ -114,14 +115,17 @@ impl ServerLinterBuilder {
#[cfg(feature = "napi")]
let loader = loader.with_js_config_loader(self.js_config_loader.as_ref());

let oxlintrc =
match loader.load_root_config_with_ancestor_search(&root_path, config_path.as_ref()) {
Ok(config) => config,
Err(e) => {
warn!("Failed to load config: {e}");
Oxlintrc::default()
}
};
let oxlintrc = match loader.load_root_config_with_ancestor_search(
&root_path,
config_path.as_ref(),
config_field,
) {
Ok(config) => config,
Err(e) => {
warn!("Failed to load config: {e}");
Oxlintrc::default()
}
};

let base_patterns = oxlintrc.ignore_patterns.clone();

Expand Down Expand Up @@ -845,6 +849,7 @@ impl ServerLinter {

fn needs_restart(old_options: &LSPLintOptions, new_options: &LSPLintOptions) -> bool {
old_options.config_path != new_options.config_path
|| old_options.config_field != new_options.config_field
|| old_options.ts_config_path != new_options.ts_config_path
|| old_options.use_nested_configs() != new_options.use_nested_configs()
|| old_options.fix_kind != new_options.fix_kind
Expand Down
4 changes: 4 additions & 0 deletions crates/oxc_language_server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ These options can be passed with [initialize](#initialize), [workspace/didChange
| Option Key | Value(s) | Default | Description |
| ------------------------- | --------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `configPath` | `<string>` \| `null` | `null` | Path to a oxlint configuration file, passing a string will disable nested configuration |
| `configField` | `<string>` \| `null` | `null` | Top-level key to extract from the config object (requires `configPath`) |
| `tsConfigPath` | `<string>` \| `null` | `null` | Path to a TypeScript configuration file. If your `tsconfig.json` is not at the root, alias paths will not be resolve correctly for the `import` plugin |
| `unusedDisableDirectives` | `"allow" \| "warn"` \| "deny"` | `"allow"` | Define how directive comments like `// oxlint-disable-line` should be reported, when no errors would have been reported on that line anyway |
| `typeAware` | `<boolean>` \| `null` | `null` | Enables type-aware linting. When unset (`null`), uses the root config's `options.typeAware` value. |
Expand Down Expand Up @@ -72,6 +73,7 @@ The client can pass the workspace options like following:
"options": {
"run": "onType",
"configPath": null,
"configField": null,
"tsConfigPath": null,
"unusedDisableDirectives": "allow",
"typeAware": false,
Expand Down Expand Up @@ -112,6 +114,7 @@ The client can pass the workspace options like following:
"options": {
"run": "onType",
"configPath": null,
"configField": null,
"tsConfigPath": null,
"unusedDisableDirectives": "allow",
"typeAware": false,
Expand Down Expand Up @@ -225,6 +228,7 @@ The client can return a response like:
{
"run": "onType",
"configPath": null,
"configField": null,
"tsConfigPath": null,
"unusedDisableDirectives": "allow",
"typeAware": false,
Expand Down
30 changes: 28 additions & 2 deletions crates/oxc_linter/src/config/oxlintrc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,22 @@ impl Oxlintrc {
///
/// * Parse Failure
pub fn from_file(path: &Path) -> Result<Self, OxcDiagnostic> {
let json = Self::read_to_json_value(path)?;
Self::from_value(json, path)
}

/// Read a JSON/JSONC config file and return the parsed `serde_json::Value`.
///
/// This handles reading the file, stripping JSONC comments, and parsing to JSON.
/// Useful when you need to pre-process the JSON value before deserializing
/// (e.g., extracting a specific field via `--config-field`).
///
/// # Errors
///
/// * File read failure
/// * JSONC comment stripping failure
/// * JSON parse failure
pub fn read_to_json_value(path: &Path) -> Result<serde_json::Value, OxcDiagnostic> {
let mut string = read_to_string(path).map_err(|e| {
OxcDiagnostic::error(format!(
"Failed to parse config {} with error {e:?}",
Expand All @@ -284,7 +300,7 @@ impl Oxlintrc {
OxcDiagnostic::error(format!("Failed to parse jsonc file {}: {err:?}", path.display()))
})?;

let json = serde_json::from_str::<serde_json::Value>(&string).map_err(|err| {
serde_json::from_str::<serde_json::Value>(&string).map_err(|err| {
let ext = path.extension().and_then(OsStr::to_str);
let err = match ext {
// syntax error
Expand All @@ -300,8 +316,18 @@ impl Oxlintrc {
"Failed to parse oxlint config {}.\n{err}",
path.display()
))
})?;
})
}

/// Create an `Oxlintrc` from a pre-parsed JSON value and a file path.
///
/// This is useful when the JSON value has been pre-processed (e.g., extracting
/// a specific field via `--config-field`).
///
/// # Errors
///
/// * Parse Failure
pub fn from_value(json: serde_json::Value, path: &Path) -> Result<Self, OxcDiagnostic> {
let mut config = Self::deserialize(&json).map_err(|err| {
OxcDiagnostic::error(format!("Failed to parse config with error {err:?}"))
})?;
Expand Down
4 changes: 3 additions & 1 deletion tasks/website_linter/src/snapshots/cli.snap
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ search: false


## Usage
**`oxlint`** \[**`-c`**=_`<./.oxlintrc.json>`_\] \[_`PATH`_\]...
**`oxlint`** \[**`-c`**=_`<./.oxlintrc.json>`_\] \[**`--config-field`**=_`KEY`_\] \[_`PATH`_\]...

## Basic Configuration
- **`-c`**, **`--config`**=_`<./.oxlintrc.json>`_ &mdash;
Expand All @@ -19,6 +19,8 @@ search: false
* tries to be compatible with ESLint v8's format

If not provided, Oxlint will look for a `.oxlintrc.json`, `.oxlintrc.jsonc`, or `oxlint.config.ts` file in the current working directory.
- **` --config-field`**=_`KEY`_ &mdash;
Top-level key to extract from the config object (requires --config)
- **` --tsconfig`**=_`<./tsconfig.json>`_ &mdash;
TypeScript `tsconfig.json` path for reading path alias and project references for import plugin. If not provided, will look for `tsconfig.json` in the current working directory.
- **` --init`** &mdash;
Expand Down
3 changes: 2 additions & 1 deletion tasks/website_linter/src/snapshots/cli_terminal.snap
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
source: tasks/website_linter/src/cli.rs
expression: snapshot
---
Usage: [-c=<./.oxlintrc.json>] [PATH]...
Usage: [-c=<./.oxlintrc.json>] [--config-field=KEY] [PATH]...

Basic Configuration
-c, --config=<./.oxlintrc.json> Oxlint configuration file
Expand All @@ -11,6 +11,7 @@ Basic Configuration
running via Node.js
* you can use comments in configuration files.
* tries to be compatible with ESLint v8's format
--config-field=KEY Top-level key to extract from the config object (requires --config)
--tsconfig=<./tsconfig.json> TypeScript `tsconfig.json` path for reading path alias and
project references for import plugin. If not provided, will look for
`tsconfig.json` in the current working directory.
Expand Down
Loading