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)} /> - - -