From 44c603f7dba0a785feffb01dc64dd571bfcd633d Mon Sep 17 00:00:00 2001 From: d0whc3r <1378986+d0whc3r@users.noreply.github.com> Date: Mon, 2 Nov 2026 04:22:49 +0100 Subject: [PATCH] feat: add oxfmt/oxlint support for JS and TS projects (#717) Below an oxfmt configuration (.oxfmtrc.json/.jsonc, oxfmt.config.ts/.mts) Reformat Code runs oxfmt instead of Prettier, with the same external-formatter settings; oxfmt formats whole files, so a selection formats the file with a notice. oxlint --lsp serves JS/TS/React/Vue/Svelte files below an oxlint config. Both are managed npm tools; a missing oxfmt gets the same Install prompt and error handling as Prettier, and formatter messages are translated for either tool. Co-authored-by: d0whc3r <1378986+d0whc3r@users.noreply.github.com> --- crates/ide-host/src/formatter.rs | 50 ++++++++--- crates/ide-language/src/registry.rs | 20 ++++- crates/ide-tools/src/formatter.rs | 129 +++++++++++++++++++-------- crates/ide-tools/src/registry.rs | 30 +++++-- crates/ide-tools/tests/prettier.rs | 4 +- languages/javascript.toml | 8 ++ languages/javascriptreact.toml | 8 ++ languages/svelte.toml | 8 ++ languages/typescript.toml | 8 ++ languages/typescriptreact.toml | 8 ++ languages/vue.toml | 8 ++ src/editor/lsp/format.ts | 13 +-- src/editor/lsp/formatErrors.test.tsx | 13 +-- src/platform/locales/text/zh.json | 14 +-- 14 files changed, 246 insertions(+), 75 deletions(-) diff --git a/crates/ide-host/src/formatter.rs b/crates/ide-host/src/formatter.rs index 7bc9af9d..054497b3 100644 --- a/crates/ide-host/src/formatter.rs +++ b/crates/ide-host/src/formatter.rs @@ -7,6 +7,16 @@ use ide_core::{DocId, IdeError, IdeEvent, IdeResult, Range, TextEdit}; use ide_language::FormatterConfig; use ide_lsp::types::FormatOptions; use ide_tools::{Tools, formatter}; +use std::path::Path; + +const OXFMT_CONFIGS: [&str; 4] = [".oxfmtrc.json", ".oxfmtrc.jsonc", "oxfmt.config.ts", "oxfmt.config.mts"]; + +/// oxfmt below an oxfmt configuration file, else Prettier. The Prettier +/// settings (languages, preference, on save) apply to both. +fn external_tool(file: &Path) -> &'static str { + let oxfmt = OXFMT_CONFIGS.iter().any(|name| formatter::find_up(file, Path::new(name)).is_some()); + if oxfmt { "oxfmt" } else { "prettier" } +} fn external_selected(config: Option<&FormatterConfig>, language: &str, settings: &PrettierSettings, lsp: bool) -> bool { config.is_some_and(|c| c.tool == "prettier") @@ -44,16 +54,19 @@ pub async fn format( return Err(IdeError::invalid("The file is read-only")); } let config = config.unwrap().clone(); - let custom = ide_sdk::apply::setting_path(&project.root, &prettier.path); + let tool = external_tool(&snap.path); + let name = formatter::display_name(tool); + let custom = ide_sdk::apply::setting_path(&project.root, &prettier.path).filter(|_| tool == "prettier"); let tools = Tools::new(&host.local_data_dir); - let whole_selection = range.is_some() && !config.range; + let whole_selection = range.is_some() && !formatter::supports_range(tool, &config); let edits: IdeResult> = tokio::task::spawn_blocking(move || { let text = ide_text::Document::new(&snap.text); let offsets = range.map(|r| (text.pos_to_offset(r.start), text.pos_to_offset(r.end))); - let mut command = tools.formatter_command(&config.tool, &snap.path, custom.as_deref())?; - command.current_dir(snap.path.parent().ok_or_else(|| IdeError::invalid("Prettier: file has no directory"))?); - command.args(formatter::arguments(&config, &snap.path, options.tab_size, options.insert_spaces, offsets)); - let formatted = formatter::run(command, &snap.text, &Default::default())?; + let mut command = tools.formatter_command(tool, &snap.path, custom.as_deref())?; + let dir = snap.path.parent().ok_or_else(|| IdeError::invalid(format!("{name}: file has no directory")))?; + command.current_dir(dir); + command.args(formatter::arguments(tool, &config, &snap.path, options.tab_size, options.insert_spaces, offsets)); + let formatted = formatter::run(tool, command, &snap.text, &Default::default())?; Ok(text .diff(&formatted) .changes @@ -69,13 +82,13 @@ pub async fn format( if edits.is_ok() && whole_selection { project.sink.emit(IdeEvent::Notify { level: NotifyLevel::Info, - title: "Prettier".into(), - body: Some( - "Prettier does not support selection formatting for this language; formatted the whole file".into(), - ), + title: name.into(), + body: Some(format!( + "{name} does not support selection formatting for this language; formatted the whole file" + )), }); } - edits.map_err(|e| e.with_tool("prettier")) + edits.map_err(|e| e.with_tool(tool)) } #[cfg(test)] @@ -104,4 +117,19 @@ mod tests { assert!(!external_selected(ts.formatter.as_ref(), "typescript", &settings, false)); assert!(!external_selected(None, "rust", &settings, false)); } + + #[test] + fn oxfmt_config_selects_oxfmt_over_prettier() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("web/src")).unwrap(); + let (web, api) = (root.join("web/src/app.ts"), root.join("api/main.ts")); + assert_eq!(external_tool(&web), "prettier"); + for config in OXFMT_CONFIGS { + std::fs::write(root.join("web").join(config), "{}").unwrap(); + assert_eq!(external_tool(&web), "oxfmt", "{config}"); + assert_eq!(external_tool(&api), "prettier", "{config}"); + std::fs::remove_file(root.join("web").join(config)).unwrap(); + } + } } diff --git a/crates/ide-language/src/registry.rs b/crates/ide-language/src/registry.rs index 5ceaec74..58494359 100644 --- a/crates/ide-language/src/registry.rs +++ b/crates/ide-language/src/registry.rs @@ -576,7 +576,7 @@ mod tests { }; // After the TypeScript fallback (an available server before it would hide // it); before the HTML server (hover, rename and symbols take the first). - assert_eq!(names("typescript"), ["vtsls", "typescript-language-server", "angular-language-server"]); + assert_eq!(names("typescript"), ["vtsls", "typescript-language-server", "angular-language-server", "oxlint"]); assert_eq!(names("html"), ["angular-language-server", "vscode-html-language-server"]); for lang in ["typescript", "html"] { let cfg = reg.get(&LanguageId::new(lang)).unwrap(); @@ -586,6 +586,24 @@ mod tests { } } + #[test] + fn oxlint_serves_js_files_only_below_its_config() { + let reg = reg(); + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::create_dir_all(root.join("apps/web/src")).unwrap(); + std::fs::write(root.join("apps/web/.oxlintrc.json"), "{}").unwrap(); + for lang in ["javascript", "javascriptreact", "typescript", "typescriptreact", "vue", "svelte"] { + let cfg = reg.get(&LanguageId::new(lang)).unwrap(); + let oxlint = cfg.language_servers.iter().find(|s| s.name == "oxlint").unwrap(); + assert_eq!((oxlint.command.as_str(), oxlint.args.as_slice()), ("oxlint", ["--lsp".to_string()].as_slice())); + assert!(oxlint.require_root_marker && !oxlint.fallback, "{lang}"); + let served = |file: &str| crate::marked_root(Some(root), &root.join(file), &oxlint.root_markers); + assert_eq!(served("apps/web/src/main.ts"), Some(root.join("apps/web")), "{lang}"); + assert_eq!(served("apps/api/main.ts"), None, "{lang}"); + } + } + #[test] fn html_of_angular_projects_is_an_angular_template() { let reg = reg(); diff --git a/crates/ide-tools/src/formatter.rs b/crates/ide-tools/src/formatter.rs index 9b599f0b..95dd011b 100644 --- a/crates/ide-tools/src/formatter.rs +++ b/crates/ide-tools/src/formatter.rs @@ -13,20 +13,30 @@ pub fn find_up(file: &Path, relative: &Path) -> Option { file.parent()?.ancestors().map(|dir| dir.join(relative)).find(|p| p.is_file()) } -fn package_bin(package: &Path) -> IdeResult { +pub fn display_name(tool: &str) -> &str { + crate::registry::find(tool).map_or(tool, |t| t.display) +} + +fn package_bin(package: &Path, tool: &str) -> IdeResult { + let name = display_name(tool); let json: serde_json::Value = serde_json::from_slice(&std::fs::read(package.join("package.json"))?) - .map_err(|e| IdeError::invalid(format!("Prettier: {e}")))?; + .map_err(|e| IdeError::invalid(format!("{name}: {e}")))?; let bin = json["bin"] .as_str() - .or_else(|| json["bin"]["prettier"].as_str()) - .ok_or_else(|| IdeError::invalid("Prettier: package has no CLI entry point"))?; + .or_else(|| json["bin"][tool].as_str()) + .ok_or_else(|| IdeError::invalid(format!("{name}: package has no CLI entry point")))?; let path = package.join(bin); if !path.is_file() { - return Err(IdeError::unavailable(format!("Prettier: CLI does not exist: {}", path.display()))); + return Err(IdeError::unavailable(format!("{name}: CLI does not exist: {}", path.display()))); } Ok(path) } +/// Prettier formats a range for some languages (`config.range`); oxfmt formats whole files only. +pub fn supports_range(tool: &str, config: &FormatterConfig) -> bool { + tool == "prettier" && config.range +} + impl Tools { /// Explicit executable/script, then the nearest project package, then managed tools and PATH. pub fn formatter_command(&self, tool: &str, file: &Path, custom: Option<&Path>) -> IdeResult { @@ -34,22 +44,26 @@ impl Tools { } fn resolve_formatter_command(&self, tool: &str, file: &Path, custom: Option<&Path>) -> IdeResult { - if tool != "prettier" { + if crate::registry::find(tool).is_none_or(|t| t.kind != crate::ToolKind::Formatter) { return Err(IdeError::unavailable(format!("Unknown external formatter: {tool}"))); } + let package = Path::new("node_modules").join(tool); + let manifest = package.join("package.json"); let (path, node) = if let Some(custom) = custom.filter(|p| p.is_dir()) { - (package_bin(custom)?, true) + (package_bin(custom, tool)?, true) } else if let Some(custom) = custom { (custom.to_path_buf(), matches!(custom.extension().and_then(|s| s.to_str()), Some("js" | "cjs" | "mjs"))) - } else if let Some(manifest) = find_up(file, Path::new("node_modules/prettier/package.json")) { - (package_bin(manifest.parent().unwrap())?, true) - } else if self.root.join("node_modules/prettier/package.json").is_file() { - (package_bin(&self.root.join("node_modules/prettier"))?, true) + } else if let Some(manifest) = find_up(file, &manifest) { + (package_bin(manifest.parent().unwrap(), tool)?, true) + } else if self.root.join(&manifest).is_file() { + (package_bin(&self.root.join(&package), tool)?, true) } else { - (self.which("prettier").ok_or_else(|| IdeError::missing_tool("prettier"))?, false) + (self.which(tool).ok_or_else(|| IdeError::missing_tool(tool))?, false) }; if node { - let node = self.which("node").ok_or_else(|| IdeError::unavailable("Prettier: Node.js is required"))?; + let name = display_name(tool); + let node = + self.which("node").ok_or_else(|| IdeError::unavailable(format!("{name}: Node.js is required")))?; let mut cmd = self.command(node); cmd.arg(path); Ok(cmd) @@ -59,34 +73,40 @@ impl Tools { } } -/// Project configuration overrides these defaults; Prettier resolves config and plugins itself. +/// Both tools resolve their configuration and plugins themselves. Prettier's +/// project configuration overrides the editor indentation passed here; oxfmt has +/// no such flags and reads its configuration and `.editorconfig` only. pub fn arguments( + tool: &str, config: &FormatterConfig, file: &Path, tab_size: u32, insert_spaces: bool, range: Option<(u32, u32)>, ) -> Vec { - let parser = if config.parser == "markdown" && file.extension().is_some_and(|s| s == "mdx") { - "mdx" - } else { - &config.parser - }; - let mut args: Vec = [ - "--parser", - parser, - "--config-precedence", - "file-override", - "--tab-width", - &tab_size.max(1).to_string(), - "--use-tabs", - if insert_spaces { "false" } else { "true" }, - ] - .into_iter() - .map(Into::into) - .collect(); + let mut args: Vec = Vec::new(); + if tool == "prettier" { + let parser = if config.parser == "markdown" && file.extension().is_some_and(|s| s == "mdx") { + "mdx" + } else { + &config.parser + }; + args.extend( + [ + "--parser", + parser, + "--config-precedence", + "file-override", + "--tab-width", + &tab_size.max(1).to_string(), + "--use-tabs", + if insert_spaces { "false" } else { "true" }, + ] + .map(OsString::from), + ); + } args.extend(["--stdin-filepath".into(), file.as_os_str().to_owned()]); - if config.range + if supports_range(tool, config) && let Some((start, end)) = range { args.extend(["--range-start".into(), start.to_string().into(), "--range-end".into(), end.to_string().into()]); @@ -100,8 +120,8 @@ pub fn arguments( args } -pub fn run(mut command: Command, text: &str, cancel: &CancellationToken) -> IdeResult { - crate::process::run_input(&mut command, text, Duration::from_secs(15), cancel).map_err(|e| e.with_tool("prettier")) +pub fn run(tool: &str, mut command: Command, text: &str, cancel: &CancellationToken) -> IdeResult { + crate::process::run_input(&mut command, text, Duration::from_secs(15), cancel).map_err(|e| e.with_tool(tool)) } #[cfg(test)] @@ -118,7 +138,7 @@ mod tests { assert_eq!(missing.kind, ErrorKind::NotFound); assert_eq!(missing.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: true })); let custom = tools.formatter_command("prettier", &file, Some(&dir.path().join("absent-prettier"))).unwrap(); - let failure = run(custom, "text", &Default::default()).unwrap_err(); + let failure = run("prettier", custom, "text", &Default::default()).unwrap_err(); assert_eq!(failure.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: false })); } @@ -127,7 +147,7 @@ mod tests { fn stderr_is_preserved_verbatim_even_when_longer_than_four_thousand_characters() { let mut command = Command::new("sh"); command.args(["-c", "i=0; while [ $i -lt 1000 ]; do printf 'error\\n' >&2; i=$((i+1)); done; exit 1"]); - let failure = run(command, "", &Default::default()).unwrap_err(); + let failure = run("prettier", command, "", &Default::default()).unwrap_err(); assert_eq!(failure.message, "error\n".repeat(1000)); assert_eq!(failure.data, Some(ide_core::error::ErrorData::Tool { tool: "prettier".into(), missing: false })); } @@ -161,7 +181,8 @@ mod tests { ("vue", true), ] { let config = FormatterConfig { tool: "prettier".into(), parser: parser.into(), range: supports_range }; - let args = arguments(&config, Path::new("/project with spaces/file.md"), 4, false, Some((3, 9))); + let args = + arguments("prettier", &config, Path::new("/project with spaces/file.md"), 4, false, Some((3, 9))); let args: Vec<_> = args.iter().map(|a| a.to_string_lossy()).collect(); assert!(args.windows(2).any(|a| a == ["--config-precedence", "file-override"])); assert!(args.windows(2).any(|a| a == ["--stdin-filepath", "/project with spaces/file.md"])); @@ -171,4 +192,36 @@ mod tests { assert!(!args.contains(&"--write".into())); } } + + #[test] + fn oxfmt_runs_the_nearest_package_without_prettier_flags() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("node_modules/oxfmt/bin")).unwrap(); + std::fs::write(root.join("node_modules/oxfmt/package.json"), r#"{"bin":{"oxfmt":"bin/oxfmt"}}"#).unwrap(); + std::fs::write(root.join("node_modules/oxfmt/bin/oxfmt"), "").unwrap(); + std::fs::write(root.join(".prettierignore"), "dist").unwrap(); + let file = root.join("packages/web/src/app.ts"); + let path = std::env::var_os("PATH").unwrap_or_default(); + let tools = Tools::with_paths(root.join("tools"), path, crate::platform::Platform::current()); + + let command = tools.formatter_command("oxfmt", &file, None).unwrap(); + let program = Path::new(command.get_program()).file_stem().unwrap().to_string_lossy().into_owned(); + assert_eq!(program, "node"); + let script: Vec<_> = command.get_args().collect(); + assert_eq!(script, [root.join("node_modules/oxfmt/bin/oxfmt").as_os_str()]); + + let config = FormatterConfig { tool: "prettier".into(), parser: "typescript".into(), range: true }; + let args = arguments("oxfmt", &config, &file, 4, false, Some((3, 9))); + let ignore = root.join(".prettierignore").into_os_string(); + assert_eq!( + args[..4], + ["--stdin-filepath".into(), file.clone().into_os_string(), "--ignore-path".into(), ignore] + ); + assert!(args[4..].chunks(2).all(|pair| pair[0] == "--ignore-path"), "{args:?}"); + assert!(!supports_range("oxfmt", &config)); + + let unknown = tools.formatter_command("rust-analyzer", &file, None).unwrap_err(); + assert!(unknown.message.contains("Unknown external formatter"), "{}", unknown.message); + } } diff --git a/crates/ide-tools/src/registry.rs b/crates/ide-tools/src/registry.rs index 6dfcd5df..661a7170 100644 --- a/crates/ide-tools/src/registry.rs +++ b/crates/ide-tools/src/registry.rs @@ -134,6 +134,28 @@ pub static TOOLS: &[ToolSpec] = &[ needs: NODE, homepage: "https://prettier.io", }, + ToolSpec { + name: "oxfmt", + display: "oxfmt", + kind: Formatter, + commands: &["oxfmt"], + method: Method::Npm { packages: &["oxfmt"] }, + fallback: None, + version: ver("oxfmt"), + needs: NODE, + homepage: "https://oxc.rs/docs/guide/usage/formatter", + }, + ToolSpec { + name: "oxlint", + display: "oxlint", + kind: LanguageServer, + commands: &["oxlint"], + method: Method::Npm { packages: &["oxlint"] }, + fallback: None, + version: ver("oxlint"), + needs: NODE, + homepage: "https://oxc.rs/docs/guide/usage/linter", + }, ToolSpec { name: "rust-analyzer", display: "rust-analyzer", @@ -965,12 +987,8 @@ mod tests { known.insert((d.adapter.clone(), d.command.clone())); } } - for l in &langs { - if let Some(f) = &l.formatter { - known.insert((f.tool.clone(), f.tool.clone())); - } - } - for t in TOOLS.iter().filter(|t| t.kind != Plugin) { + // The host selects the formatter of each file (Prettier or oxfmt). + for t in TOOLS.iter().filter(|t| !matches!(t.kind, Plugin | Formatter)) { assert!( known.contains(&(t.name.to_string(), t.commands[0].to_string())), "{} / {} is not referenced by languages/*.toml", diff --git a/crates/ide-tools/tests/prettier.rs b/crates/ide-tools/tests/prettier.rs index 3d622caa..c09f6d59 100644 --- a/crates/ide-tools/tests/prettier.rs +++ b/crates/ide-tools/tests/prettier.rs @@ -19,8 +19,8 @@ fn format( }; let mut cmd = tools.formatter_command("prettier", path, None)?; cmd.current_dir(path.parent().unwrap()); - cmd.args(formatter::arguments(&config, path, 4, true, range)); - formatter::run(cmd, text, &CancellationToken::new()) + cmd.args(formatter::arguments("prettier", &config, path, 4, true, range)); + formatter::run("prettier", cmd, text, &CancellationToken::new()) } #[test] diff --git a/languages/javascript.toml b/languages/javascript.toml index b5676537..5f19df86 100644 --- a/languages/javascript.toml +++ b/languages/javascript.toml @@ -27,6 +27,14 @@ args = ["--stdio"] root-markers = ["jsconfig.json", "tsconfig.json", "package.json"] install-hint = "npm i -g typescript@6 typescript-language-server" +[[language-servers]] +name = "oxlint" +command = "oxlint" +args = ["--lsp"] +root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"] +require-root-marker = true +install-hint = "npm i -D oxlint" + [debugger] adapter = "js-debug" command = "js-debug-adapter" diff --git a/languages/javascriptreact.toml b/languages/javascriptreact.toml index 3c5a1776..be5b5ab9 100644 --- a/languages/javascriptreact.toml +++ b/languages/javascriptreact.toml @@ -26,6 +26,14 @@ args = ["--stdio"] root-markers = ["jsconfig.json", "tsconfig.json", "package.json"] install-hint = "npm i -g typescript@6 typescript-language-server" +[[language-servers]] +name = "oxlint" +command = "oxlint" +args = ["--lsp"] +root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"] +require-root-marker = true +install-hint = "npm i -D oxlint" + [debugger] adapter = "js-debug" command = "js-debug-adapter" diff --git a/languages/svelte.toml b/languages/svelte.toml index 41095ee1..b441d410 100644 --- a/languages/svelte.toml +++ b/languages/svelte.toml @@ -13,3 +13,11 @@ command = "svelteserver" args = ["--stdio"] root-markers = ["svelte.config.js", "svelte.config.mjs", "svelte.config.ts", "package.json"] install-hint = "npm i -g svelte-language-server" + +[[language-servers]] +name = "oxlint" +command = "oxlint" +args = ["--lsp"] +root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"] +require-root-marker = true +install-hint = "npm i -D oxlint" diff --git a/languages/typescript.toml b/languages/typescript.toml index 41817c5c..0dedc17a 100644 --- a/languages/typescript.toml +++ b/languages/typescript.toml @@ -37,6 +37,14 @@ root-markers = ["angular.json", "node_modules/@angular/core"] require-root-marker = true install-hint = "npm i -g @angular/language-server" +[[language-servers]] +name = "oxlint" +command = "oxlint" +args = ["--lsp"] +root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"] +require-root-marker = true +install-hint = "npm i -D oxlint" + [debugger] adapter = "js-debug" command = "js-debug-adapter" diff --git a/languages/typescriptreact.toml b/languages/typescriptreact.toml index ad238c6c..d7ecf0b8 100644 --- a/languages/typescriptreact.toml +++ b/languages/typescriptreact.toml @@ -26,6 +26,14 @@ args = ["--stdio"] root-markers = ["tsconfig.json", "jsconfig.json", "package.json"] install-hint = "npm i -g typescript@6 typescript-language-server" +[[language-servers]] +name = "oxlint" +command = "oxlint" +args = ["--lsp"] +root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"] +require-root-marker = true +install-hint = "npm i -D oxlint" + [debugger] adapter = "js-debug" command = "js-debug-adapter" diff --git a/languages/vue.toml b/languages/vue.toml index e260bce7..247f0930 100644 --- a/languages/vue.toml +++ b/languages/vue.toml @@ -22,3 +22,11 @@ command = "vtsls" args = ["--stdio"] root-markers = ["tsconfig.json", "jsconfig.json", "package.json"] install-hint = "npm i -g @vtsls/language-server" + +[[language-servers]] +name = "oxlint" +command = "oxlint" +args = ["--lsp"] +root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"] +require-root-marker = true +install-hint = "npm i -D oxlint" diff --git a/src/editor/lsp/format.ts b/src/editor/lsp/format.ts index 061e377c..5d3031c3 100644 --- a/src/editor/lsp/format.ts +++ b/src/editor/lsp/format.ts @@ -118,19 +118,22 @@ async function format( try { edits = await lspRequest(() => commands.lspFormat(doc.doc, range, { ...options, onSave: opts.onSave })); } catch (e) { - if (e instanceof IpcError && e.data?.type === "tool" && e.data.tool === "prettier") { + if (e instanceof IpcError && e.data?.type === "tool" && (e.data.tool === "prettier" || e.data.tool === "oxfmt")) { + const tool = e.data.tool; if (e.kind === "notFound" && e.data.missing) { - await executeAction("tools.promptInstall", { name: "prettier", sticky: !opts.onSave }); + await executeAction("tools.promptInstall", { name: tool, sticky: !opts.onSave }); } else if (e.kind !== "cancelled") { notifications().notify({ group: "tools", level: "error", - title: tx("Prettier formatting failed"), + title: tool === "oxfmt" ? tx("oxfmt formatting failed") : tx("Prettier formatting failed"), body: e.kind === "timedOut" - ? tx("Prettier formatting timed out") + ? tool === "oxfmt" + ? tx("oxfmt formatting timed out") + : tx("Prettier formatting timed out") : createElement("span", { className: styles.diagnostics }, e.message), - key: `prettier-format-error:${doc.path}`, + key: `${tool}-format-error:${doc.path}`, sticky: false, }); } diff --git a/src/editor/lsp/formatErrors.test.tsx b/src/editor/lsp/formatErrors.test.tsx index aa21c997..3107316b 100644 --- a/src/editor/lsp/formatErrors.test.tsx +++ b/src/editor/lsp/formatErrors.test.tsx @@ -18,7 +18,6 @@ import { import { formatDocument } from "./format"; import * as lsp from "./lsp"; -const tool = { type: "tool", tool: "prettier", missing: false } as const; const views: EditorView[] = []; const viewFor = (path = "/project/file.css") => { const view = new EditorView({ @@ -43,7 +42,9 @@ afterEach(() => { _resetI18nForTests(); }); -describe("external formatter errors", () => { +describe.each(["prettier", "oxfmt"])("%s formatter errors", (name) => { + const tool = { type: "tool", tool: name, missing: false } as const; + const display = name === "prettier" ? "Prettier" : name; it("uses structured missing-tool information to request the shared install prompt", async () => { const prompt = vi.fn(); const dispose = registerAction({ @@ -54,9 +55,9 @@ describe("external formatter errors", () => { try { failWith({ kind: "notFound", message: "unrelated diagnostic", data: { ...tool, missing: true } }); await formatDocument(viewFor()); - expect(prompt).toHaveBeenCalledWith({ name: "prettier", sticky: true }); + expect(prompt).toHaveBeenCalledWith({ name, sticky: true }); await formatDocument(viewFor(), { explicit: false, onSave: true }); - expect(prompt).toHaveBeenLastCalledWith({ name: "prettier", sticky: false }); + expect(prompt).toHaveBeenLastCalledWith({ name, sticky: false }); expect(useNotifications.getState().items).toHaveLength(0); } finally { dispose(); @@ -95,7 +96,7 @@ describe("external formatter errors", () => { for (let i = 0; i < 3; i++) await formatDocument(view, { explicit: false, onSave: true }); const notes = useNotifications.getState().items; expect(notes).toHaveLength(1); - expect(notes[0]).toMatchObject({ sticky: false, title: "Prettier 格式化失败" }); + expect(notes[0]).toMatchObject({ sticky: false, title: `${display} 格式化失败` }); expect(balloonTimeout(notes[0]!)).toBe(10000); render(); expect(screen.getByRole("alert")).toHaveTextContent(stderr); @@ -115,7 +116,7 @@ describe("external formatter errors", () => { const request = failWith({ kind: "timedOut", message: "arbitrary backend text", data: tool }); const view = viewFor(); await formatDocument(view); - expect(useNotifications.getState().items[0]).toMatchObject({ body: "Prettier 格式化超时", sticky: false }); + expect(useNotifications.getState().items[0]).toMatchObject({ body: `${display} 格式化超时`, sticky: false }); clearNotifications(); request.mockRejectedValue(new IpcError({ kind: "cancelled", message: "cancelled", data: tool })); await formatDocument(view); diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index fec979fe..811644d7 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1614,16 +1614,18 @@ "Install Missing Tool": "安装缺失的工具", "Prettier formatting failed": "Prettier 格式化失败", "Prettier formatting timed out": "Prettier 格式化超时", - "Prettier does not support selection formatting for this language; formatted the whole file": "Prettier 不支持此语言的选区格式化,已格式化整个文件", + "oxfmt formatting failed": "oxfmt 格式化失败", + "oxfmt formatting timed out": "oxfmt 格式化超时", + "{name} does not support selection formatting for this language; formatted the whole file": "{name} 不支持此语言的选区格式化,已格式化整个文件", "Formatting timed out": "格式化超时", - "Prettier: Node.js is required": "Prettier:需要 Node.js", + "{name}: Node.js is required": "{name}:需要 Node.js", "Formatter": "格式化工具", "JSON with Comments": "带注释的 JSON", "Unknown external formatter: {tool}": "未知的外部格式化工具:{tool}", - "Prettier: package has no CLI entry point": "Prettier:软件包未提供命令行入口", - "Prettier: CLI does not exist: {path}": "Prettier:命令行程序不存在:{path}", - "Prettier: file has no directory": "Prettier:文件路径不含目录", - "Prettier: {error}": "Prettier:{error}", + "{name}: package has no CLI entry point": "{name}:软件包未提供命令行入口", + "{name}: CLI does not exist: {path}": "{name}:命令行程序不存在:{path}", + "{name}: file has no directory": "{name}:文件路径不含目录", + "{name}: {error}": "{name}:{error}", "Reformat File": "重新格式化文件", "Reformat code": "重新格式化代码", "Reformat code on save": "保存时重新格式化代码",