дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
fix(editor): auto-indent blocks in languages without a CodeMirror mode
Languages without a CodeMirror grammar had no indentation service. Add a low-precedence fallback, GDScript smart Backspace and statement completion. Review fixes preserve literal contents, Elixir inline do expressions and paired-bracket Enter indentation; regression tests cover these sibling cases. Co-authored-by: kartik-madhak <[email protected]>
Цей коміт міститься в:
1 джерело
1283af8a41
коміт
3f9512056d
8 files changed
+206
-13
No files matched your search
@@ -102,6 +102,7 @@ const EXT_LANG: Record<string, string> = {
|
||||
kts: "kotlin",
|
||||
java: "java",
|
||||
py: "python",
|
||||
gd: "gdscript",
|
||||
go: "go",
|
||||
json: "json",
|
||||
md: "markdown",
|
||||
@@ -182,6 +183,15 @@ const MEM_LANGUAGES: LanguageInfo[] = [
|
||||
blockComment: null,
|
||||
indent: null,
|
||||
},
|
||||
{
|
||||
id: "gdscript",
|
||||
name: "GDScript",
|
||||
extensions: ["gd"],
|
||||
uiMode: null,
|
||||
lineComment: "#",
|
||||
blockComment: null,
|
||||
indent: { tabSize: 4, insertSpaces: false },
|
||||
},
|
||||
{
|
||||
id: "json",
|
||||
name: "JSON",
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { EditorSelection, EditorState, type Extension } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { insertNewlineAndIndent } from "@codemirror/commands";
|
||||
import { indentService, indentUnit } from "@codemirror/language";
|
||||
import {
|
||||
addCaretsToLineEnds,
|
||||
commentLine,
|
||||
completeStatement,
|
||||
joinLines,
|
||||
startNewLine,
|
||||
startNewLineBefore,
|
||||
toggleCase,
|
||||
unselectOccurrence,
|
||||
} from "./editing";
|
||||
import { commentTokens } from "./languages";
|
||||
import { toggleCaret } from "./multiCaret";
|
||||
import { enterInLineComment, lineCommentStyle } from "./comments";
|
||||
import { languages } from "@codemirror/language-data";
|
||||
@@ -42,7 +45,7 @@ function make(text: string, extensions: Extension = []) {
|
||||
state: EditorState.create({
|
||||
doc,
|
||||
selection: EditorSelection.create(ranges.length ? ranges : [EditorSelection.cursor(0)]),
|
||||
extensions: [EditorState.allowMultipleSelections.of(true), indentUnit.of(" "), extensions],
|
||||
extensions: [EditorState.allowMultipleSelections.of(true), extensions, indentUnit.of(" ")],
|
||||
}),
|
||||
parent: document.body,
|
||||
});
|
||||
@@ -259,3 +262,79 @@ describe("enter in a line comment", async () => {
|
||||
expect(enterInLineComment(make("f(|x)", ext))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fallback indentation for GDScript", () => {
|
||||
const gd = [
|
||||
EditorState.tabSize.of(4),
|
||||
indentUnit.of("\t"),
|
||||
commentTokens({
|
||||
id: "gdscript",
|
||||
name: "GDScript",
|
||||
extensions: ["gd"],
|
||||
uiMode: null,
|
||||
lineComment: "#",
|
||||
blockComment: null,
|
||||
indent: { tabSize: 4, insertSpaces: false },
|
||||
}),
|
||||
];
|
||||
|
||||
it("indents with a tab after a function header ending in a colon", () => {
|
||||
const v = make("func test() -> void:|", gd);
|
||||
insertNewlineAndIndent(v);
|
||||
expect(show(v)).toBe("func test() -> void:\n\t|");
|
||||
});
|
||||
|
||||
it("indents nested blocks and ignores trailing comments", () => {
|
||||
const v = make("func test() -> void:\n\tif x > 0: # check|", gd);
|
||||
insertNewlineAndIndent(v);
|
||||
expect(show(v)).toBe("func test() -> void:\n\tif x > 0: # check\n\t\t|");
|
||||
});
|
||||
|
||||
it("does not indent after a comment ending in a colon", () => {
|
||||
const v = make("\t# note:|", gd);
|
||||
insertNewlineAndIndent(v);
|
||||
expect(show(v)).toBe("\t# note:\n\t|");
|
||||
});
|
||||
|
||||
it("dedents after pass, return, break, and continue", () => {
|
||||
const v = make("func test() -> void:\n\tpass|", gd);
|
||||
insertNewlineAndIndent(v);
|
||||
expect(show(v)).toBe("func test() -> void:\n\tpass\n|");
|
||||
|
||||
const w = make("func test() -> int:\n\tif ready:\n\t\treturn 1|", gd);
|
||||
insertNewlineAndIndent(w);
|
||||
expect(show(w)).toBe("func test() -> int:\n\tif ready:\n\t\treturn 1\n\t|");
|
||||
});
|
||||
|
||||
it("completes a GDScript function header with a colon and indents the next line", () => {
|
||||
const v = make("func test() -> void|", gd);
|
||||
completeStatement("gdscript")(v);
|
||||
expect(show(v)).toBe("func test() -> void:\n\t|");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fallback indentation boundaries", () => {
|
||||
const fallback = (id: string, lineComment = "#") =>
|
||||
commentTokens({
|
||||
id,
|
||||
name: id,
|
||||
extensions: [],
|
||||
uiMode: null,
|
||||
lineComment,
|
||||
blockComment: null,
|
||||
indent: null,
|
||||
});
|
||||
it.each([
|
||||
["gdscript", "var items = {|}", "var items = {\n |\n}"],
|
||||
["gdscript", 'var text = "unfinished:|', 'var text = "unfinished:\n|'],
|
||||
["gdscript", 'var text = "unfinished {|', 'var text = "unfinished {\n|'],
|
||||
["gdscript", 'var text = "#" # comment:|', 'var text = "#" # comment:\n|'],
|
||||
["gdscript", "if ready: # note|", "if ready: # note\n |"],
|
||||
["elixir", "if ready, do: value|", "if ready, do: value\n|"],
|
||||
["lua", "if ready then work() end|", "if ready then work() end\n|"],
|
||||
])("handles %s: %s", (id, input, expected) => {
|
||||
const v = make(input, fallback(id));
|
||||
insertNewlineAndIndent(v);
|
||||
expect(show(v)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -539,6 +539,7 @@ export const SEMICOLON_LANGS = new Set([
|
||||
/** Blocks close with a keyword (`end`) or by indentation: never complete them with braces. */
|
||||
export const KEYWORD_BLOCK_LANGS = new Set([
|
||||
"python",
|
||||
"gdscript",
|
||||
"ruby",
|
||||
"lua",
|
||||
"elixir",
|
||||
@@ -621,7 +622,11 @@ export function completeStatementSpec(state: EditorState, language: string): Tra
|
||||
}
|
||||
let insert = "";
|
||||
if (SEMICOLON_LANGS.has(language) && !comment && text.trim() && !/[;{},:]$/.test(text)) insert = ";";
|
||||
if (language === "python" && BLOCK_HEAD.test(text.replace(/^\s*(def|elif)\b/, "if")) && !text.endsWith(":"))
|
||||
if (
|
||||
(language === "python" || language === "gdscript") &&
|
||||
BLOCK_HEAD.test(text.replace(/^\s*(def|elif|static\s+func)\b/, "if")) &&
|
||||
!text.endsWith(":")
|
||||
)
|
||||
insert = ":";
|
||||
const opensBlock =
|
||||
text.endsWith("{") ||
|
||||
|
||||
@@ -5,7 +5,13 @@
|
||||
* and indentation fall back to the backend definition when the mode lacks them.
|
||||
*/
|
||||
import { EditorState, Prec, type Extension } from "@codemirror/state";
|
||||
import { LRLanguage, LanguageDescription, LanguageSupport } from "@codemirror/language";
|
||||
import {
|
||||
LRLanguage,
|
||||
LanguageDescription,
|
||||
LanguageSupport,
|
||||
indentService,
|
||||
language as languageFacet,
|
||||
} from "@codemirror/language";
|
||||
import { styleTags, tags as t } from "@lezer/highlight";
|
||||
import { languages as languageData } from "@codemirror/language-data";
|
||||
import type { DocBackend, LanguageInfo } from "../backend";
|
||||
@@ -118,11 +124,103 @@ export function loadedSupport(desc: LanguageDescription | null): LanguageSupport
|
||||
return desc?.support ?? null;
|
||||
}
|
||||
|
||||
/** Comment tokens from the backend definition, below the mode's own data. */
|
||||
export function commentTokens(info: LanguageInfo | undefined): Extension {
|
||||
if (!info || (!info.lineComment && !info.blockComment)) return [];
|
||||
const tokens: { line?: string; block?: { open: string; close: string } } = {};
|
||||
if (info.lineComment) tokens.line = info.lineComment;
|
||||
if (info.blockComment) tokens.block = { open: info.blockComment[0], close: info.blockComment[1] };
|
||||
return Prec.lowest(EditorState.languageData.of(() => [{ commentTokens: tokens }]));
|
||||
const COLON_INDENT_LANGS = new Set(["gdscript", "python"]);
|
||||
const DEDENT_STATEMENT = /^\s*(?:pass|break|continue|return)\b/;
|
||||
const KEYWORD_OPEN_LANGS = new Set(["elixir", "ruby", "lua"]);
|
||||
const KEYWORD_OPEN =
|
||||
/\b(?:do(?:\s*\|[^|]*\|)?|then|->)$|^\s*(?:def|defp|defmodule|class|module|function|local\s+function|if|unless|elsif|elseif|else|while|until|for|case|begin|cond|with|receive|try)\b/;
|
||||
const NO_INDENT_LANGS = new Set(["plaintext", "markdown", "log", "diff", "ignore", "gitattributes"]);
|
||||
|
||||
function opensBlock(code: string, language: string | undefined): boolean {
|
||||
if (!code) return false;
|
||||
if (/[{([]$/.test(code)) return true;
|
||||
if (COLON_INDENT_LANGS.has(language ?? "") && code.endsWith(":")) return true;
|
||||
if (language === "elixir") return /\bdo$|->$/.test(code);
|
||||
if (KEYWORD_OPEN_LANGS.has(language ?? "") && !/\bend$/.test(code) && KEYWORD_OPEN.test(code)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function dedentsAfter(code: string, language: string | undefined): boolean {
|
||||
if (!COLON_INDENT_LANGS.has(language ?? "")) return false;
|
||||
return DEDENT_STATEMENT.test(code) && !/[,\\]$/.test(code);
|
||||
}
|
||||
|
||||
/** Hide literal contents before interpreting punctuation as block syntax. */
|
||||
function indentationCode(text: string, lineComment: string | undefined): string {
|
||||
let code = "";
|
||||
let quote: string | null = null;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i]!;
|
||||
if (quote) {
|
||||
if (ch === "\\") i++;
|
||||
else if (ch === quote) quote = null;
|
||||
} else if (ch === '"' || ch === "'" || ch === "`") {
|
||||
quote = ch;
|
||||
code += "x";
|
||||
} else if (lineComment && text.startsWith(lineComment, i)) break;
|
||||
else code += ch;
|
||||
}
|
||||
return code.trimEnd();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback indentation for languages without a CodeMirror mode (GDScript, Zig,
|
||||
* Elixir, HCL, Terraform…): preserves the previous line's indentation and
|
||||
* indents one unit deeper after a block opener (`:`, `{`, `(`, `[` or keyword).
|
||||
*/
|
||||
export function fallbackIndent(language?: string): Extension {
|
||||
return indentService.of((cx, pos) => {
|
||||
if (cx.state.facet(languageFacet)) return undefined;
|
||||
const lineComment = cx.state.languageDataAt<{ line?: string }>("commentTokens", pos)[0]?.line;
|
||||
const stripComment = (text: string) => indentationCode(text, lineComment);
|
||||
|
||||
const after = cx.textAfterPos(pos);
|
||||
const closingBracket = /^\s*[}\])]/.test(after);
|
||||
|
||||
if (cx.simulatedBreak != null) {
|
||||
const line = cx.state.doc.lineAt(pos);
|
||||
const before = line.text.slice(0, pos - line.from);
|
||||
const base = cx.countColumn(before, before.search(/\S|$/));
|
||||
if (/\S/.test(before)) {
|
||||
const code = stripComment(before);
|
||||
if (closingBracket) return /[{([]$/.test(code) ? base : Math.max(0, base - cx.unit);
|
||||
if (opensBlock(code, language)) return base + cx.unit;
|
||||
if (dedentsAfter(code, language)) return Math.max(0, base - cx.unit);
|
||||
return base;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
const curLine = cx.state.doc.lineAt(pos);
|
||||
for (let n = curLine.number - 1; n >= 1; n--) {
|
||||
const prev = cx.state.doc.line(n);
|
||||
if (!/\S/.test(prev.text)) continue;
|
||||
const base = cx.countColumn(prev.text, prev.text.search(/\S|$/));
|
||||
const code = stripComment(prev.text);
|
||||
if (closingBracket) return /[{([]$/.test(code) ? base : Math.max(0, base - cx.unit);
|
||||
if (opensBlock(code, language)) return base + cx.unit;
|
||||
if (dedentsAfter(code, language)) return Math.max(0, base - cx.unit);
|
||||
return base;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
/** Comment tokens and fallback indentation from the backend definition, below the mode's own data. */
|
||||
export function commentTokens(info: LanguageInfo | undefined, language = info?.id): Extension {
|
||||
const lang = language ?? info?.id;
|
||||
const hasComments = !!info && !!(info.lineComment || info.blockComment);
|
||||
const needsIndent = !!lang && !NO_INDENT_LANGS.has(lang);
|
||||
if (!hasComments && !needsIndent) return [];
|
||||
const data: Record<string, unknown> = {};
|
||||
if (hasComments) {
|
||||
const tokens: { line?: string; block?: { open: string; close: string } } = {};
|
||||
if (info!.lineComment) tokens.line = info!.lineComment;
|
||||
if (info!.blockComment) tokens.block = { open: info!.blockComment[0], close: info!.blockComment[1] };
|
||||
data.commentTokens = tokens;
|
||||
}
|
||||
if (needsIndent) data.indentOnInput = /^\s*[}\])]$/;
|
||||
const out: Extension[] = [Prec.lowest(EditorState.languageData.of(() => [data]))];
|
||||
if (needsIndent) out.push(Prec.lowest(fallbackIndent(lang)));
|
||||
return out;
|
||||
}
|
||||
@@ -355,7 +355,7 @@ export function baseExtensions(o: SetupOptions): Extension {
|
||||
tabKeymap,
|
||||
keymap.of([...closeBracketsKeymap, ...editorBaseKeymap, indentWithTab]),
|
||||
compartments.language.of(o.language ?? []),
|
||||
compartments.comments.of(commentTokens(o.info)),
|
||||
compartments.comments.of(commentTokens(o.info, o.ctx.language)),
|
||||
compartments.indent.of(indentExtension(o.indent ?? effectiveIndent(o.settings, o.info))),
|
||||
compartments.readOnly.of(readOnlyExtension(o.ctx.readOnly)),
|
||||
compartments.wrap.of(wrapExtension(o.settings, o.ctx.path, o.wrapOverride)),
|
||||
|
||||
@@ -101,6 +101,7 @@ describe("smart backspace", () => {
|
||||
it("applies in 'proper indent' mode except in indentation-based languages", () => {
|
||||
expect(smartBackspaceApplies("autoindent", "java")).toBe(true);
|
||||
expect(smartBackspaceApplies("autoindent", "python")).toBe(false);
|
||||
expect(smartBackspaceApplies("autoindent", "gdscript")).toBe(false);
|
||||
expect(smartBackspaceApplies("nearest", "java")).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ export const tabKeymap: Extension = keymap.of([{ key: "Tab", run: insertTab }]);
|
||||
// --- Backspace -------------------------------------------------------------------------------
|
||||
|
||||
/** Languages whose layout is indentation: IDEA keeps "to nearest indent position" there. */
|
||||
const NEAREST_INDENT_LANGS = new Set(["python", "yaml", "shellscript"]);
|
||||
const NEAREST_INDENT_LANGS = new Set(["python", "gdscript", "yaml", "shellscript"]);
|
||||
|
||||
export function smartBackspaceApplies(mode: string, language: string | undefined): boolean {
|
||||
return mode === "autoindent" && !NEAREST_INDENT_LANGS.has(language ?? "");
|
||||
|
||||
@@ -422,7 +422,7 @@ export class DocModel {
|
||||
compartments.wrap.reconfigure(wrapExtension(editor, this.path, this.softWrapOverride)),
|
||||
compartments.sticky.reconfigure(stickyExtension(editor)),
|
||||
compartments.font.reconfigure(fontExtension(editor)),
|
||||
compartments.comments.reconfigure(commentTokens(this.info)),
|
||||
compartments.comments.reconfigure(commentTokens(this.info, this.meta.language)),
|
||||
compartments.lineNumbers.reconfigure(lineNumbersExtension(editor)),
|
||||
compartments.folding.reconfigure(foldingFor(editor)),
|
||||
compartments.caret.reconfigure(caretFor(editor)),
|
||||
|
||||
Reference in new issue
Заблокувати користувача