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]>
Цей коміт міститься в:
Kartik authored and GitHub committed 2026-10-29 15:03:25 +08:00
1 джерело 1283af8a41
коміт 3f9512056d
8 files changed
+206 -13

No files matched your search

+10
Переглянути файл
@@ -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",
+80 -1
Переглянути файл
@@ -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);
});
});
+6 -1
Переглянути файл
@@ -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("{") ||
+106 -8
Переглянути файл
@@ -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;
}
+1 -1
Переглянути файл
@@ -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)),
+1
Переглянути файл
@@ -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);
});
+1 -1
Переглянути файл
@@ -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 ?? "");
+1 -1
Переглянути файл
@@ -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)),