feat(oxfmt): support associations config to specify language over filepath (#27437)

Closes #17852 (finally!), closes #26257.

---

Introduce `associations` config field (inspired by dprint).

```json
{
  "associations": [
    { "files": ["**/*.html"], "excludeFiles": ["index.html"], "language": "angular-html" },
    { "files": ["**/*.wxml"], "language": "html" },
    { "files": ["**/*.conf"], "language": "json" }
  ]
}
```

Precedence is solved by an array instead of an object, later wins like `overrides`.
For `language`, use Shiki ids. It's intuitive and already used in our codebase.

In the CLI, this bypasses selecting a formatter based on the filename and behaves as if one had been determined.
This commit is contained in:
leaysgur committed 2026-10-11 12:18:59 +00:00
1 parent ffc6a7116f
commit a495e8241e
32 files changed
+599 -51

No files matched your search

+50 -5
View File
@@ -4,6 +4,33 @@
*/
export type ArrowParensConfig = "always" | "avoid";
/**
* A set of glob patterns.
* Patterns are matched against paths relative to the configuration file's directory.
*/
export type GlobSet = string[];
export type Language =
| "javascript"
| "jsx"
| "typescript"
| "tsx"
| "json"
| "jsonc"
| "json5"
| "css"
| "scss"
| "less"
| "graphql"
| "yaml"
| "markdown"
| "toml"
| "html"
| "angular-html"
| "vue"
| "svelte"
| "astro"
| "handlebars"
| "mdx";
export type AstroUserConfig = boolean | AstroConfig;
export type AstroCompressHtmlConfig = "jsx" | "html" | "none";
export type EmbeddedLanguageFormattingConfig = "auto" | "off";
@@ -14,11 +41,6 @@ export type JsdocUserConfig = boolean | JsdocConfig;
export type CommentLineStrategyConfig = "singleLine" | "multiline" | "keep";
export type LineWrappingStyleConfig = "greedy" | "balance";
export type ObjectWrapConfig = "preserve" | "collapse";
/**
* A set of glob patterns.
* Patterns are matched against paths relative to the configuration file's directory.
*/
export type GlobSet = string[];
export type ProseWrapConfig = "always" | "never" | "preserve";
export type QuotePropsConfig = "as-needed" | "consistent" | "preserve";
export type SortImportsUserConfig = boolean | SortImportsConfig;
@@ -63,6 +85,14 @@ export interface Oxfmtrc {
* - Default: `"always"`
*/
arrowParens?: ArrowParensConfig;
/**
* Format matched files as the given language, instead of detecting it from the file name.
* e.g. Angular templates named `*.html`, or `*.wxml` files formatted as HTML.
* When a file matches multiple associations, the later one takes precedence (array order matters).
*
* - Default: `[]`
*/
associations?: OxfmtAssociationConfig[];
/**
* Options for `prettier-plugin-astro`.
*
@@ -320,6 +350,21 @@ export interface Oxfmtrc {
vueIndentScriptAndStyle?: boolean;
[k: string]: unknown;
}
export interface OxfmtAssociationConfig {
/**
* Glob patterns to exclude from this association.
*/
excludeFiles?: GlobSet;
/**
* Glob patterns to match files for this association.
*/
files: GlobSet;
/**
* The language to format matched files as, named by its Shiki id (as in Markdown code fences).
*/
language: Language;
[k: string]: unknown;
}
export interface AstroConfig {
/**
* Whether to normalize matching identifier attributes to shorthand or explicit form.
+2 -3
View File
@@ -9,8 +9,7 @@ use oxc_diagnostics::{DiagnosticService, GraphicalReportHandler};
use super::{CliRunResult, FormatCommand, Mode};
use crate::core::{
ConfigScopes, ExternalServices, FormatResult, JsConfigLoaderCb, ResolveOutcome,
SourceFormatter, build_global_ignore_matchers, classify_file, is_ignored, resolve_ignore_paths,
utils,
SourceFormatter, build_global_ignore_matchers, is_ignored, resolve_ignore_paths, utils,
};
pub struct StdinRunner {
@@ -116,7 +115,7 @@ impl StdinRunner {
return CliRunResult::FormatSucceeded;
}
let Some(strategy) = classify_file(&filepath) else {
let Some(strategy) = config_resolver.classify(&filepath, None) else {
utils::print_and_flush(stderr, "Unsupported file type for stdin-filepath\n");
return CliRunResult::InvalidOptionConfig;
};
+2 -2
View File
@@ -16,7 +16,7 @@ use oxc_diagnostics::{DiagnosticSender, DiagnosticService, OxcDiagnostic};
use crate::core::{
ConfigResolver, ConfigScopes, FormatPlan, ResolveOutcome, build_global_ignore_matchers,
classify_file, is_ignored,
is_ignored,
};
/// Orchestrates file discovery with nested config and ignore handling.
@@ -640,7 +640,7 @@ fn resolve_format_plan(
tx_error: &DiagnosticSender,
cwd: &Path,
) -> Option<FormatPlan> {
let strategy = classify_file(path)?;
let strategy = resolver.classify(path, None)?;
match resolver.resolve(path, strategy) {
Ok(ResolveOutcome::Format(plan)) => Some(plan),
Ok(ResolveOutcome::MissingPlugin(_)) => None,
@@ -0,0 +1,29 @@
use std::path::{Path, PathBuf};
use crate::core::{Language, oxfmtrc::OxfmtAssociationConfig};
use super::relative_path;
/// Resolved `associations` for file-specific language matching.
/// Like `OxfmtrcOverrides`, but picks a language instead of merging options.
#[derive(Debug)]
pub struct OxfmtrcAssociations {
base_dir: Option<PathBuf>,
entries: Vec<OxfmtAssociationConfig>,
}
impl OxfmtrcAssociations {
pub fn new(associations: Vec<OxfmtAssociationConfig>, base_dir: Option<PathBuf>) -> Self {
Self { base_dir, entries: associations }
}
/// The language of the last association matching `path`, if any.
pub fn matching(&self, path: &Path) -> Option<Language> {
let relative = relative_path(self.base_dir.as_deref(), path);
self.entries
.iter()
.rev()
.find(|e| e.files.is_match(&relative) && !e.exclude_files.is_match(&relative))
.map(|e| e.language)
}
}
+36 -6
View File
@@ -1,3 +1,4 @@
mod associations;
mod editorconfig;
#[cfg(feature = "napi")]
mod js_config;
@@ -11,6 +12,7 @@ pub use nested::NestedConfigCtx;
pub use scopes::ConfigScopes;
use std::{
borrow::Cow,
path::{Path, PathBuf},
sync::Arc,
};
@@ -24,18 +26,26 @@ use tracing::instrument;
use oxc_config::{ConfigDiscovery, DiscoveredConfigFile, is_js_config_path, vp_version};
use self::{
associations::OxfmtrcAssociations,
editorconfig::{apply_editorconfig, resolve_editorconfig_overrides, root_properties},
overrides::OxfmtrcOverrides,
};
use super::{
FormatPlan,
FormatPlan, Language,
global_ignore::matches_with_ancestors,
options::{ValidatedOptions, validate},
oxfmtrc::{FormatConfig, Oxfmtrc},
support::FormatStrategy,
support::{FormatStrategy, classify_as, classify_file},
utils,
};
/// `path` relative to the config directory, as written glob patterns expect.
fn relative_path<'a>(base_dir: Option<&Path>, path: &'a Path) -> Cow<'a, str> {
// NOTE: On Windows, `to_string_lossy()` produces `\`-separated paths.
// This is OK since `fast_glob::glob_match()` supports both `/` and `\` via `std::path::is_separator`.
base_dir.and_then(|dir| path.strip_prefix(dir).ok()).unwrap_or(path).to_string_lossy()
}
pub fn config_discovery() -> ConfigDiscovery {
if cfg!(feature = "napi") && vp_version().is_some() {
ConfigDiscovery::vite_plus()
@@ -275,6 +285,8 @@ pub struct ConfigResolver {
base: Option<(Arc<FormatConfig>, Arc<ValidatedOptions>)>,
/// Resolved overrides from `.oxfmtrc` for file-specific matching.
oxfmtrc_overrides: Option<OxfmtrcOverrides>,
/// Resolved `associations` from `.oxfmtrc`, consulted before the built-in classification.
associations: Option<OxfmtrcAssociations>,
/// Ignore glob built from this config's `ignorePatterns`.
ignore_glob: Option<Gitignore>,
/// Parsed `.editorconfig`, if any.
@@ -295,6 +307,7 @@ impl ConfigResolver {
config_dir,
base: None,
oxfmtrc_overrides: None,
associations: None,
ignore_glob: None,
editorconfig,
}
@@ -348,6 +361,7 @@ impl ConfigResolver {
/// - `self.base` is set to the validated `FormatConfig` snapshot
/// (with `.editorconfig` `[*]` already folded in) paired with its gate artifacts
/// - `self.oxfmtrc_overrides` is set if `overrides` exists
/// - `self.associations` is set if `associations` is not empty
/// - `self.ignore_glob` is built from `ignorePatterns`
///
/// Validation runs eagerly via `validate()`,
@@ -359,10 +373,14 @@ impl ConfigResolver {
pub fn build_and_validate(&mut self) -> Result<(), String> {
let oxfmtrc = Oxfmtrc::deserialize(&self.raw_config).map_err(|err| err.to_string())?;
// Resolve `overrides` from `Oxfmtrc` for later per-file matching
let base_dir = self.config_dir.clone();
self.oxfmtrc_overrides =
oxfmtrc.overrides.map(|overrides| OxfmtrcOverrides::new(overrides, base_dir));
// Resolve `overrides` and `associations` from `Oxfmtrc` for later per-file matching
self.oxfmtrc_overrides = oxfmtrc
.overrides
.map(|overrides| OxfmtrcOverrides::new(overrides, self.config_dir.clone()));
self.associations = oxfmtrc
.associations
.filter(|associations| !associations.is_empty())
.map(|associations| OxfmtrcAssociations::new(associations, self.config_dir.clone()));
let mut format_config = oxfmtrc.format_config;
@@ -390,6 +408,18 @@ impl ConfigResolver {
Ok(())
}
/// Classify `path`: the given `language` first (e.g. LSP `languageId`),
/// then this config's `associations`, then the file name.
///
/// A given or matched language decides alone, see [`classify_as`] for when it yields `None`.
pub fn classify(&self, path: &Path, language: Option<Language>) -> Option<FormatStrategy> {
let language = language.or_else(|| self.associations.as_ref()?.matching(path));
match language {
Some(language) => classify_as(language, path),
None => classify_file(path),
}
}
/// Resolve options for a pre-classified file and build a [`ResolveOutcome`].
///
/// Returns `Err` only when the merged config (after override application) fails validation.
+3 -8
View File
@@ -4,6 +4,8 @@ use oxc_config::GlobSet;
use crate::core::oxfmtrc::{FormatConfig, OxfmtOverrideConfig};
use super::relative_path;
/// Resolved overrides for file-specific matching.
/// Similar to `EditorConfig`, this also handles `FormatConfig` override resolution.
#[derive(Debug)]
@@ -30,14 +32,7 @@ impl OxfmtrcOverrides {
/// Collect the options of every override matching `path`, in config order.
/// Empty when nothing matches, so callers can gate on `is_empty()` without a separate probe.
pub fn matching(&self, path: &Path) -> Vec<&FormatConfig> {
// NOTE: On Windows, `to_string_lossy()` produces `\`-separated paths.
// This is OK since `fast_glob::glob_match()` supports both `/` and `\` via `std::path::is_separator`.
let relative = self
.base_dir
.as_ref()
.and_then(|dir| path.strip_prefix(dir).ok())
.unwrap_or(path)
.to_string_lossy();
let relative = relative_path(self.base_dir.as_deref(), path);
self.entries
.iter()
+19 -2
View File
@@ -4,6 +4,9 @@
//! The aliases used by embedded code, the LSP `languageId`s and the fake path extension all live here,
//! so adding a language never needs syncing several tables.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use oxc_formatter_css::CssVariant;
use oxc_formatter_json::JsonVariant;
use oxc_span::{FileExtension, SourceType};
@@ -35,7 +38,8 @@ pub enum Route {
Unsupported,
}
#[derive(Clone, Copy)]
#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum Language {
Javascript,
Jsx,
@@ -110,7 +114,7 @@ impl Language {
}
/// LSP only: the extension of the fake path for an in-memory document,
/// which `classify_file` and `overrides` see as a real file.
/// so that config scopes, `ignorePatterns` and `overrides` see it as a file of this language.
#[cfg(feature = "napi")]
pub fn to_lsp_extension(self) -> &'static str {
match self {
@@ -170,6 +174,19 @@ impl Language {
mod tests {
use super::*;
/// The config ids (serde names) are the canonical embedded names, so both stay one vocabulary.
#[test]
fn serde_names_are_embedded_names() {
let schema = serde_json::to_value(schemars::schema_for!(Language)).unwrap();
let names = schema["enum"].as_array().expect("`Language` is a plain string enum");
for name in names {
let name = name.as_str().unwrap();
let language = Language::from_embedded(name)
.unwrap_or_else(|| panic!("`{name}` is not an embedded name"));
assert_eq!(serde_json::to_value(language).unwrap(), name);
}
}
/// Prettier-served fences (native ones are covered by `every_native_language_dispatches`)
/// and the LSP ids that differ from embedded names or extensions.
#[test]
+1 -1
View File
@@ -16,8 +16,8 @@ pub use config::{ConfigResolver, ConfigScopes, ResolveOutcome, config_discovery}
pub use config::{JsConfigLoaderCb, JsLoadJsConfigCb, create_js_config_loader, resolve_for_api};
pub use format::{FormatPlan, FormatResult, SourceFormatter};
pub use global_ignore::{build_global_ignore_matchers, is_ignored, resolve_ignore_paths};
#[cfg(feature = "napi")]
pub use language::Language;
#[cfg(feature = "napi")]
pub use support::classify_file;
#[cfg(feature = "napi")]
+22 -1
View File
@@ -6,7 +6,7 @@ use serde_json::Value;
use oxc_config::GlobSet;
use crate::core::utils;
use crate::core::{Language, utils};
/// Configuration options for Oxfmt.
///
@@ -23,6 +23,13 @@ pub struct Oxfmtrc {
/// - Default: `[]`
#[serde(skip_serializing_if = "Option::is_none")]
pub overrides: Option<Vec<OxfmtOverrideConfig>>,
/// Format matched files as the given language, instead of detecting it from the file name.
/// e.g. Angular templates named `*.html`, or `*.wxml` files formatted as HTML.
/// When a file matches multiple associations, the later one takes precedence (array order matters).
///
/// - Default: `[]`
#[serde(skip_serializing_if = "Option::is_none")]
pub associations: Option<Vec<OxfmtAssociationConfig>>,
/// Ignore files matching these glob patterns.
/// Patterns use gitignore-style matching, rooted at the directory containing the configuration file.
/// Files outside that directory cannot be matched; patterns containing `..` are rejected as a configuration error.
@@ -50,6 +57,20 @@ pub struct OxfmtOverrideConfig {
// ---
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct OxfmtAssociationConfig {
/// Glob patterns to match files for this association.
pub files: GlobSet,
/// Glob patterns to exclude from this association.
#[serde(default, skip_serializing_if = "GlobSet::is_empty")]
pub exclude_files: GlobSet,
/// The language to format matched files as, named by its Shiki id (as in Markdown code fences).
pub language: Language,
}
// ---
// NOTE: All fields are typed as `Option` to distinguish between user-specified values and defaults.
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase", default)]
+20 -1
View File
@@ -6,6 +6,7 @@ use oxc_formatter_css::CssVariant;
use oxc_formatter_json::JsonVariant;
use oxc_span::SourceType;
use super::language::{Language, Route};
#[cfg(feature = "napi")]
use super::oxfmtrc::FormatConfig;
@@ -64,8 +65,26 @@ pub fn classify_file(path: &Path) -> Option<FormatStrategy> {
None
}
/// Classify a file explicitly assigned to `language` (e.g. by `associations`), regardless of its name.
///
/// Still `None` for excluded files (lock files), and in the pure Rust build for languages Prettier serves.
pub fn classify_as(language: Language, path: &Path) -> Option<FormatStrategy> {
// Lock files must NEVER be formatted, even when explicitly assigned
// (e.g. `pnpm-lock.yaml` matched by a broad `associations` glob).
// This bypasses `classify_file`, so the same rule is checked here.
if EXCLUDE_FILENAMES.contains(path.file_name()?.to_str()?) {
return None;
}
match language.route() {
Route::Native(language) => Some(FormatStrategy::Native(language)),
#[cfg(feature = "napi")]
Route::Prettier(language) => Some(FormatStrategy::Prettier(language)),
_ => None,
}
}
/// How a whole file is formatted: which formatter, with any pre-process.
/// The whole-file counterpart of [`Route`](super::language::Route) for embedded parts,
/// The whole-file counterpart of [`Route`] for embedded parts,
/// with `PackageJson` / `YamlRc` as file-only pre-processes.
///
/// Consumed by the resolver to construct a [`super::FormatPlan`] with the resolved config.
+10 -14
View File
@@ -4,7 +4,7 @@ use std::{
sync::Arc,
};
use oxc_language_server::{LanguageId, run_server};
use oxc_language_server::run_server;
use tower_lsp_server::gen_lsp_types::Uri;
use crate::core::{ExternalServices, JsConfigLoaderCb, Language};
@@ -12,12 +12,10 @@ use crate::core::{ExternalServices, JsConfigLoaderCb, Language};
mod options;
mod server_formatter;
pub fn create_fake_file_path_from_language_id(
language_id: &LanguageId,
root: &Path,
uri: &Uri,
) -> Option<PathBuf> {
let file_extension = Language::from_lsp_id(language_id.as_str())?.to_lsp_extension();
/// The path of an in-memory document,
/// so that config scopes, `ignorePatterns` and `overrides` see it as a file of `language`.
pub fn create_fake_file_path(language: Language, root: &Path, uri: &Uri) -> PathBuf {
let file_extension = language.to_lsp_extension();
// Use the authority (if available) or the last segment of the path as the file name, defaulting to "Untitled" if neither is available
let mut name = uri.authority().map_or_else(
|| uri.path().rsplit_once('/').map_or_else(|| "Untitled", |(_, s)| s.as_str()),
@@ -29,7 +27,7 @@ pub fn create_fake_file_path_from_language_id(
}
let file_name = format!("{name}.{file_extension}");
Some(root.join(file_name))
root.join(file_name)
}
/// Run the language server
@@ -56,23 +54,21 @@ pub async fn run_lsp(js_config_loader: JsConfigLoaderCb, external_services: Exte
mod test {
use std::str::FromStr;
use oxc_language_server::LanguageId;
use tower_lsp_server::gen_lsp_types::Uri;
use crate::lsp::create_fake_file_path_from_language_id;
use crate::{core::Language, lsp::create_fake_file_path};
#[test]
fn test_create_fake_file_path_from_language_id() {
let language_id = LanguageId::new("jsonc".to_string());
fn test_create_fake_file_path() {
let root = std::env::temp_dir();
let uri = Uri::from_str("vscode-userdata:/c%3A/Users/User/settings.json").unwrap();
let result = create_fake_file_path_from_language_id(&language_id, &root, &uri).unwrap();
let result = create_fake_file_path(Language::Jsonc, &root, &uri);
assert_eq!(result.extension().unwrap(), "jsonc");
assert!(result.starts_with(&root));
let uri = Uri::from_str("Untitled://Untitled-1").unwrap();
let result = create_fake_file_path_from_language_id(&language_id, &root, &uri).unwrap();
let result = create_fake_file_path(Language::Jsonc, &root, &uri);
assert_eq!(result.extension().unwrap(), "jsonc");
assert!(result.starts_with(&root));
}
+12 -8
View File
@@ -17,11 +17,11 @@ use oxc_language_server::{
};
use crate::core::{
ConfigScopes, ExternalServices, FormatResult, JsConfigLoaderCb, ResolveOutcome,
SourceFormatter, build_global_ignore_matchers, classify_file, config_discovery, is_ignored,
ConfigScopes, ExternalServices, FormatResult, JsConfigLoaderCb, Language, ResolveOutcome,
SourceFormatter, build_global_ignore_matchers, config_discovery, is_ignored,
resolve_ignore_paths, utils,
};
use crate::lsp::create_fake_file_path_from_language_id;
use crate::lsp::create_fake_file_path;
use crate::lsp::options::FormatOptions as LSPFormatOptions;
pub struct ServerFormatterBuilder {
@@ -367,11 +367,15 @@ impl ServerFormatter {
/// Resolve config and format a file at the given path.
/// Returns `None` if the file is unsupported or ignored.
///
/// `language` is given for an in-memory document (from its `languageId`),
/// which then decides the formatter, see [`crate::core::ConfigResolver::classify`].
fn resolve_and_format(
&self,
scopes: &ConfigScopes,
path: &Path,
source_text: &str,
language: Option<Language>,
) -> Result<Option<FormatResult>, String> {
let resolver = match scopes.resolve(path) {
Ok(r) => r,
@@ -389,7 +393,7 @@ impl ServerFormatter {
return Ok(None);
}
let Some(strategy) = classify_file(path) else {
let Some(strategy) = resolver.classify(path, language) else {
debug!("Unsupported file type for formatting: {}", path.display());
return Ok(None);
};
@@ -416,7 +420,7 @@ impl ServerFormatter {
debug!("File is ignored by .prettierignore: {}", path.display());
return Ok(None);
}
self.resolve_and_format(&state.scopes, path, source_text)
self.resolve_and_format(&state.scopes, path, source_text, None)
}
fn format_in_memory(
@@ -425,12 +429,12 @@ impl ServerFormatter {
source_text: &str,
language_id: &LanguageId,
) -> Result<Option<FormatResult>, String> {
let Some(path) = create_fake_file_path_from_language_id(language_id, &self.root_path, uri)
else {
let Some(language) = Language::from_lsp_id(language_id.as_str()) else {
debug!("Unsupported language id for in-memory formatting: {language_id:?}");
return Ok(None);
};
self.resolve_and_format(&self.snapshot().scopes, &path, source_text)
let path = create_fake_file_path(language, &self.root_path, uri);
self.resolve_and_format(&self.snapshot().scopes, &path, source_text, Some(language))
}
}
@@ -0,0 +1,94 @@
# Input
## Command
```
oxfmt .
```
## File tree
```
- fixtures/ <CWD>
- .oxfmtrc.json
- app.conf
- index.html
- invalid_language.json
- notes.txt
- page.wxml
- src/
- app/
- user.html
- app.conf
- keep.conf
- yarn.lock
```
# Result
## Exit code
0
## stdout
```
Finished in <time> on 8 files using 1 threads.
```
## stderr
```
```
## File changes
### app.conf
`````diff
@@ -1,2 +1,4 @@
-{"a":1,// comment
-"b":2}
+{
+ "a": 1, // comment
+ "b": 2
+}
`````
### page.wxml
`````diff
@@ -1,3 +1,3 @@
<view>
-<text class="a">{{msg}}</text>
+ <text class="a">{{msg}}</text>
</view>
`````
### src/app.conf
`````diff
@@ -1,2 +1,4 @@
-{"a":1,// comment
-"b":2}
+{
+ "a": 1, // comment
+ "b": 2,
+}
`````
### src/app/user.html
`````diff
@@ -1,5 +1,5 @@
@if (user) {
-<p>{{user.name}}</p>
+ <p>{{ user.name }}</p>
} @else {
-<p>Guest</p>
+ <p>Guest</p>
}
`````
### src/keep.conf
`````diff
@@ -1,2 +1,4 @@
-{"a":1,// comment
-"b":2}
+{
+ "a": 1, // comment
+ "b": 2
+}
`````
@@ -0,0 +1,45 @@
# Input
## Command
```
oxfmt --stdin-filepath src/app/user.html
```
## File tree
```
- fixtures/ <CWD>
- .oxfmtrc.json
- app.conf
- index.html
- invalid_language.json
- notes.txt
- page.wxml
- src/
- app/
- user.html
- app.conf
- keep.conf
- yarn.lock
```
# Result
## Exit code
0
## stdout
```
@if (user) {
<p>{{ user.name }}</p>
} @else {
<p>Guest</p>
}
```
## stderr
```
```
## File changes
No changes
@@ -0,0 +1,42 @@
# Input
## Command
```
oxfmt -c invalid_language.json --check page.wxml
```
## File tree
```
- fixtures/ <CWD>
- .oxfmtrc.json
- app.conf
- index.html
- invalid_language.json
- notes.txt
- page.wxml
- src/
- app/
- user.html
- app.conf
- keep.conf
- yarn.lock
```
# Result
## Exit code
1
## stdout
```
```
## stderr
```
Failed to parse configuration.
unknown variant `angular`, expected one of `javascript`, `jsx`, `typescript`, `tsx`, `json`, `jsonc`, `json5`, `css`, `scss`, `less`, `graphql`, `yaml`, `markdown`, `toml`, `html`, `angular-html`, `vue`, `svelte`, `astro`, `handlebars`, `mdx`
```
## File changes
No changes
@@ -0,0 +1,9 @@
{
"associations": [
{ "files": ["**/*.html"], "excludeFiles": ["index.html"], "language": "angular-html" },
{ "files": ["**/*.wxml"], "language": "html" },
{ "files": ["**/*.conf"], "language": "json" },
{ "files": ["src/**/*.conf"], "excludeFiles": ["src/keep.conf"], "language": "jsonc" },
{ "files": ["*.lock"], "language": "json" }
]
}
@@ -0,0 +1,2 @@
{"a":1,// comment
"b":2}
@@ -0,0 +1,5 @@
@if (user) {
<p>{{user.name}}</p>
} @else {
<p>Guest</p>
}
@@ -0,0 +1,3 @@
{
"associations": [{ "files": ["*.html"], "language": "angular" }]
}
@@ -0,0 +1 @@
not formatted
@@ -0,0 +1,3 @@
<view>
<text class="a">{{msg}}</text>
</view>
@@ -0,0 +1,2 @@
{"a":1,// comment
"b":2}
@@ -0,0 +1,5 @@
@if (user) {
<p>{{user.name}}</p>
} @else {
<p>Guest</p>
}
@@ -0,0 +1,2 @@
{"a":1,// comment
"b":2}
@@ -0,0 +1,2 @@
{"a":1,// comment
"b":2}
@@ -0,0 +1,5 @@
[
{ "args": ["."] },
{ "args": ["--stdin-filepath", "src/app/user.html"], "stdin": "src/app/user.html" },
{ "args": ["-c", "invalid_language.json", "--check", "page.wxml"] }
]
@@ -104,6 +104,26 @@ const x = 1;
--------------------"
`;
exports[`LSP formatting > config options > should apply config from config-associations/test.html 1`] = `
"--- URI -----------
file://<fixture>/config-associations/test.html
--- BEFORE ---------
@if (user) {
<p>{{user.name}}</p>
} @else {
<p>Guest</p>
}
--- AFTER ----------
@if (user) {
<p>{{ user.name }}</p>
} @else {
<p>Guest</p>
}
--------------------"
`;
exports[`LSP formatting > config options > should apply config from config-astro/test.astro 1`] = `
"--- URI -----------
file://<fixture>/config-astro/test.astro
@@ -525,6 +545,26 @@ hello world
--------------------"
`;
exports[`LSP formatting > in-memory document > should format uri untitled://Untitled-7 1`] = `
"--- URI -----------
untitled://Untitled-7
--- BEFORE ---------
@if (user) {
<p>{{user.name}}</p>
} @else {
<p>Guest</p>
}
--- AFTER ----------
@if (user) {
<p>{{user.name}}</p>
} @else {
<p>Guest</p>
}
--------------------"
`;
exports[`LSP formatting > in-memory document > should format uri vscode-userdata:/c%3A/Users/User/settings.json 1`] = `
"--- URI -----------
vscode-userdata:/c%3A/Users/User/settings.json
@@ -0,0 +1,5 @@
{
"associations": [
{ "files": ["**/*.html"], "language": "angular-html" }
]
}
@@ -0,0 +1,5 @@
@if (user) {
<p>{{user.name}}</p>
} @else {
<p>Guest</p>
}
@@ -32,6 +32,7 @@ describe("LSP formatting", () => {
["config-vue-indent/test.vue", "vue"],
["config-svelte/test.svelte", "svelte"],
["config-astro/test.astro", "astro"],
["config-associations/test.html", "html"],
["config-sort-imports/test.js", "javascript"],
["config-sort-tailwindcss/test.tsx", "typescriptreact"],
["config-sort-tailwindcss/test.vue", "vue"],
@@ -111,6 +112,8 @@ describe("LSP formatting", () => {
["untitled://Untitled-4", "format/test.toml", "toml"],
["untitled://Untitled-5", "format/formatted.ts", "typescript"],
["untitled://Untitled-6", "format/test.txt", "plaintext"],
// `associations` (`**/*.html` as Angular) do not apply, the `languageId` decides
["untitled://Untitled-7", "config-associations/test.html", "html"],
// with path
["vscode-userdata:/c%3A/Users/User/settings.json", "format/test.tsx", "typescriptreact"],
])("should format uri %s", async (uri, path, languageId) => {
+70
View File
@@ -13,6 +13,14 @@
],
"markdownDescription": "Include parentheses around a sole arrow function parameter.\n\n- Languages: JS, JSX, TS, TSX\n- Default: `\"always\"`"
},
"associations": {
"description": "Format matched files as the given language, instead of detecting it from the file name.\ne.g. Angular templates named `*.html`, or `*.wxml` files formatted as HTML.\nWhen a file matches multiple associations, the later one takes precedence (array order matters).\n\n- Default: `[]`",
"type": "array",
"items": {
"$ref": "#/definitions/OxfmtAssociationConfig"
},
"markdownDescription": "Format matched files as the given language, instead of detecting it from the file name.\ne.g. Angular templates named `*.html`, or `*.wxml` files formatted as HTML.\nWhen a file matches multiple associations, the later one takes precedence (array order matters).\n\n- Default: `[]`"
},
"astro": {
"description": "Options for `prettier-plugin-astro`.\n\nPass `true` or an object to enable `.astro` file formatting, `false` to disable (handy in overrides).\nSetting `true` resets to defaults, dropping options inherited from a parent scope.\n\nNOTE: `prettier-plugin-astro` requires the `@astrojs/compiler-rs` package at runtime.\nOxfmt does NOT bundle it, so install it in your project, otherwise formatting fails.\n\n- Languages: Astro\n- Default: Disabled",
"allOf": [
@@ -672,6 +680,32 @@
}
]
},
"Language": {
"type": "string",
"enum": [
"javascript",
"jsx",
"typescript",
"tsx",
"json",
"jsonc",
"json5",
"css",
"scss",
"less",
"graphql",
"yaml",
"markdown",
"toml",
"html",
"angular-html",
"vue",
"svelte",
"astro",
"handlebars",
"mdx"
]
},
"LineWrappingStyleConfig": {
"type": "string",
"enum": [
@@ -706,6 +740,42 @@
"end"
]
},
"OxfmtAssociationConfig": {
"type": "object",
"required": [
"files",
"language"
],
"properties": {
"excludeFiles": {
"description": "Glob patterns to exclude from this association.",
"allOf": [
{
"$ref": "#/definitions/GlobSet"
}
],
"markdownDescription": "Glob patterns to exclude from this association."
},
"files": {
"description": "Glob patterns to match files for this association.",
"allOf": [
{
"$ref": "#/definitions/GlobSet"
}
],
"markdownDescription": "Glob patterns to match files for this association."
},
"language": {
"description": "The language to format matched files as, named by its Shiki id (as in Markdown code fences).",
"allOf": [
{
"$ref": "#/definitions/Language"
}
],
"markdownDescription": "The language to format matched files as, named by its Shiki id (as in Markdown code fences)."
}
}
},
"OxfmtOverrideConfig": {
"type": "object",
"required": [
@@ -23,6 +23,56 @@ Include parentheses around a sole arrow function parameter.
- Default: `"always"`
## associations
type: `array`
Format matched files as the given language, instead of detecting it from the file name.
e.g. Angular templates named `*.html`, or `*.wxml` files formatted as HTML.
When a file matches multiple associations, the later one takes precedence (array order matters).
- Default: `[]`
### associations[n]
type: `object`
#### associations[n].excludeFiles
type: `string[]`
Glob patterns to exclude from this association.
A set of glob patterns.
Patterns are matched against paths relative to the configuration file's directory.
#### associations[n].files
type: `string[]`
Glob patterns to match files for this association.
A set of glob patterns.
Patterns are matched against paths relative to the configuration file's directory.
#### associations[n].language
type: `"javascript" | "jsx" | "typescript" | "tsx" | "json" | "jsonc" | "json5" | "css" | "scss" | "less" | "graphql" | "yaml" | "markdown" | "toml" | "html" | "angular-html" | "vue" | "svelte" | "astro" | "handlebars" | "mdx"`
The language to format matched files as, named by its Shiki id (as in Markdown code fences).
## astro
type: `object | boolean`