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

+42 -2
Переглянути файл
@@ -92,7 +92,13 @@ describe("languages settings", () => {
validateLanguages({
prettier: { path: " ./bin/prettier ", languages: ["markdown", 4, "markdown"], prefer: true, runOnSave: true },
}).prettier,
).toEqual({ path: "./bin/prettier", languages: ["markdown"], prefer: true, runOnSave: true });
).toEqual({
path: "./bin/prettier",
configurationDirectory: "",
languages: ["markdown"],
prefer: true,
runOnSave: true,
});
expect(validateLanguages({ prettier: { languages: [] } }).prettier.languages).toEqual([]);
});
@@ -102,10 +108,43 @@ describe("languages settings", () => {
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.oxfmt).toEqual({
path: "./bin/oxfmt",
configurationDirectory: "",
languages: ["typescript"],
prefer: true,
runOnSave: false,
});
expect(v.prettier).toMatchObject({ runOnSave: true, prefer: false, path: "" });
});
it("enables Ruff for Python by default and keeps its settings separate", () => {
expect(validateLanguages({}).ruff).toMatchObject({
path: "",
configurationDirectory: "",
languages: ["python"],
prefer: false,
runOnSave: false,
});
const v = validateLanguages({
ruff: {
path: " ./bin/ruff ",
configurationDirectory: " ./config/ruff ",
languages: ["python", null, "python", "pyi"],
prefer: true,
runOnSave: true,
},
});
expect(v.ruff).toEqual({
path: "./bin/ruff",
configurationDirectory: "./config/ruff",
languages: ["python", "pyi"],
prefer: true,
runOnSave: true,
});
expect(v.prettier).toEqual(DEFAULT_LANGUAGES.prettier);
});
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 });
@@ -143,6 +182,7 @@ describe("language pages", () => {
"PHP",
"Prettier",
"oxfmt",
"Ruff",
"Ruby",
"Rust",
"TypeScript",
+68 -7
Переглянути файл
@@ -1,9 +1,11 @@
import { useId } from "react";
import type { ExternalFormatter } from "@/bindings";
import { Input } from "@/ui";
import { IconButton, Input } from "@/ui";
import { Row, Section, Select, Toggle } from "../components/Form";
import { useSetting } from "../components/context";
import { DEFAULT_LANGUAGES } from "@/platform/languageSettings";
import { choosePath } from "./api";
import styles from "./Sdk.module.css";
const names: Record<string, string> = {
markdown: "Markdown",
@@ -20,17 +22,28 @@ const names: Record<string, string> = {
json: "JSON",
jsonc: "JSON with Comments",
vue: "Vue",
python: "Python",
};
interface FormatterPageProps {
tool: ExternalFormatter;
name: string;
pathLabel: string;
pathHint?: string;
configurationDirectory?: boolean;
providerHint?: string;
saveHint: string;
}
function FormatterPage({ tool, name, pathLabel, providerHint, saveHint }: FormatterPageProps) {
function FormatterPage({
tool,
name,
pathLabel,
pathHint = "Auto: nearest project node_modules, managed installation, then PATH. An explicit path overrides auto detection.",
configurationDirectory = false,
providerHint,
saveHint,
}: FormatterPageProps) {
const id = useId();
const [path, setPath] = useSetting<string>(`languages.${tool}.path`);
const [languages, setLanguages] = useSetting<string[]>(`languages.${tool}.languages`);
@@ -39,13 +52,10 @@ function FormatterPage({ tool, name, pathLabel, providerHint, saveHint }: Format
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."
>
<Row label={pathLabel} htmlFor={id} hint={pathHint}>
<Input id={id} mono value={path} placeholder="Auto" onChange={(e) => setPath(e.target.value)} />
</Row>
{configurationDirectory && <ConfigurationDirectory settingKey={`languages.${tool}.configurationDirectory`} />}
<Row label="Formatting provider" hint={providerHint}>
<Select
value={prefer ? tool : "auto"}
@@ -75,6 +85,43 @@ function FormatterPage({ tool, name, pathLabel, providerHint, saveHint }: Format
);
}
function ConfigurationDirectory({ settingKey }: { settingKey: string }) {
const id = useId();
const [path, setPath] = useSetting<string>(settingKey);
return (
<Row
label="Configuration directory"
htmlFor={id}
hint="Empty uses the nearest project pyproject.toml ([tool.ruff]), ruff.toml, or .ruff.toml. A configured directory must contain one of them."
>
<span className={styles.pathRow}>
<Input
id={id}
mono
className={styles.pathInput}
value={path}
placeholder="Project configuration"
onChange={(e) => setPath(e.target.value)}
/>
<IconButton
icon="folder-open"
size="small"
title="Browse…"
aria-label="Select Ruff Configuration Directory"
onClick={async () => {
const picked = await choosePath({
directory: true,
title: "Select Ruff Configuration Directory",
defaultPath: path || undefined,
});
if (picked) setPath(picked);
}}
/>
</span>
</Row>
);
}
export function PrettierPage() {
return (
<FormatterPage
@@ -97,3 +144,17 @@ export function OxfmtPage() {
/>
);
}
export function RuffPage() {
return (
<FormatterPage
tool="ruff"
name="Ruff"
pathLabel="Ruff path"
pathHint="Auto: managed installation, then PATH. An explicit path overrides auto detection."
configurationDirectory
providerHint="Ruff reads the nearest pyproject.toml ([tool.ruff]), ruff.toml, or .ruff.toml. Use it before the language server when preferred."
saveHint="Run Ruff on save for enabled languages when it is the selected formatting provider."
/>
);
}
+19 -1
Переглянути файл
@@ -5,7 +5,7 @@
*/
import { GoPage, JavaPage, NodePage, PhpPage, RubyPage, RustPage, TypeScriptPage } from "../languages/LanguagePages";
import { MarkdownPage } from "../languages/MarkdownPage";
import { OxfmtPage, PrettierPage } from "../languages/FormatterPage";
import { OxfmtPage, PrettierPage, RuffPage } from "../languages/FormatterPage";
import { ProjectStructurePage } from "../languages/ProjectStructurePage";
import { PythonInterpreterPage } from "../languages/PythonInterpreterPage";
import { GoBuildTagsPage, GO_BUILD_TAG_SECTIONS } from "../languages/GoBuildTags";
@@ -138,6 +138,24 @@ export const LANGUAGE_PAGES: PageSpec[] = [
keywords: ["formatter", "oxfmt", "oxc", ".oxfmtrc.json", "format on save", "node_modules"],
component: OxfmtPage,
},
{
id: "languages.ruff",
projectScope: true,
defaultScope: "project",
title: "Ruff",
group: LANGUAGES_GROUP,
order: 403.7,
keywords: [
"formatter",
"ruff",
"python",
"configuration directory",
"pyproject.toml",
"ruff.toml",
"format on save",
],
component: RuffPage,
},
{
id: "languages.php",
projectScope: true,
+19
Переглянути файл
@@ -147,6 +147,25 @@ describe("missing language server prompt", () => {
off();
});
it("offers Ruff formatting even when another Python server is installed", async () => {
const installed = sampleTools().find((t) => t.name === "basedpyright")!;
backend.tools.push({
...installed,
name: "ruff",
displayName: "Ruff",
command: "ruff",
installMethod: "uv (virtualenv)",
languages: ["Python"],
alternatives: [],
});
await promptInstall("ruff", true, new Set(), true);
expect(notes.at(-1)).toMatchObject({
title: "Ruff is not installed",
body: "Reformat Code needs Ruff. Install it via uv (virtualenv)?",
key: missingKey("ruff"),
});
});
it("stays silent when an installed alternative serves the same languages", async () => {
const off = installMissingServerPrompts();
// typescript-language-server (on PATH) covers vtsls' languages.
+6 -1
Переглянути файл
@@ -46,7 +46,12 @@ export function activate(): () => void {
hidden: true,
run: async ({ arg }) => {
if (arg && typeof arg === "object" && "name" in arg && typeof arg.name === "string") {
await promptInstall(arg.name, !("sticky" in arg) || arg.sticky !== false);
await promptInstall(
arg.name,
!("sticky" in arg) || arg.sticky !== false,
new Set(),
"formatter" in arg && arg.formatter === true,
);
}
},
},
+6 -5
Переглянути файл
@@ -21,12 +21,14 @@ export async function promptInstall(
name: string,
sticky = true,
offered: ReadonlySet<string> = new Set(),
formatter = false,
): Promise<boolean> {
if (!useTools.getState().loaded) await refreshTools();
const tool = toolByName(name);
if (!tool || !tool.canInstall || tool.status !== "missing") return false;
const position = tool.alternatives.indexOf(name);
if (position > 0 && tool.alternatives.slice(0, position).some((n) => offered.has(n))) return false;
formatter ||= tool.kind === "formatter";
// An installed alternative (typescript-language-server for vtsls) already serves these files.
const served = useTools
.getState()
@@ -37,17 +39,16 @@ export async function promptInstall(
t.status !== "missing" &&
t.languages.some((l) => tool.languages.includes(l)),
);
if (tool.kind !== "formatter" && served) return false;
if (!formatter && served) return false;
const key = missingKey(name);
const langs = tool.languages.length ? tool.languages.join(", ") : "these files";
notifications().notify({
group: "tools",
level: "warning",
title: tx`${tool.displayName} is not installed`,
body:
tool.kind === "formatter"
? tx`Reformat Code needs ${tool.displayName}. Install it via ${tool.installMethod}?`
: tx`Code insight for ${langs} needs ${tool.command}. Install it via ${tool.installMethod}?`,
body: formatter
? tx`Reformat Code needs ${tool.displayName}. Install it via ${tool.installMethod}?`
: tx`Code insight for ${langs} needs ${tool.command}. Install it via ${tool.installMethod}?`,
key,
sticky,
actions: [