mirror of
https://github.com/oxc-project/oxc.git
synced 2026-11-04 10:29:58 +00:00
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:
1 parent
ffc6a7116f
commit
a495e8241e
32 files changed
+599
-51
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in new issue
Block a user