дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
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]>
298 рядки
12 KiB
TypeScript
298 рядки
12 KiB
TypeScript
/**
|
||
* Reformat Code (⌥⌘L): `lsp_format` for the selection (or the whole file),
|
||
* applied locally as one CodeMirror transaction and one undo step; editor/core
|
||
* syncs it to the backend like any user edit. As in IDEA an explicit reformat
|
||
* reports "Formatted N lines" with a link to the Reformat File dialog
|
||
* (⌥⇧⌘L: scope and Optimize Imports).
|
||
*/
|
||
import { createElement } from "react";
|
||
import styles from "./format.module.css";
|
||
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";
|
||
import { executeAction } from "@/platform/actions";
|
||
import {
|
||
formatExcluded,
|
||
formatGuardOptions,
|
||
guardFormatChanges,
|
||
type FormatGuardOptions,
|
||
} from "@/platform/formatGuard";
|
||
import { useProject } from "@/platform/project";
|
||
import { CONTRIBUTED_FORMAT, contributedAt, whenSynced, type ContributedFormatter } from "@/editor/api";
|
||
import { docOf, lspRequest, reportLspError } from "./lsp";
|
||
import { formatIndentFacet } from "../core/cm/setup";
|
||
import { offsetToPos, textEditsToChanges } from "./positions";
|
||
import { tx, txTemplate } from "@/platform/i18n";
|
||
import { showHint } from "./hint";
|
||
import { openPopup, type ReformatOptions } from "./popupStore";
|
||
|
||
export interface FormatResult {
|
||
applied: boolean;
|
||
/** Number of changed regions. */
|
||
changes: number;
|
||
/** Lines of the result that the changes touched. */
|
||
lines: number;
|
||
}
|
||
|
||
const NOTHING: FormatResult = { applied: false, changes: 0, lines: 0 };
|
||
|
||
/** Lines of the new document touched by `changes` (given against `doc`), as IDEA counts "Formatted N lines". */
|
||
export function formattedLines(doc: Text, changes: readonly { from: number; to: number; insert: string }[]): number {
|
||
const touched = new Set<number>();
|
||
// Line shift of the changes so far, to number lines as in the new text.
|
||
let shift = 0;
|
||
for (const c of [...changes].sort((a, b) => a.from - b.from)) {
|
||
const startLine = doc.lineAt(c.from).number + shift;
|
||
const insertedLines = c.insert.split("\n").length - 1;
|
||
for (let n = startLine; n <= startLine + insertedLines; n++) touched.add(n);
|
||
shift += insertedLines - (doc.lineAt(c.to).number - doc.lineAt(c.from).number);
|
||
}
|
||
return touched.size;
|
||
}
|
||
|
||
function formattedHint(view: EditorView, lines: number) {
|
||
const keys = shortcutLabel("code.reformatDialog");
|
||
showHint(view, () => {
|
||
const box = document.createElement("div");
|
||
box.textContent = txTemplate(`Formatted ${lines} ${lines === 1 ? "line" : "lines"}`);
|
||
const more = document.createElement("div");
|
||
const link = document.createElement("button");
|
||
link.type = "button";
|
||
link.className = "cm-lsp-link-button";
|
||
link.textContent = tx("Show");
|
||
link.addEventListener("mousedown", (e) => e.preventDefault());
|
||
link.addEventListener("click", () => void executeAction("code.reformatDialog"));
|
||
more.append(link, `${tx(" the Reformat dialog")}${keys ? `: ${keys}` : ""}`);
|
||
box.appendChild(more);
|
||
return box;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* LSP `FormattingOptions`: with spaces the indent width is the indent unit, not the tab size.
|
||
* An explicit Code Style override replaces the editor's indent; the server's formatter
|
||
* profile carries the same override, so both agree.
|
||
*/
|
||
export function formatOptionsOf(state: EditorState): { tabSize: number; insertSpaces: boolean } {
|
||
const style = state.facet(formatIndentFacet)[0];
|
||
if (style)
|
||
return style.insertSpaces
|
||
? { tabSize: style.indentSize ?? style.tabSize, insertSpaces: true }
|
||
: { tabSize: style.tabSize, insertSpaces: false };
|
||
const unit = state.facet(indentUnit);
|
||
return unit.includes("\t")
|
||
? { tabSize: state.tabSize, insertSpaces: false }
|
||
: { 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: 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: FormatDocumentOptions): Promise<FormatResult> {
|
||
const { project, settings } = useProject.getState();
|
||
const guard = formatGuardOptions(settings);
|
||
const path = docOf(view)?.path;
|
||
if (path && formatExcluded(path, project?.root ?? null, guard.doNotFormat)) {
|
||
if (opts.explicit)
|
||
showHint(view, "Formatting is turned off for this file (Code Style › Formatter › Do not format)");
|
||
return NOTHING;
|
||
}
|
||
const own = contributedAt<ContributedFormatter>(view.state, CONTRIBUTED_FORMAT, 0);
|
||
if (own && !opts.tool) {
|
||
if (opts.onSave && !settings?.editor?.formatOnSave) return NOTHING;
|
||
return applyContributedFormat(view, own, opts, guard);
|
||
}
|
||
const doc = docOf(view);
|
||
if (!doc || doc.readOnly) return NOTHING;
|
||
// The server formats the backend's copy: let pending keystrokes reach it first.
|
||
await whenSynced(view);
|
||
const state = view.state;
|
||
const sel = state.selection.main;
|
||
const useSelection = (opts.selectionOnly ?? true) && !sel.empty;
|
||
const range = useSelection ? { start: offsetToPos(state.doc, sel.from), end: offsetToPos(state.doc, sel.to) } : null;
|
||
const options = formatOptionsOf(state);
|
||
let edits;
|
||
try {
|
||
edits = await lspRequest(() =>
|
||
commands.lspFormat(doc.doc, range, { ...options, onSave: opts.onSave, tool: opts.tool }),
|
||
);
|
||
} catch (e) {
|
||
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, formatter: true });
|
||
} else if (e.kind !== "cancelled") {
|
||
notifications().notify({
|
||
group: "tools",
|
||
level: "error",
|
||
title: tx(formatter.failed),
|
||
body:
|
||
e.kind === "timedOut"
|
||
? tx(formatter.timedOut)
|
||
: createElement("span", { className: styles.diagnostics }, e.message),
|
||
key: `${tool}-format-error:${doc.path}`,
|
||
sticky: false,
|
||
});
|
||
}
|
||
} else reportLspError(view, e, opts.explicit);
|
||
return NOTHING;
|
||
}
|
||
if (view.state.doc !== state.doc) {
|
||
if (opts.explicit) showHint(view, "The file changed while formatting; try again");
|
||
return NOTHING;
|
||
}
|
||
const changes = guardFormatChanges(state.doc.toString(), textEditsToChanges(state.doc, edits), guard);
|
||
if (!changes.length) {
|
||
if (opts.explicit) showHint(view, "No lines changed: content is already properly formatted");
|
||
return NOTHING;
|
||
}
|
||
view.dispatch({
|
||
changes,
|
||
userEvent: "format.reformat",
|
||
annotations: isolateHistory.of("full"),
|
||
scrollIntoView: false,
|
||
});
|
||
return { applied: true, changes: changes.length, lines: formattedLines(state.doc, changes) };
|
||
}
|
||
|
||
/** Whether `after` has a non-blank line at column 0 where all of `before` was indented. */
|
||
export function dedentsToColumnZero(before: string, after: string): boolean {
|
||
const minIndent = (t: string) =>
|
||
Math.min(
|
||
...t
|
||
.split("\n")
|
||
.filter((l) => l.trim())
|
||
.map((l) => l.length - l.trimStart().length),
|
||
);
|
||
return minIndent(before) > 0 && minIndent(after) === 0;
|
||
}
|
||
|
||
/**
|
||
* Reformats lines `from..to` silently (generated code: quick fixes, Generate),
|
||
* merged into the edit's undo step. False when the server can't format ranges.
|
||
*/
|
||
export async function formatLines(view: EditorView, from: number, to: number): Promise<boolean> {
|
||
const doc = docOf(view);
|
||
if (!doc || doc.readOnly) return false;
|
||
const { project, settings } = useProject.getState();
|
||
if (formatExcluded(doc.path, project?.root ?? null, formatGuardOptions(settings).doNotFormat)) return false;
|
||
await whenSynced(view);
|
||
const state = view.state;
|
||
const range = { start: offsetToPos(state.doc, from), end: offsetToPos(state.doc, to) };
|
||
const options = formatOptionsOf(state);
|
||
let edits;
|
||
try {
|
||
edits = await lspRequest(() => commands.lspFormat(doc.doc, range, options));
|
||
} catch {
|
||
return false;
|
||
}
|
||
if (view.state.doc !== state.doc) return false;
|
||
// Only inside the range: some servers answer a range request with whole-file edits.
|
||
const changes = textEditsToChanges(state.doc, edits).filter(
|
||
(c) => c.from >= from && c.to <= to && state.sliceDoc(c.from, c.to) !== c.insert,
|
||
);
|
||
if (!changes.length) return true;
|
||
// A server formatting the range out of context (kotlin-language-server) moves
|
||
// indented code to column 0: its answer is ignored then.
|
||
const delta = changes.reduce((d, c) => d + c.insert.length - (c.to - c.from), 0);
|
||
const next = state.update({ changes }).state.doc;
|
||
if (dedentsToColumnZero(state.sliceDoc(from, to), next.sliceString(from, to + delta))) return false;
|
||
view.dispatch({ changes, userEvent: "format", scrollIntoView: false });
|
||
return true;
|
||
}
|
||
|
||
function applyContributedFormat(
|
||
view: EditorView,
|
||
format: ContributedFormatter,
|
||
opts: FormatDocumentOptions,
|
||
guard: FormatGuardOptions,
|
||
): FormatResult {
|
||
const state = view.state;
|
||
if (state.readOnly) return NOTHING;
|
||
const sel = state.selection.main;
|
||
const useSelection = (opts.selectionOnly ?? true) && !sel.empty;
|
||
const changes = guardFormatChanges(
|
||
state.doc.toString(),
|
||
format(state, useSelection ? { from: sel.from, to: sel.to } : null)
|
||
.filter((c) => state.sliceDoc(c.from, c.to) !== c.insert)
|
||
.sort((a, b) => a.from - b.from),
|
||
guard,
|
||
);
|
||
if (!changes.length) {
|
||
if (opts.explicit) showHint(view, "No lines changed: content is already properly formatted");
|
||
return NOTHING;
|
||
}
|
||
view.dispatch({
|
||
changes,
|
||
userEvent: "format.reformat",
|
||
annotations: isolateHistory.of("full"),
|
||
scrollIntoView: false,
|
||
});
|
||
return { applied: true, changes: changes.length, lines: formattedLines(state.doc, changes) };
|
||
}
|
||
|
||
/** The dialog's last choices, as IDEA remembers them. */
|
||
let lastOptions: ReformatOptions = { selectionOnly: true, optimizeImports: false };
|
||
|
||
/**
|
||
* Reformat File… (⌥⇧⌘L): asks for the scope and options, then optimizes
|
||
* imports (when chosen) and reformats.
|
||
*/
|
||
export async function reformatDialog(
|
||
view: EditorView,
|
||
optimizeImports: (view: EditorView) => Promise<unknown> = async () => {},
|
||
): Promise<void> {
|
||
const ctx = docOf(view);
|
||
if (!ctx || ctx.readOnly) {
|
||
if (ctx?.readOnly) showHint(view, "The file is read-only");
|
||
return;
|
||
}
|
||
const sel = view.state.selection.main;
|
||
const name = ctx.path.slice(ctx.path.lastIndexOf("/") + 1);
|
||
openPopup({
|
||
kind: "reformat",
|
||
title: tx`Reformat File: ${name}`,
|
||
hasSelection: !sel.empty,
|
||
canOptimizeImports: !contributedAt(view.state, CONTRIBUTED_FORMAT, 0),
|
||
initial: lastOptions,
|
||
onRun: (options) =>
|
||
void (async () => {
|
||
lastOptions = {
|
||
...lastOptions,
|
||
...options,
|
||
selectionOnly: sel.empty ? lastOptions.selectionOnly : options.selectionOnly,
|
||
};
|
||
view.focus();
|
||
if (options.optimizeImports) await optimizeImports(view);
|
||
await formatDocument(view, { explicit: true, selectionOnly: options.selectionOnly });
|
||
})(),
|
||
});
|
||
}
|