From c5727141a03f8406aa0f6c98b44ff0bd8a3a944e Mon Sep 17 00:00:00 2001 From: d0whc3r <1378986+d0whc3r@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:54:55 +0100 Subject: [PATCH] feat(format): make oxfmt a first-class formatter next to Prettier (#1010) oxfmt ran only below an oxfmt configuration and reused the Prettier settings. It now has its own settings group and page (path, enabled languages, formatting provider, run on save) next to Prettier. For a language with an external formatter, oxfmt formats it when enabled for that language and either an oxfmt configuration is found above the file or oxfmt is the selected provider; otherwise Prettier, when enabled. Turning Prettier off for a language still leaves it to the language server. New Reformat with Prettier / Reformat with oxfmt commands run that tool regardless of the settings and report languages it cannot format. Co-authored-by: d0whc3r <1378986+d0whc3r@users.noreply.github.com> --- crates/ide-core/src/settings.rs | 37 +++- crates/ide-host/src/formatter.rs | 158 ++++++++++++------ crates/ide-host/tests/light_edit.rs | 2 +- crates/ide-lsp/src/types.rs | 5 + crates/ide-lsp/tests/mock_server.rs | 13 +- crates/ide-lsp/tests/real_servers.rs | 11 +- src/bindings.ts | 27 +-- src/editor/lsp/actions.ts | 33 +++- src/editor/lsp/format.test.ts | 13 ++ src/editor/lsp/format.ts | 24 ++- src/editor/lsp/formatErrors.test.tsx | 30 +++- .../settings/__tests__/languages.test.ts | 11 ++ .../settings/languages/FormatterPage.tsx | 99 +++++++++++ .../settings/languages/PrettierPage.tsx | 72 -------- src/features/settings/pages/languages.tsx | 12 +- src/platform/languageSettings.ts | 80 +++++---- src/platform/locales/text/zh.json | 5 + src/platform/locales/textCatalog.test.ts | 1 + 18 files changed, 441 insertions(+), 192 deletions(-) create mode 100644 src/features/settings/languages/FormatterPage.tsx delete mode 100644 src/features/settings/languages/PrettierPage.tsx diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 69542e593..9008406b8 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -547,7 +547,8 @@ pub struct LanguagesSettings { pub rust: RustSettings, pub go: GoSettings, pub typescript: TypeScriptSettings, - pub prettier: PrettierSettings, + pub prettier: ExternalFormatterSettings, + pub oxfmt: ExternalFormatterSettings, pub java: JavaSettings, pub ruby: RubySettings, pub markdown: MarkdownSettings, @@ -561,18 +562,46 @@ pub struct MarkdownSettings { pub preview_font_size: Option, } +impl LanguagesSettings { + pub fn formatter(&self, tool: ExternalFormatter) -> &ExternalFormatterSettings { + match tool { + ExternalFormatter::Prettier => &self.prettier, + ExternalFormatter::Oxfmt => &self.oxfmt, + } + } +} + +/// A formatter that runs outside the language servers, with its own settings group. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "lowercase")] +pub enum ExternalFormatter { + Prettier, + Oxfmt, +} + +impl ExternalFormatter { + /// The name of the tool in the tool registry. + pub fn name(self) -> &'static str { + match self { + Self::Prettier => "prettier", + Self::Oxfmt => "oxfmt", + } + } +} + +/// An external formatter (Prettier, oxfmt) and the languages it formats. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] -pub struct PrettierSettings { +pub struct ExternalFormatterSettings { /// Empty path: project node_modules, managed installation, then PATH. pub path: String, pub languages: Vec, - /// Prefer Prettier even when a language server offers formatting. + /// Prefer this formatter even when a language server offers formatting. pub prefer: bool, pub run_on_save: bool, } -impl Default for PrettierSettings { +impl Default for ExternalFormatterSettings { fn default() -> Self { Self { path: String::new(), diff --git a/crates/ide-host/src/formatter.rs b/crates/ide-host/src/formatter.rs index 054497b37..59e9ffeae 100644 --- a/crates/ide-host/src/formatter.rs +++ b/crates/ide-host/src/formatter.rs @@ -2,26 +2,48 @@ use crate::Host; use ide_core::event::NotifyLevel; -use ide_core::settings::PrettierSettings; +use ide_core::settings::{ExternalFormatter, LanguagesSettings}; use ide_core::{DocId, IdeError, IdeEvent, IdeResult, Range, TextEdit}; -use ide_language::FormatterConfig; +use ide_language::LanguageConfig; 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") - && settings.languages.iter().any(|l| l == language) - && (settings.prefer || !lsp) +/// The external formatter of a file, for a language with an external formatter. +/// `forced` names the tool (Reformat with Prettier/oxfmt). Otherwise oxfmt +/// applies when it is enabled for the language and either an oxfmt +/// configuration is found above the file or oxfmt is the selected provider; +/// else Prettier, when it is enabled. Turning Prettier off for a language +/// leaves it to the language server, not to oxfmt. +fn external_tool( + forced: Option, + language: Option<&LanguageConfig>, + file: &Path, + settings: &LanguagesSettings, +) -> IdeResult> { + use ExternalFormatter::{Oxfmt, Prettier}; + let Some(language) = language.filter(|l| l.formatter.as_ref().is_some_and(|f| f.tool == "prettier")) else { + return match forced { + Some(tool) => Err(IdeError::invalid(format!( + "{} cannot format {} files", + formatter::display_name(tool.name()), + language.map_or("these", |l| l.name.as_str()) + ))), + None => Ok(None), + }; + }; + if forced.is_some() { + return Ok(forced); + } + let enabled = |tool| settings.formatter(tool).languages.iter().any(|l| l == language.id.as_str()); + let oxfmt_config = || OXFMT_CONFIGS.iter().any(|name| formatter::find_up(file, Path::new(name)).is_some()); + Ok(if enabled(Oxfmt) && (settings.oxfmt.prefer || oxfmt_config()) { + Some(Oxfmt) + } else { + enabled(Prettier).then_some(Prettier) + }) } pub async fn format( @@ -34,29 +56,33 @@ pub async fn format( let project = host.project(window)?; let snap = project.documents.snapshot(doc)?; let settings = project.settings.effective(); - let prettier = &settings.languages.prettier; let language = project.languages.get(&snap.language); - let config = language.as_ref().and_then(|l| l.formatter.as_ref()); - let candidate = - config.is_some_and(|c| c.tool == "prettier") && prettier.languages.iter().any(|l| l == snap.language.as_str()); - let lsp = if candidate && !prettier.prefer { project.lsp.can_format(doc).await? } else { false }; - if !external_selected(config, snap.language.as_str(), prettier, lsp) { + let mut selected = external_tool(options.tool, language.as_ref(), &snap.path, &settings.languages)?; + if let Some(tool) = selected + && options.tool.is_none() + && !settings.languages.formatter(tool).prefer + && project.lsp.can_format(doc).await? + { + selected = None; + } + let Some(selected) = selected else { if options.on_save && !settings.editor.format_on_save { return Ok(Vec::new()); } return project.lsp.format(doc, range, options).await; - } - if options.on_save && !prettier.run_on_save { + }; + let tool_settings = settings.languages.formatter(selected); + if options.on_save && !tool_settings.run_on_save { return Ok(Vec::new()); } host.require_trust(window)?; if snap.read_only { return Err(IdeError::invalid("The file is read-only")); } - let config = config.unwrap().clone(); - let tool = external_tool(&snap.path); + let config = language.and_then(|l| l.formatter).expect("external_tool selects languages with a formatter"); + let tool = selected.name(); let name = formatter::display_name(tool); - let custom = ide_sdk::apply::setting_path(&project.root, &prettier.path).filter(|_| tool == "prettier"); + let custom = ide_sdk::apply::setting_path(&project.root, &tool_settings.path); let tools = Tools::new(&host.local_data_dir); let whole_selection = range.is_some() && !formatter::supports_range(tool, &config); let edits: IdeResult> = tokio::task::spawn_blocking(move || { @@ -94,42 +120,76 @@ pub async fn format( #[cfg(test)] mod tests { use super::*; + use ExternalFormatter::{Oxfmt, Prettier}; + use ide_core::LanguageId; + use ide_language::LanguageRegistry; - #[test] - fn language_enablement_and_preference_preserve_lsp_formatters() { - let languages = ide_language::LanguageRegistry::load(None).unwrap(); - let mut settings = PrettierSettings::default(); - for lang in languages.all() { - for lsp in [false, true] { - let configured = lang.formatter.as_ref().is_some_and(|f| f.tool == "prettier"); - assert_eq!( - external_selected(lang.formatter.as_ref(), lang.id.as_str(), &settings, lsp), - configured && !lsp, - "{}", - lang.id - ); - } - } - let ts = languages.get(&ide_core::LanguageId::new("typescript")).unwrap(); - settings.prefer = true; - assert!(external_selected(ts.formatter.as_ref(), "typescript", &settings, true)); - settings.languages.clear(); - assert!(!external_selected(ts.formatter.as_ref(), "typescript", &settings, false)); - assert!(!external_selected(None, "rust", &settings, false)); + fn tool( + forced: Option, + language: &str, + file: &Path, + settings: &LanguagesSettings, + ) -> IdeResult> { + let language = LanguageRegistry::load(None).unwrap().get(&LanguageId::new(language)); + external_tool(forced, language.as_ref(), file, settings) } #[test] - fn oxfmt_config_selects_oxfmt_over_prettier() { + fn default_settings_give_every_external_formatter_language_to_prettier() { + let settings = LanguagesSettings::default(); + let file = Path::new("/project/file"); + for lang in LanguageRegistry::load(None).unwrap().all() { + let configured = lang.formatter.as_ref().is_some_and(|f| f.tool == "prettier"); + let selected = external_tool(None, Some(&lang), file, &settings).unwrap(); + assert_eq!(selected, configured.then_some(Prettier), "{}", lang.id); + } + assert_eq!(external_tool(None, None, file, &settings).unwrap(), None); + } + + #[test] + fn oxfmt_config_selects_oxfmt_for_its_enabled_languages() { 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"); + let mut settings = LanguagesSettings::default(); + assert_eq!(tool(None, "typescript", &web, &settings).unwrap(), Some(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}"); + assert_eq!(tool(None, "typescript", &web, &settings).unwrap(), Some(Oxfmt), "{config}"); + assert_eq!(tool(None, "typescript", &api, &settings).unwrap(), Some(Prettier), "{config}"); std::fs::remove_file(root.join("web").join(config)).unwrap(); } + std::fs::write(root.join("web/.oxfmtrc.json"), "{}").unwrap(); + settings.oxfmt.languages.retain(|l| l != "markdown"); + assert_eq!(tool(None, "markdown", &web, &settings).unwrap(), Some(Prettier)); + assert_eq!(tool(None, "typescript", &web, &settings).unwrap(), Some(Oxfmt)); + } + + #[test] + fn oxfmt_provider_selects_oxfmt_without_a_config_and_prettier_off_leaves_the_language_server() { + let file = Path::new("/project/src/app.ts"); + let mut settings = LanguagesSettings::default(); + settings.oxfmt.prefer = true; + assert_eq!(tool(None, "typescript", file, &settings).unwrap(), Some(Oxfmt)); + settings.oxfmt.prefer = false; + settings.prettier.languages.clear(); + assert_eq!(tool(None, "typescript", file, &settings).unwrap(), None); + settings.oxfmt.prefer = true; + settings.oxfmt.languages.clear(); + assert_eq!(tool(None, "typescript", file, &settings).unwrap(), None); + } + + #[test] + fn forced_tool_runs_that_formatter_or_reports_why_not() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join(".oxfmtrc.json"), "{}").unwrap(); + let file = dir.path().join("app.ts"); + let mut settings = LanguagesSettings::default(); + settings.oxfmt.languages.clear(); + assert_eq!(tool(Some(Prettier), "typescript", &file, &settings).unwrap(), Some(Prettier)); + assert_eq!(tool(Some(Oxfmt), "typescript", &file, &settings).unwrap(), Some(Oxfmt)); + let rust = tool(Some(Oxfmt), "rust", &dir.path().join("main.rs"), &settings).unwrap_err(); + assert_eq!(rust.message, "oxfmt cannot format Rust files"); } } diff --git a/crates/ide-host/tests/light_edit.rs b/crates/ide-host/tests/light_edit.rs index 99f2e96c2..5fa245536 100644 --- a/crates/ide-host/tests/light_edit.rs +++ b/crates/ide-host/tests/light_edit.rs @@ -116,7 +116,7 @@ async fn light_windows_stay_restricted_when_folder_trust_changes() { "main", doc.id, None, - ide_lsp::types::FormatOptions { tab_size: 2, insert_spaces: true, on_save: false }, + ide_lsp::types::FormatOptions { tab_size: 2, insert_spaces: true, on_save: false, tool: None }, ) .await .unwrap_err(); diff --git a/crates/ide-lsp/src/types.rs b/crates/ide-lsp/src/types.rs index 050d493d1..047aabf4e 100644 --- a/crates/ide-lsp/src/types.rs +++ b/crates/ide-lsp/src/types.rs @@ -1,6 +1,7 @@ //! IDE-side, IPC-stable equivalents of the LSP types we surface. use ide_core::event::ServerStatus; +use ide_core::settings::ExternalFormatter; use ide_core::{Location, Range, ServerId, SymbolKind, TextEdit, WorkspaceEdit}; use serde::{Deserialize, Serialize}; use specta::Type; @@ -222,6 +223,10 @@ pub struct FormatOptions { #[serde(default)] #[specta(optional)] pub on_save: bool, + /// External formatter to run whatever the settings select. + #[serde(default)] + #[specta(optional)] + pub tool: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index a535caee8..e7f110401 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -1463,13 +1463,20 @@ async fn navigation_rename_format_and_decorations() { assert!(h.mgr.can_format(doc).await.unwrap()); s.respond("textDocument/formatting", json!([{ "range": r(0,10,0,11), "newText": "\n}" }])); - let edits = - h.mgr.format(doc, None, FormatOptions { tab_size: 2, insert_spaces: true, on_save: false }).await.unwrap(); + let edits = h + .mgr + .format(doc, None, FormatOptions { tab_size: 2, insert_spaces: true, on_save: false, tool: None }) + .await + .unwrap(); assert_eq!(edits[0].range, rng(0, 10, 0, 11)); assert_eq!(s.requested("textDocument/formatting")[0]["options"], json!({ "tabSize": 2, "insertSpaces": true })); assert!( h.mgr - .format(doc, Some(rng(0, 0, 1, 0)), FormatOptions { tab_size: 2, insert_spaces: true, on_save: false }) + .format( + doc, + Some(rng(0, 0, 1, 0)), + FormatOptions { tab_size: 2, insert_spaces: true, on_save: false, tool: None } + ) .await .is_err() ); diff --git a/crates/ide-lsp/tests/real_servers.rs b/crates/ide-lsp/tests/real_servers.rs index 4ed350101..7ed98d3bf 100644 --- a/crates/ide-lsp/tests/real_servers.rs +++ b/crates/ide-lsp/tests/real_servers.rs @@ -101,8 +101,13 @@ async fn intellij_java_formatter_preserves_manual_breaks_and_wraps_long_lines() async move { matches!(status, Some((ServerStatus::Ready | ServerStatus::Busy, _))).then_some(()) } }) .await; - let edits = - mgr.format(doc, None, ide_lsp::types::FormatOptions { tab_size: 4, insert_spaces: true, on_save: false }).await; + let edits = mgr + .format( + doc, + None, + ide_lsp::types::FormatOptions { tab_size: 4, insert_spaces: true, on_save: false, tool: None }, + ) + .await; tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap(); let mut document = ide_text::Document::new(source); document.apply_edits(&edits.unwrap()).unwrap(); @@ -234,7 +239,7 @@ async fn ruff_next_to_basedpyright_end_to_end() { .await; assert!(codes.iter().any(|c| c == "F401") && codes.iter().any(|c| c == "I001"), "{codes:?}"); - let options = ide_lsp::types::FormatOptions { tab_size: 4, insert_spaces: true, on_save: false }; + let options = ide_lsp::types::FormatOptions { tab_size: 4, insert_spaces: true, on_save: false, tool: None }; let edits = mgr.format(doc, None, options).await.unwrap(); assert!(!edits.is_empty() && edits.iter().all(|e| e.range.start.line == 3), "{edits:?}"); diff --git a/src/bindings.ts b/src/bindings.ts index 14f693f90..fd9ac8c37 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -3097,6 +3097,19 @@ export type ExpressionAt = { hasError: boolean, }; +/** A formatter that runs outside the language servers, with its own settings group. */ +export type ExternalFormatter = "prettier" | "oxfmt"; + +/** An external formatter (Prettier, oxfmt) and the languages it formats. */ +export type ExternalFormatterSettings = { + /** Empty path: project node_modules, managed installation, then PATH. */ + path?: string, + languages?: string[], + /** Prefer this formatter even when a language server offers formatting. */ + prefer?: boolean, + runOnSave?: boolean, +}; + /** Tools › External Tools entry. */ export type ExternalTool = { id?: string, @@ -3286,6 +3299,8 @@ export type FormatOptions = { tabSize: number, insertSpaces: boolean, onSave?: boolean, + /** External formatter to run whatever the settings select. */ + tool?: ExternalFormatter | null, }; /** One occurrence of a duplicated code fragment. */ @@ -4126,7 +4141,8 @@ export type LanguagesSettings = { rust?: RustSettings, go?: GoSettings, typescript?: TypeScriptSettings, - prettier?: PrettierSettings, + prettier?: ExternalFormatterSettings, + oxfmt?: ExternalFormatterSettings, java?: JavaSettings, ruby?: RubySettings, markdown?: MarkdownSettings, @@ -4891,15 +4907,6 @@ export type PreparedRequest = { body: string | null, }; -export type PrettierSettings = { - /** Empty path: project node_modules, managed installation, then PATH. */ - path?: string, - languages?: string[], - /** Prefer Prettier even when a language server offers formatting. */ - prefer?: boolean, - runOnSave?: boolean, -}; - /** Memory of one row of the breakdown. */ export type ProcessMemory = { category: MemoryCategory, diff --git a/src/editor/lsp/actions.ts b/src/editor/lsp/actions.ts index f774f6779..2765aca57 100644 --- a/src/editor/lsp/actions.ts +++ b/src/editor/lsp/actions.ts @@ -6,6 +6,7 @@ import { activeEditor, editorsFor, registerSaveParticipant, type TextEditorHandl import { maybeEditorService } from "@/workbench/contributions"; import { useProject } from "@/platform/project"; import { validateLanguages } from "@/platform/languageSettings"; +import type { Settings } from "@/bindings"; import { explicitCompletion } from "./completion"; import { triggerSignatureHelp } from "./signature"; import { quickDefinition, quickDocumentation } from "./hover"; @@ -117,20 +118,22 @@ function editorAction( return { id, title, category, when: EDITOR_WHEN, run: withEditor(run), ...extra }; } +/** Whether saving a `language` file formats it: Format on save, or Run on save of Prettier or oxfmt. */ +export function formatsOnSave(settings: Settings | null, language: string): boolean { + const { prettier, oxfmt } = validateLanguages(settings?.languages); + return ( + !!settings?.editor?.formatOnSave || + [prettier, oxfmt].some((formatter) => formatter.runOnSave && formatter.languages.includes(language)) + ); +} + /** Format on save (settings.editor.formatOnSave), run by editor/core before writing. */ export function registerFormatOnSave(): () => void { return registerSaveParticipant({ id: "lsp.formatOnSave", order: 50, beforeSave: async (ctx, handle) => { - const settings = useProject.getState().settings; - const prettier = validateLanguages(settings?.languages).prettier; - if ( - !handle || - ctx.readOnly || - (!settings?.editor?.formatOnSave && !(prettier.runOnSave && prettier.languages.includes(ctx.language))) - ) - return; + if (!handle || ctx.readOnly || !formatsOnSave(useProject.getState().settings, ctx.language)) return; await formatDocument(handle.view, { explicit: false, selectionOnly: false, onSave: true }); }, }); @@ -183,6 +186,20 @@ export function registerLspActions(): () => void { editorAction("code.reformat", "Reformat Code", "Code", reformat, { keywords: ["format", "prettier"], }), + editorAction( + "code.reformatWithPrettier", + "Reformat with Prettier", + "Code", + (v) => formatDocument(v, { explicit: true, tool: "prettier" }), + { keywords: ["format", "prettier"] }, + ), + editorAction( + "code.reformatWithOxfmt", + "Reformat with oxfmt", + "Code", + (v) => formatDocument(v, { explicit: true, tool: "oxfmt" }), + { keywords: ["format", "oxfmt", "oxc"] }, + ), editorAction("code.reformatDialog", "Reformat File…", "Code", (v) => reformatDialog(v, (view) => applyKindSilently(view, "source.organizeImports")), ), diff --git a/src/editor/lsp/format.test.ts b/src/editor/lsp/format.test.ts index 71f46be70..503fc8bdb 100644 --- a/src/editor/lsp/format.test.ts +++ b/src/editor/lsp/format.test.ts @@ -13,6 +13,7 @@ import { } from "@/platform/formatGuard"; import { indentExtension } from "@/editor/core/cm/setup"; import { dedentsToColumnZero, formatDocument, formatOptionsOf, formattedLines } from "./format"; +import { formatsOnSave } from "./actions"; describe("formatOptionsOf", () => { const stateWith = (...args: Parameters) => @@ -168,4 +169,16 @@ describe("format on save provider isolation", () => { useProject.setState(previous); } }); + + it("formats on save for the languages whose Prettier or oxfmt Run on save is on", () => { + const settings = (languages: Settings["languages"], formatOnSave = false) => + ({ editor: { formatOnSave }, languages }) as Settings; + const oxfmt = settings({ oxfmt: { runOnSave: true, languages: ["typescript"] } }); + expect(formatsOnSave(oxfmt, "typescript")).toBe(true); + expect(formatsOnSave(oxfmt, "markdown")).toBe(false); + expect(formatsOnSave(settings({ prettier: { runOnSave: true } }), "markdown")).toBe(true); + expect(formatsOnSave(settings({}), "typescript")).toBe(false); + expect(formatsOnSave(settings({}, true), "rust")).toBe(true); + expect(formatsOnSave(null, "typescript")).toBe(false); + }); }); diff --git a/src/editor/lsp/format.ts b/src/editor/lsp/format.ts index 61f4a3b5c..89c38ffa2 100644 --- a/src/editor/lsp/format.ts +++ b/src/editor/lsp/format.ts @@ -11,6 +11,7 @@ import { indentUnit } from "@codemirror/language"; import { isolateHistory } from "@codemirror/commands"; import type { EditorState, Text } from "@codemirror/state"; import type { EditorView } from "@codemirror/view"; +import type { ExternalFormatter } from "@/bindings"; import { commands, IpcError } from "@/platform/ipc"; import { notifications } from "@/workbench/contributions"; import { shortcutLabel } from "@/platform/keymap"; @@ -89,19 +90,24 @@ export function formatOptionsOf(state: EditorState): { tabSize: number; insertSp : { tabSize: unit.length || state.tabSize, insertSpaces: true }; } +interface FormatDocumentOptions { + explicit: boolean; + selectionOnly?: boolean; + onSave?: boolean; + /** Runs this external formatter whatever the settings select (Reformat with Prettier/oxfmt). */ + tool?: ExternalFormatter; +} + export async function formatDocument( view: EditorView, - opts: { explicit: boolean; selectionOnly?: boolean; onSave?: boolean } = { explicit: true }, + opts: FormatDocumentOptions = { explicit: true }, ): Promise { const result = await format(view, opts); if (opts.explicit && result.applied) formattedHint(view, result.lines); return result; } -async function format( - view: EditorView, - opts: { explicit: boolean; selectionOnly?: boolean; onSave?: boolean }, -): Promise { +async function format(view: EditorView, opts: FormatDocumentOptions): Promise { const { project, settings } = useProject.getState(); const guard = formatGuardOptions(settings); const path = docOf(view)?.path; @@ -111,7 +117,7 @@ async function format( return NOTHING; } const own = contributedAt(view.state, CONTRIBUTED_FORMAT, 0); - if (own) { + if (own && !opts.tool) { if (opts.onSave && !settings?.editor?.formatOnSave) return NOTHING; return applyContributedFormat(view, own, opts, guard); } @@ -126,7 +132,9 @@ async function format( const options = formatOptionsOf(state); let edits; try { - edits = await lspRequest(() => commands.lspFormat(doc.doc, range, { ...options, onSave: opts.onSave })); + edits = await lspRequest(() => + commands.lspFormat(doc.doc, range, { ...options, onSave: opts.onSave, tool: opts.tool }), + ); } catch (e) { if (e instanceof IpcError && e.data?.type === "tool" && (e.data.tool === "prettier" || e.data.tool === "oxfmt")) { const tool = e.data.tool; @@ -217,7 +225,7 @@ export async function formatLines(view: EditorView, from: number, to: number): P function applyContributedFormat( view: EditorView, format: ContributedFormatter, - opts: { explicit: boolean; selectionOnly?: boolean; onSave?: boolean }, + opts: FormatDocumentOptions, guard: FormatGuardOptions, ): FormatResult { const state = view.state; diff --git a/src/editor/lsp/formatErrors.test.tsx b/src/editor/lsp/formatErrors.test.tsx index 3107316b9..732ede238 100644 --- a/src/editor/lsp/formatErrors.test.tsx +++ b/src/editor/lsp/formatErrors.test.tsx @@ -3,9 +3,11 @@ import { act, render, screen } from "@testing-library/react"; import { EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import type { IdeError } from "@/bindings"; -import { registerAction } from "@/platform/actions"; +import { getAction, registerAction } from "@/platform/actions"; +import { commands, IpcError } from "@/platform/ipc"; +import { useEditorRegistry } from "@/editor/api"; +import { registerLspActions } from "./actions"; import { _resetI18nForTests, setLocale } from "@/platform/i18n"; -import { IpcError } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { provideNotificationService } from "@/workbench/contributions"; import { Balloons } from "@/workbench/Notifications"; @@ -123,3 +125,27 @@ describe.each(["prettier", "oxfmt"])("%s formatter errors", (name) => { expect(useNotifications.getState().items).toHaveLength(0); }); }); + +describe("Reformat with Prettier / oxfmt", () => { + it.each([ + ["code.reformatWithPrettier", "prettier"], + ["code.reformatWithOxfmt", "oxfmt"], + ])("%s asks the backend for %s whatever the settings select", async (id, tool) => { + vi.spyOn(lsp, "lspRequest").mockImplementation(async (request) => { + await request(); + return [] as never; + }); + const lspFormat = vi.spyOn(commands, "lspFormat").mockResolvedValue({ status: "ok", data: [] }); + const view = viewFor("/project/app.ts"); + const previous = useEditorRegistry.getState().active; + const dispose = registerLspActions(); + try { + useEditorRegistry.setState({ active: { view, ctx: view.state.facet(lsp.lspDoc)! } as never }); + await getAction(id)!.run({ ctx: {} } as never); + await vi.waitFor(() => expect(lspFormat).toHaveBeenCalledWith(1, null, expect.objectContaining({ tool }))); + } finally { + dispose(); + useEditorRegistry.setState({ active: previous }); + } + }); +}); diff --git a/src/features/settings/__tests__/languages.test.ts b/src/features/settings/__tests__/languages.test.ts index 670e882d8..a53555f33 100644 --- a/src/features/settings/__tests__/languages.test.ts +++ b/src/features/settings/__tests__/languages.test.ts @@ -53,6 +53,16 @@ describe("languages settings", () => { expect(validateLanguages({ prettier: { languages: [] } }).prettier.languages).toEqual([]); }); + it("gives oxfmt the same opt-in settings as Prettier, kept apart from them", () => { + expect(validateLanguages({}).oxfmt).toEqual(DEFAULT_LANGUAGES.prettier); + const v = validateLanguages({ + prettier: { runOnSave: true }, + oxfmt: { path: " ./bin/oxfmt ", languages: ["typescript", null, "typescript"], prefer: true }, + }); + expect(v.oxfmt).toEqual({ path: "./bin/oxfmt", languages: ["typescript"], prefer: true, runOnSave: false }); + expect(v.prettier).toMatchObject({ runOnSave: true, prefer: false, path: "" }); + }); + it("leaves the Markdown preview font size unset (editor font) and keeps a set one in 8..72", () => { expect(validateLanguages({}).markdown).toEqual({ previewFontSize: null }); expect(validateLanguages({ markdown: { previewFontSize: 90 } }).markdown).toEqual({ previewFontSize: 72 }); @@ -89,6 +99,7 @@ describe("language pages", () => { "Python Interpreter", "PHP", "Prettier", + "oxfmt", "Ruby", "Rust", "TypeScript", diff --git a/src/features/settings/languages/FormatterPage.tsx b/src/features/settings/languages/FormatterPage.tsx new file mode 100644 index 000000000..5c73746fa --- /dev/null +++ b/src/features/settings/languages/FormatterPage.tsx @@ -0,0 +1,99 @@ +import { useId } from "react"; +import type { ExternalFormatter } from "@/bindings"; +import { Input } from "@/ui"; +import { Row, Section, Select, Toggle } from "../components/Form"; +import { useSetting } from "../components/context"; +import { DEFAULT_LANGUAGES } from "@/platform/languageSettings"; + +const names: Record = { + markdown: "Markdown", + yaml: "YAML", + css: "CSS", + scss: "SCSS", + less: "Less", + html: "HTML", + graphql: "GraphQL", + javascript: "JavaScript", + javascriptreact: "JavaScript JSX", + typescript: "TypeScript", + typescriptreact: "TypeScript JSX", + json: "JSON", + jsonc: "JSON with Comments", + vue: "Vue", +}; + +interface FormatterPageProps { + tool: ExternalFormatter; + name: string; + pathLabel: string; + providerHint?: string; + saveHint: string; +} + +function FormatterPage({ tool, name, pathLabel, providerHint, saveHint }: FormatterPageProps) { + const id = useId(); + const [path, setPath] = useSetting(`languages.${tool}.path`); + const [languages, setLanguages] = useSetting(`languages.${tool}.languages`); + const [prefer, setPrefer] = useSetting(`languages.${tool}.prefer`); + const [save, setSave] = useSetting(`languages.${tool}.runOnSave`); + return ( + <> +
+ + setPath(e.target.value)} /> + + + setPath(e.target.value)} /> - - -