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 <[email protected]>
Цей коміт міститься в:
d0whc3r authored and GitHub committed 2026-11-03 23:54:55 +08:00
1 джерело e8d1c45970
коміт c5727141a0
18 files changed
+441 -192

No files matched your search

+33 -4
Переглянути файл
@@ -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<u32>,
}
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<String>,
/// 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(),
+109 -49
Переглянути файл
@@ -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<ExternalFormatter>,
language: Option<&LanguageConfig>,
file: &Path,
settings: &LanguagesSettings,
) -> IdeResult<Option<ExternalFormatter>> {
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<Vec<TextEdit>> = 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<ExternalFormatter>,
language: &str,
file: &Path,
settings: &LanguagesSettings,
) -> IdeResult<Option<ExternalFormatter>> {
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");
}
}
+1 -1
Переглянути файл
@@ -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();
+5
Переглянути файл
@@ -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<ExternalFormatter>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
+10 -3
Переглянути файл
@@ -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()
);
+8 -3
Переглянути файл
@@ -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:?}");
+17 -10
Переглянути файл
@@ -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,
+25 -8
Переглянути файл
@@ -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")),
),
+13
Переглянути файл
@@ -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<typeof indentExtension>) =>
@@ -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);
});
});
+16 -8
Переглянути файл
@@ -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<FormatResult> {
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<FormatResult> {
async function format(view: EditorView, opts: FormatDocumentOptions): Promise<FormatResult> {
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<ContributedFormatter>(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;
+28 -2
Переглянути файл
@@ -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 });
}
});
});
+11
Переглянути файл
@@ -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",
+99
Переглянути файл
@@ -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<string, string> = {
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<string>(`languages.${tool}.path`);
const [languages, setLanguages] = useSetting<string[]>(`languages.${tool}.languages`);
const [prefer, setPrefer] = useSetting<boolean>(`languages.${tool}.prefer`);
const [save, setSave] = useSetting<boolean>(`languages.${tool}.runOnSave`);
return (
<>
<Section title={name}>
<Row
label={pathLabel}
htmlFor={id}
hint="Auto: nearest project node_modules, managed installation, then PATH. An explicit path overrides auto detection."
>
<Input id={id} mono value={path} placeholder="Auto" onChange={(e) => setPath(e.target.value)} />
</Row>
<Row label="Formatting provider" hint={providerHint}>
<Select
value={prefer ? tool : "auto"}
onChange={(v) => setPrefer(v === tool)}
aria-label="Formatting provider"
options={[
{ value: "auto", label: "Automatic (language server first)" },
{ value: tool, label: name },
]}
/>
</Row>
<Toggle label="Run on save" checked={save} onChange={setSave} hint={saveHint} />
</Section>
<Section title="Enabled languages">
{DEFAULT_LANGUAGES[tool].languages.map((language) => (
<Toggle
key={language}
label={names[language]!}
checked={languages.includes(language)}
onChange={(checked) =>
setLanguages(checked ? [...languages, language] : languages.filter((l) => l !== language))
}
/>
))}
</Section>
</>
);
}
export function PrettierPage() {
return (
<FormatterPage
tool="prettier"
name="Prettier"
pathLabel="Prettier path"
saveHint="Run Prettier on save for enabled languages when it is the selected formatting provider."
/>
);
}
export function OxfmtPage() {
return (
<FormatterPage
tool="oxfmt"
name="oxfmt"
pathLabel="oxfmt path"
providerHint="oxfmt formats an enabled language instead of Prettier below an oxfmt configuration file (.oxfmtrc.json, oxfmt.config.ts) or when it is the selected provider."
saveHint="Run oxfmt on save for enabled languages when it is the selected formatting provider."
/>
);
}
-72
Переглянути файл
@@ -1,72 +0,0 @@
import { useId } from "react";
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<string, string> = {
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",
};
export function PrettierPage() {
const id = useId();
const [path, setPath] = useSetting<string>("languages.prettier.path");
const [languages, setLanguages] = useSetting<string[]>("languages.prettier.languages");
const [prefer, setPrefer] = useSetting<boolean>("languages.prettier.prefer");
const [save, setSave] = useSetting<boolean>("languages.prettier.runOnSave");
return (
<>
<Section title="Prettier">
<Row
label="Prettier path"
htmlFor={id}
hint="Auto: nearest project node_modules, managed installation, then PATH. An explicit path overrides auto detection."
>
<Input id={id} mono value={path} placeholder="Auto" onChange={(e) => setPath(e.target.value)} />
</Row>
<Row label="Formatting provider">
<Select
value={prefer ? "prettier" : "auto"}
onChange={(v) => setPrefer(v === "prettier")}
aria-label="Formatting provider"
options={[
{ value: "auto", label: "Automatic (language server first)" },
{ value: "prettier", label: "Prettier" },
]}
/>
</Row>
<Toggle
label="Run on save"
checked={save}
onChange={setSave}
hint="Run Prettier on save for enabled languages when it is the selected formatting provider."
/>
</Section>
<Section title="Enabled languages">
{DEFAULT_LANGUAGES.prettier.languages.map((language) => (
<Toggle
key={language}
label={names[language]!}
checked={languages.includes(language)}
onChange={(checked) =>
setLanguages(checked ? [...languages, language] : languages.filter((l) => l !== language))
}
/>
))}
</Section>
</>
);
}
+11 -1
Переглянути файл
@@ -5,7 +5,7 @@
*/
import { GoPage, JavaPage, NodePage, PhpPage, RubyPage, RustPage, TypeScriptPage } from "../languages/LanguagePages";
import { MarkdownPage } from "../languages/MarkdownPage";
import { PrettierPage } from "../languages/PrettierPage";
import { OxfmtPage, PrettierPage } from "../languages/FormatterPage";
import { ProjectStructurePage } from "../languages/ProjectStructurePage";
import { PythonInterpreterPage } from "../languages/PythonInterpreterPage";
import { GoBuildTagsPage, GO_BUILD_TAG_SECTIONS } from "../languages/GoBuildTags";
@@ -128,6 +128,16 @@ export const LANGUAGE_PAGES: PageSpec[] = [
keywords: ["formatter", "prettier", "markdown", "yaml", "css", "format on save", "node_modules"],
component: PrettierPage,
},
{
id: "languages.oxfmt",
projectScope: true,
defaultScope: "project",
title: "oxfmt",
group: LANGUAGES_GROUP,
order: 403.6,
keywords: ["formatter", "oxfmt", "oxc", ".oxfmtrc.json", "format on save", "node_modules"],
component: OxfmtPage,
},
{
id: "languages.php",
projectScope: true,
+49 -31
Переглянути файл
@@ -25,6 +25,36 @@ const DEFAULT_GO_BUILD_TAGS: GoBuildTags = {
experiments: "",
};
/** An external formatter (Prettier, oxfmt) and the languages it formats. */
interface ExternalFormatterValid {
path: string;
languages: string[];
prefer: boolean;
runOnSave: boolean;
}
const DEFAULT_FORMATTER: ExternalFormatterValid = {
path: "",
languages: [
"markdown",
"yaml",
"css",
"scss",
"less",
"html",
"graphql",
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"json",
"jsonc",
"vue",
],
prefer: false,
runOnSave: false,
};
export interface LanguagesValid {
python: { interpreter: string; activateVirtualenv: boolean };
ruby: { interpreter: string };
@@ -44,7 +74,8 @@ export interface LanguagesValid {
modules: { env: Record<string, string>; autoVendor: boolean };
};
typescript: { version: string };
prettier: { path: string; languages: string[]; prefer: boolean; runOnSave: boolean };
prettier: ExternalFormatterValid;
oxfmt: ExternalFormatterValid;
java: {
jdk: string;
jdtlsJdk: string;
@@ -87,27 +118,8 @@ export const DEFAULT_LANGUAGES: LanguagesValid = {
rust: { toolchain: "", stdlib: "", checkOnSave: true, checkCommand: "check", rustfmtArgs: "" },
go: { goroot: "", gopath: "", buildTags: DEFAULT_GO_BUILD_TAGS, modules: { env: {}, autoVendor: true } },
typescript: { version: "bundled" },
prettier: {
path: "",
languages: [
"markdown",
"yaml",
"css",
"scss",
"less",
"html",
"graphql",
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"json",
"jsonc",
"vue",
],
prefer: false,
runOnSave: false,
},
prettier: DEFAULT_FORMATTER,
oxfmt: DEFAULT_FORMATTER,
java: {
jdk: "",
jdtlsJdk: "",
@@ -164,6 +176,19 @@ const folders = (v: unknown): string[] =>
const folderKey = (stored: string) => (stored === "." ? "" : stored);
const storedFolder = (rel: string) => normalizeFolder(rel) || ".";
function validateFormatter(raw: unknown): ExternalFormatterValid {
const d = DEFAULT_FORMATTER;
const f = obj(raw);
return {
path: text(f.path, d.path),
languages: Array.isArray(f.languages)
? [...new Set(f.languages.filter((v): v is string => typeof v === "string"))]
: d.languages,
prefer: bool(f.prefer, d.prefer),
runOnSave: bool(f.runOnSave, d.runOnSave),
};
}
export function validateLanguages(raw: unknown): LanguagesValid {
const d = DEFAULT_LANGUAGES;
const l = obj(raw);
@@ -176,7 +201,6 @@ export function validateLanguages(raw: unknown): LanguagesValid {
const tags = obj(go.buildTags);
const modules = obj(go.modules);
const ts = obj(l.typescript);
const prettier = obj(l.prettier);
const java = obj(l.java);
const maven = obj(java.maven);
const gradle = obj(java.gradle);
@@ -215,14 +239,8 @@ export function validateLanguages(raw: unknown): LanguagesValid {
},
},
typescript: { version: text(ts.version, d.typescript.version) || d.typescript.version },
prettier: {
path: text(prettier.path, d.prettier.path),
languages: Array.isArray(prettier.languages)
? [...new Set(prettier.languages.filter((v): v is string => typeof v === "string"))]
: d.prettier.languages,
prefer: bool(prettier.prefer, d.prettier.prefer),
runOnSave: bool(prettier.runOnSave, d.prettier.runOnSave),
},
prettier: validateFormatter(l.prettier),
oxfmt: validateFormatter(l.oxfmt),
java: {
jdk: text(java.jdk, d.java.jdk),
jdtlsJdk: text(java.jdtlsJdk, d.java.jdtlsJdk),
+5
Переглянути файл
@@ -1650,12 +1650,17 @@
"Refactoring undone": "重构已撤消",
"Refactoring applied": "重构已应用",
"Reformat Code": "重新格式化代码",
"Reformat with Prettier": "使用 Prettier 重新格式化",
"Reformat with oxfmt": "使用 oxfmt 重新格式化",
"Prettier path": "Prettier 路径",
"oxfmt path": "oxfmt 路径",
"Auto: nearest project node_modules, managed installation, then PATH. An explicit path overrides auto detection.": "自动:依次查找最近的项目 node_modules、受管安装和 PATH。指定路径将覆盖自动检测。",
"Formatting provider": "格式化工具",
"Automatic (language server first)": "自动(优先语言服务器)",
"Run on save": "保存时运行",
"Run Prettier on save for enabled languages when it is the selected formatting provider.": "对已启用的语言,在选择 Prettier 作为格式化工具时于保存时运行。",
"Run oxfmt on save for enabled languages when it is the selected formatting provider.": "对已启用的语言,在选择 oxfmt 作为格式化工具时于保存时运行。",
"oxfmt formats an enabled language instead of Prettier below an oxfmt configuration file (.oxfmtrc.json, oxfmt.config.ts) or when it is the selected provider.": "在 oxfmt 配置文件(.oxfmtrc.json、oxfmt.config.ts)所在目录及其子目录中,或选择 oxfmt 作为格式化工具时,oxfmt 将代替 Prettier 格式化已启用的语言。",
"Enabled languages": "启用的语言",
"Reformat Code needs {name}. Install it via {method}?": "重新格式化代码需要 {name}。是否通过 {method} 安装?",
"Install Missing Tool": "安装缺失的工具",
+1
Переглянути файл
@@ -85,6 +85,7 @@ const UNTRANSLATED = new Set<string>([
"Cargo",
"CMake",
"Prettier",
"oxfmt",
"IntelliJ IDEA",
// Go environment variables and placeholder values
"CGO",