diff --git a/src/editor/core/backend.ts b/src/editor/core/backend.ts index 9cb3a4d51..d03481bbe 100644 --- a/src/editor/core/backend.ts +++ b/src/editor/core/backend.ts @@ -102,6 +102,7 @@ const EXT_LANG: Record = { 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", diff --git a/src/editor/core/cm/editing.test.ts b/src/editor/core/cm/editing.test.ts index 8bae84dde..3be938f42 100644 --- a/src/editor/core/cm/editing.test.ts +++ b/src/editor/core/cm/editing.test.ts @@ -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); + }); +}); diff --git a/src/editor/core/cm/editing.ts b/src/editor/core/cm/editing.ts index ca473e962..452547f6c 100644 --- a/src/editor/core/cm/editing.ts +++ b/src/editor/core/cm/editing.ts @@ -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("{") || diff --git a/src/editor/core/cm/languages.ts b/src/editor/core/cm/languages.ts index ae770b858..7dc310fcf 100644 --- a/src/editor/core/cm/languages.ts +++ b/src/editor/core/cm/languages.ts @@ -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 = {}; + 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; } diff --git a/src/editor/core/cm/setup.ts b/src/editor/core/cm/setup.ts index d19b2a6e7..152930b45 100644 --- a/src/editor/core/cm/setup.ts +++ b/src/editor/core/cm/setup.ts @@ -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)), diff --git a/src/editor/core/cm/smartKeys.test.ts b/src/editor/core/cm/smartKeys.test.ts index 089405f15..a5844dee2 100644 --- a/src/editor/core/cm/smartKeys.test.ts +++ b/src/editor/core/cm/smartKeys.test.ts @@ -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); }); diff --git a/src/editor/core/cm/smartKeys.ts b/src/editor/core/cm/smartKeys.ts index 8f978ba3b..5bcd3b704 100644 --- a/src/editor/core/cm/smartKeys.ts +++ b/src/editor/core/cm/smartKeys.ts @@ -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 ?? ""); diff --git a/src/editor/core/model/document.ts b/src/editor/core/model/document.ts index f741223f9..db0f7ba6f 100644 --- a/src/editor/core/model/document.ts +++ b/src/editor/core/model/document.ts @@ -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)),