feat(python): support Ruff formatting and config directories (#1573)

Ruff is now an external formatter for Python next to the Ruff language server: Settings › Languages & Frameworks › Ruff has the executable path, a configuration directory (or file), the provider preference, enabled languages, run-on-save, and Reformat with Ruff. Formatting runs on the unsaved document through stdin (with Ruff's --range for selections, in 1-based code-point columns) and never writes the file first. The configuration directory is shared with Ruff LSP, which can then start without a root marker. Tools that can't format a language are rejected even when requested.

Co-authored-by: muutot <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Цей коміт міститься в:
authored and GitHub committed 2026-11-05 23:41:44 +08:00
1 джерело f35026f31c
коміт a275239138
24 files changed
+643 -69

No files matched your search

+10 -3
Переглянути файл
@@ -124,12 +124,12 @@ 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. */
/** Whether saving a `language` file formats it: Format on save, or Run on save of an external formatter. */
export function formatsOnSave(settings: Settings | null, language: string): boolean {
const { prettier, oxfmt } = validateLanguages(settings?.languages);
const { prettier, oxfmt, ruff } = validateLanguages(settings?.languages);
return (
!!settings?.editor?.formatOnSave ||
[prettier, oxfmt].some((formatter) => formatter.runOnSave && formatter.languages.includes(language))
[prettier, oxfmt, ruff].some((formatter) => formatter.runOnSave && formatter.languages.includes(language))
);
}
@@ -206,6 +206,13 @@ export function registerLspActions(): () => void {
(v) => formatDocument(v, { explicit: true, tool: "oxfmt" }),
{ keywords: ["format", "oxfmt", "oxc"] },
),
editorAction(
"code.reformatWithRuff",
"Reformat with Ruff",
"Code",
(v) => formatDocument(v, { explicit: true, tool: "ruff" }),
{ keywords: ["format", "ruff", "python"] },
),
editorAction("code.reformatDialog", "Reformat File…", "Code", (v) =>
reformatDialog(v, (view) => applyKindSilently(view, "source.organizeImports")),
),
+2
Переглянути файл
@@ -177,6 +177,8 @@ describe("format on save provider isolation", () => {
expect(formatsOnSave(oxfmt, "typescript")).toBe(true);
expect(formatsOnSave(oxfmt, "markdown")).toBe(false);
expect(formatsOnSave(settings({ prettier: { runOnSave: true } }), "markdown")).toBe(true);
expect(formatsOnSave(settings({ ruff: { runOnSave: true } }), "python")).toBe(true);
expect(formatsOnSave(settings({ ruff: { runOnSave: true } }), "typescript")).toBe(false);
expect(formatsOnSave(settings({}), "typescript")).toBe(false);
expect(formatsOnSave(settings({}, true), "rust")).toBe(true);
expect(formatsOnSave(null, "typescript")).toBe(false);
+12 -6
Переглянути файл
@@ -136,20 +136,26 @@ async function format(view: EditorView, opts: FormatDocumentOptions): Promise<Fo
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 formatter =
e instanceof IpcError && e.data?.type === "tool"
? {
prettier: { failed: "Prettier formatting failed", timedOut: "Prettier formatting timed out" },
oxfmt: { failed: "oxfmt formatting failed", timedOut: "oxfmt formatting timed out" },
ruff: { failed: "Ruff formatting failed", timedOut: "Ruff formatting timed out" },
}[e.data.tool]
: undefined;
if (e instanceof IpcError && e.data?.type === "tool" && formatter) {
const tool = e.data.tool;
if (e.kind === "notFound" && e.data.missing) {
await executeAction("tools.promptInstall", { name: tool, sticky: !opts.onSave });
await executeAction("tools.promptInstall", { name: tool, sticky: !opts.onSave, formatter: true });
} else if (e.kind !== "cancelled") {
notifications().notify({
group: "tools",
level: "error",
title: tool === "oxfmt" ? tx("oxfmt formatting failed") : tx("Prettier formatting failed"),
title: tx(formatter.failed),
body:
e.kind === "timedOut"
? tool === "oxfmt"
? tx("oxfmt formatting timed out")
: tx("Prettier formatting timed out")
? tx(formatter.timedOut)
: createElement("span", { className: styles.diagnostics }, e.message),
key: `${tool}-format-error:${doc.path}`,
sticky: false,
+6 -5
Переглянути файл
@@ -44,9 +44,9 @@ afterEach(() => {
_resetI18nForTests();
});
describe.each(["prettier", "oxfmt"])("%s formatter errors", (name) => {
describe.each(["prettier", "oxfmt", "ruff"])("%s formatter errors", (name) => {
const tool = { type: "tool", tool: name, missing: false } as const;
const display = name === "prettier" ? "Prettier" : name;
const display = name === "prettier" ? "Prettier" : name === "ruff" ? "Ruff" : name;
it("uses structured missing-tool information to request the shared install prompt", async () => {
const prompt = vi.fn();
const dispose = registerAction({
@@ -57,9 +57,9 @@ describe.each(["prettier", "oxfmt"])("%s formatter errors", (name) => {
try {
failWith({ kind: "notFound", message: "unrelated diagnostic", data: { ...tool, missing: true } });
await formatDocument(viewFor());
expect(prompt).toHaveBeenCalledWith({ name, sticky: true });
expect(prompt).toHaveBeenCalledWith({ name, sticky: true, formatter: true });
await formatDocument(viewFor(), { explicit: false, onSave: true });
expect(prompt).toHaveBeenLastCalledWith({ name, sticky: false });
expect(prompt).toHaveBeenLastCalledWith({ name, sticky: false, formatter: true });
expect(useNotifications.getState().items).toHaveLength(0);
} finally {
dispose();
@@ -126,10 +126,11 @@ describe.each(["prettier", "oxfmt"])("%s formatter errors", (name) => {
});
});
describe("Reformat with Prettier / oxfmt", () => {
describe("Reformat with an external formatter", () => {
it.each([
["code.reformatWithPrettier", "prettier"],
["code.reformatWithOxfmt", "oxfmt"],
["code.reformatWithRuff", "ruff"],
])("%s asks the backend for %s whatever the settings select", async (id, tool) => {
vi.spyOn(lsp, "lspRequest").mockImplementation(async (request) => {
await request();