feat(languages): Add Godot / GDScript support (#73)

Adds GDScript, Godot resource and shader file types with tree-sitter highlighting, the GDScript language server over TCP (a headless Godot editor started on demand), "Godot Project" / current-scene run configurations, and debugging through Godot's debug adapter.

Breakpoints set through symlinked paths (/tmp, /var on macOS) were reported verified but never hit; the adapter now sends resolved paths and maps stack frames back to the paths the IDE opened. The duplicate exit event Godot sends after disconnect is ignored.

Fixes #68

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-27 15:31:25 +08:00
1 джерело 2eaa6cb0c7
коміт e3dae02de7
52 files changed
+1584 -33

No files matched your search

+11
Переглянути файл
@@ -197,4 +197,15 @@ describe("run config → launch config", () => {
// lldb-dap takes `KEY=VALUE` strings.
expect(extra(dap)).toEqual({ env: ["K=v"] });
});
it("Godot: the editor's adapter plays the main scene or the chosen one of the project", () => {
const project = rc({ kind: "godot", command: "/Apps/Godot", args: ["--path", "."], cwd: "game", debugAdapter: "godot" });
const main = launchConfigFor(project, "/p")!;
expect(main).toMatchObject({ adapter: "godot", request: "launch", cwd: "/p/game", program: null });
expect(extra(main)).toEqual({ project: "/p/game", scene: "main" });
const scene = launchConfigFor({ ...project, args: ["--path", ".", "res://levels/one.tscn"] }, "/p")!;
expect(extra(scene)).toEqual({ project: "/p/game", scene: "res://levels/one.tscn" });
const relative = launchConfigFor({ ...project, cwd: null, args: ["--path", "game", "menu.tscn"] }, "/p")!;
expect(extra(relative)).toEqual({ project: "/p/game", scene: "res://menu.tscn" });
});
});
+8
Переглянути файл
@@ -137,6 +137,14 @@ describe("remote attach requests", () => {
});
});
it("Attach to Godot connects to the editor's debug adapter for the project", () => {
const l = remoteAttachConfig(configOf(remote.godotAttachType), "/p/game")!;
expect(l).toMatchObject({ adapter: "godot", request: "attach", cwd: "/p/game" });
expect(JSON.parse(l.extraJson!)).toEqual({ debugServer: "localhost:6006", project: "/p/game" });
const custom = remoteAttachConfig(configOf(remote.godotAttachType, { port: "6010" }), "/p")!;
expect(JSON.parse(custom.extraJson!).debugServer).toBe("localhost:6010");
});
it("Attach to Node.js attaches js-debug to the inspector with local/remote roots", () => {
const l = remoteAttachConfig(configOf(nodeRemoteType, { host: "web", remoteRoot: "/usr/src/app" }), "/p")!;
expect(l.adapter).toBe("js-debug");
+19
Переглянути файл
@@ -196,6 +196,24 @@ function delveLaunch(rc: RunConfig, cwd: string): LaunchConfig {
return out;
}
/** The scene a `godot --path <dir> [scene]` command line plays: `main`, or a `res://` path. */
export function godotScene(args: readonly string[]): string {
const scene = args.find((a, i) => args[i - 1] !== "--path" && /\.t?scn$/i.test(a));
if (!scene) return "main";
return scene.startsWith("res://") ? scene : `res://${scene.replace(/^\.\//, "")}`;
}
/**
* Godot: the adapter inside the editor plays the scene itself (with the editor's run
* settings), so the launch names the project (checked against the editor's) and the scene.
*/
function godotLaunch(rc: RunConfig, cwd: string): LaunchConfig {
const i = rc.args.indexOf("--path");
const dir = i >= 0 ? rc.args[i + 1] : undefined;
const project = dir ? join(cwd, dir) : cwd;
return { ...base(rc, "godot", project), extraJson: JSON.stringify({ project, scene: godotScene(rc.args) }) };
}
/** A native program under CodeLLDB or lldb-dap: program, args, cwd, env. */
function nativeLaunch(rc: RunConfig, cwd: string, adapter: string): LaunchConfig {
const out: LaunchConfig = { ...base(rc, adapter, cwd), program: resolveCommand(rc.command, cwd), args: [...rc.args] };
@@ -219,6 +237,7 @@ export function launchConfigFor(rc: RunConfig, root: string): LaunchConfig | nul
if (adapter === "debugpy") return debugpyLaunch(rc, cwd);
if (adapter === "js-debug") return jsDebugLaunch(rc, cwd);
if (adapter === "delve") return delveLaunch(rc, cwd);
if (adapter === "godot") return godotLaunch(rc, cwd);
// Adapters without a template (hidden in Edit Configurations): generic arguments.
return {
...base(rc, adapter, cwd),
+28
Переглянути файл
@@ -12,6 +12,7 @@ export const REMOTE_JVM = "remote-jvm";
export const GO_REMOTE = "go-remote";
export const PYTHON_REMOTE = "python-remote";
export const NODE_REMOTE = "node-remote";
export const GODOT_ATTACH = "godot-attach";
type Params = Readonly<Record<string, string>>;
@@ -220,11 +221,36 @@ export const nodeRemoteType: RunConfigTypeContribution = {
commandLine: () => null,
};
// ---------------------------------------------------------------------------
// Attach to Godot (the editor's debug adapter, Editor Settings › Network › Debug Adapter)
export const godotAttachType: RunConfigTypeContribution = {
id: GODOT_ATTACH,
label: "Attach to Godot",
icon: "file-gdscript",
description: "Debug the game the Godot editor is running, through the editor's debug adapter",
defaults: { host: "localhost", port: "6006" },
debugOnly: true,
debugAdapter: "godot",
fields: [
hostField,
portField,
{
key: "hint",
label: "Before attaching",
control: "output",
value: () => "Open the project in the Godot editor and run a scene (F5 / F6)",
},
],
commandLine: () => null,
};
export const REMOTE_TYPES: readonly RunConfigTypeContribution[] = [
remoteJvmType,
goRemoteType,
pythonRemoteType,
nodeRemoteType,
godotAttachType,
];
export const isRemoteConfig = (c: Pick<RunConfig, "kind">) => REMOTE_TYPES.some((t) => t.id === c.kind);
@@ -291,6 +317,8 @@ export function remoteAttachConfig(rc: RunConfig, root: string): LaunchConfig |
justMyCode: skip,
...(map ? { pathMappings: [{ localRoot: map.local, remoteRoot: map.remote }] } : {}),
});
case GODOT_ATTACH:
return attachBase(rc, "godot", root, { debugServer: serverAddress(host, portOf(p, 6006)), project: root });
case NODE_REMOTE:
return attachBase(rc, "js-debug", root, {
type: "pwa-node",
+20
Переглянути файл
@@ -262,6 +262,26 @@ describe("project producers", () => {
cfg({ id: "pytest", kind: "pytest", command: ".venv/bin/pytest", args: [], isTest: true }),
];
it("Godot scenes run with godot --path in the project holding them (Current Scene)", () => {
const godot = [
cfg({ id: "godot:project", kind: "godot", command: "godot", args: ["--path", "."], debugAdapter: "godot" }),
cfg({ id: "godot:project:demo", kind: "godot", command: "godot", args: ["--path", "."], cwd: "demo" }),
];
const root = produceContext({ path: `${ROOT}/levels/one.tscn`, root: ROOT, configs: godot }, OPTIMISTIC)!;
expect(root).toMatchObject({ name: "one.tscn", scope: "file" });
expect(root.config).toMatchObject({
kind: "godot",
command: "godot",
args: ["--path", ".", "res://levels/one.tscn"],
cwd: null,
debugAdapter: "godot",
});
const nested = produceContext({ path: `${ROOT}/demo/main.tscn`, root: ROOT, configs: godot }, OPTIMISTIC)!;
expect(nested.config).toMatchObject({ args: ["--path", ".", "res://main.tscn"], cwd: "demo" });
// Outside a Godot project there is nothing to run.
expect(produceContext({ path: `${ROOT}/a.tscn`, root: ROOT, configs: [] }, OPTIMISTIC)).toBeNull();
});
it("keeps package.json test files on the project's runner", () => {
const p = produceContext({ path: `${ROOT}/src/a.test.ts`, root: ROOT, configs, content: "test('x', () => {})" }, ALL)!;
expect(p).toMatchObject({ name: "a.test.ts", scope: "file" });
+28
Переглянути файл
@@ -511,6 +511,33 @@ function cargoOwner(rel: string, configs: readonly RunConfig[]): RunConfig[] {
const hasBuildTool = (configs: readonly RunConfig[], kinds: string[]) => configs.some((c) => kinds.includes(c.kind));
/**
* A Godot scene: `godot --path <project> res://<scene>` in the Godot project holding it
* (a detected "Godot Project" configuration), debuggable through the editor's adapter.
*/
function godotScene(rel: string, configs: readonly RunConfig[]): Produced | null {
const dirs = configs
.filter((c) => c.kind === "godot" && c.detected && c.id.startsWith("godot:project"))
.map((c) => (c.cwd ?? "").replace(/^\.\/?/, "").replace(/\/+$/, ""));
const dir = dirs.filter((d) => !d || rel.startsWith(d + "/")).sort((a, b) => b.length - a.length)[0];
if (dir === undefined) return null;
const name = baseName(rel);
const scene = `res://${dir ? rel.slice(dir.length + 1) : rel}`;
const config: RunConfig = {
id: `context:file:${rel}`,
name,
kind: "godot",
command: "godot",
args: ["--path", ".", scene],
cwd: dir || null,
env: {},
detected: true,
isTest: false,
debugAdapter: "godot",
};
return { name, scope: "file", config };
}
/** The file as a whole: tests of a test file, the project's run target, or the runtime for its language. */
export function produceFile(input: ContextInput, env: ToolEnv): Produced | null {
const s = site(input);
@@ -519,6 +546,7 @@ export function produceFile(input: ContextInput, env: ToolEnv): Produced | null
const rel = s.idPath;
if (s.inProject) {
if (/\.t?scn$/i.test(rel)) return godotScene(rel, configs);
// Project-aware: test files through the project's test runner, Cargo / Go / Gradle / Maven mains.
const role = fileRole(rel, input.content);
if (role === "test") {
+20
Переглянути файл
@@ -77,6 +77,26 @@ describe("configuration types", () => {
expect(withParams(old, params)).toMatchObject({ command: "cargo", args: ["run", "--bin", "my app"] });
});
it("Godot runs the main scene or a chosen one, and reads detected configurations back", () => {
const godot = typeFor("godot")!;
expect(godot.debugAdapter).toBe("godot");
const c = cfg("g", { kind: "godot" });
expect(withParams(c, { projectDir: "game", scene: "", arguments: "" })).toMatchObject({
command: "godot",
args: ["--path", "."],
cwd: "game",
});
expect(withParams(c, { scene: "res://one.tscn", arguments: "--debug-collisions" })).toMatchObject({
args: ["--path", ".", "res://one.tscn", "--debug-collisions"],
cwd: null,
});
const detected = cfg("godot:project", { kind: "godot", command: "/Apps/Godot.app/Contents/MacOS/Godot", args: ["--path", "."] });
expect(godot.paramsFrom!(detected)).toEqual({ projectDir: "", scene: "", arguments: "" });
const scene = { ...detected, cwd: "demo", args: ["--path", ".", "res://a.tscn", "--verbose"] };
expect(godot.paramsFrom!(scene)).toEqual({ projectDir: "demo", scene: "res://a.tscn", arguments: "--verbose" });
expect(godot.paramsFrom!(cfg("x", { command: "cargo" }))).toBeNull();
});
it("shell script files and text derive the command line", () => {
const c = cfg("s");
expect(
+42 -1
Переглянути файл
@@ -111,6 +111,47 @@ const program: RunConfigTypeContribution = {
c.args.length || !/\s/.test(c.command) ? { program: c.command, arguments: formatArgs(c.args) } : null,
};
const isScene = (a: string) => /\.t?scn$/i.test(a);
const isGodot = (cmd: string) => /^godot([-.\w]*)$/i.test(baseName(cmd).replace(/\.exe$/i, ""));
/**
* Godot: `godot --path <project>` runs the main scene, `… <scene>` one scene. The
* executable is the one from Settings › Language Servers › godot (else detected).
*/
const godot: RunConfigTypeContribution = {
id: "godot",
label: "Godot",
icon: "file-gdscript",
description: "Run a Godot project's main scene or a chosen scene",
ownsWorkingDirectory: true,
debugAdapter: "godot",
fields: [
{ key: "projectDir", label: "Project directory", mono: true, placeholder: "Project root (holds project.godot)" },
{
key: "scene",
label: "Scene",
mono: true,
placeholder: "Main scene",
hint: "e.g. res://levels/level_1.tscn; empty runs the project's main scene",
suggestions: (configs) => [...new Set(configs.filter((c) => c.kind === "godot").flatMap((c) => c.args.filter(isScene)))],
},
{ key: "arguments", label: "Arguments", mono: true, placeholder: "Godot command line options, e.g. --debug-collisions" },
],
commandLine: (p) => ({
command: "godot",
args: ["--path", ".", ...(p.scene?.trim() ? [p.scene.trim()] : []), ...parseArgs(p.arguments ?? "")],
cwd: p.projectDir?.trim() || null,
}),
paramsFrom(c) {
if (!isGodot(c.command)) return null;
const rest = [...c.args];
const path = rest.indexOf("--path");
if (path >= 0) rest.splice(path, 2);
const scene = rest.find(isScene) ?? "";
return { projectDir: dirOf(c.cwd), scene, arguments: formatArgs(rest.filter((a) => a !== scene)) };
},
};
const PACKAGE_MANAGERS = ["npm", "pnpm", "yarn", "bun"];
const NPM_COMMANDS = ["run", "test", "start", "install", "ci", "publish", "pack"];
@@ -228,7 +269,7 @@ const compound: RunConfigTypeContribution = {
commandLine: () => null,
};
export const BUILTIN_TYPES: readonly RunConfigTypeContribution[] = [shell, program, npm, gradle, maven, compound];
export const BUILTIN_TYPES: readonly RunConfigTypeContribution[] = [shell, program, npm, gradle, maven, godot, compound];
export function registerBuiltinTypes(): () => void {
const disposers = BUILTIN_TYPES.map(registerRunConfigType);
+6
Переглянути файл
@@ -112,6 +112,9 @@ const ICONS = {
"file-ruby": "openidea/fileTypes/ruby",
"file-lua": "openidea/fileTypes/lua",
"file-zig": "openidea/fileTypes/zig",
"file-gdscript": "openidea/fileTypes/gdscript",
"file-godot": "openidea/fileTypes/godot",
"file-gdshader": "openidea/fileTypes/gdshader",
"file-dart": "openidea/fileTypes/dart",
"file-elixir": "openidea/fileTypes/elixir",
"file-erlang": "openidea/fileTypes/erlang",
@@ -800,6 +803,9 @@ const EXT_ICONS = table({
"file-ruby": "rb rake gemspec ru rbi jbuilder podspec",
"file-lua": "lua luau rockspec",
"file-zig": "zig zon",
"file-gdscript": "gd",
"file-godot": "tscn tres godot import",
"file-gdshader": "gdshader gdshaderinc",
"file-dart": "dart",
"file-elixir": "ex exs",
"file-erlang": "erl hrl escript app.src",
+3
Переглянути файл
@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g stroke-linejoin="round"><path d="M14 4C14 2.89543 13.1046 2 12 2H4C2.89543 2 2 2.89543 2 4V12C2 13.1046 2.89543 14 4 14H12C13.1046 14 14 13.1046 14 12V4Z" fill="#478CBF"/><path transform="translate(4 6)" d="M3.5 1.4C3.2 0.8 2.6 0.5 2 0.5C1.1 0.5 0.5 1.3 0.5 2.3V3.7C0.5 4.7 1.1 5.5 2 5.5C2.9 5.5 3.5 4.9 3.5 4V3.5H2" stroke="white"/><path transform="translate(9 6)" d="M0.5 0.5V5.5H1.7C2.8 5.5 3.5 4.7 3.5 3.6V2.4C3.5 1.3 2.8 0.5 1.7 0.5Z" stroke="white"/></g>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 567 B

+3
Переглянути файл
@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g stroke-linejoin="round"><path d="M14 4C14 2.89543 13.1046 2 12 2H4C2.89543 2 2 2.89543 2 4V12C2 13.1046 2.89543 14 4 14H12C13.1046 14 14 13.1046 14 12V4Z" fill="#5C9DD1"/><path transform="translate(4 6)" d="M3.5 1.4C3.2 0.8 2.6 0.5 2 0.5C1.1 0.5 0.5 1.3 0.5 2.3V3.7C0.5 4.7 1.1 5.5 2 5.5C2.9 5.5 3.5 4.9 3.5 4V3.5H2" stroke="#2B2D30"/><path transform="translate(9 6)" d="M0.5 0.5V5.5H1.7C2.8 5.5 3.5 4.7 3.5 3.6V2.4C3.5 1.3 2.8 0.5 1.7 0.5Z" stroke="#2B2D30"/></g>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 571 B

+3
Переглянути файл
@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g stroke-linejoin="round"><path d="M14 4C14 2.89543 13.1046 2 12 2H4C2.89543 2 2 2.89543 2 4V12C2 13.1046 2.89543 14 4 14H12C13.1046 14 14 13.1046 14 12V4Z" fill="#8E5DB8"/><path transform="translate(4 6)" d="M3.5 1.3C3.2 0.8 2.7 0.5 2 0.5C1.1 0.5 0.5 1 0.5 1.7C0.5 3.2 3.5 2.6 3.5 4.3C3.5 5 2.9 5.5 2 5.5C1.3 5.5 0.7 5.2 0.4 4.6" stroke="white"/><path transform="translate(9 6)" d="M0.5 0V6M3.5 0V6M0.5 3H3.5" stroke="white"/></g>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 536 B

+3
Переглянути файл
@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g stroke-linejoin="round"><path d="M14 4C14 2.89543 13.1046 2 12 2H4C2.89543 2 2 2.89543 2 4V12C2 13.1046 2.89543 14 4 14H12C13.1046 14 14 13.1046 14 12V4Z" fill="#A577CC"/><path transform="translate(4 6)" d="M3.5 1.3C3.2 0.8 2.7 0.5 2 0.5C1.1 0.5 0.5 1 0.5 1.7C0.5 3.2 3.5 2.6 3.5 4.3C3.5 5 2.9 5.5 2 5.5C1.3 5.5 0.7 5.2 0.4 4.6" stroke="#2B2D30"/><path transform="translate(9 6)" d="M0.5 0V6M3.5 0V6M0.5 3H3.5" stroke="#2B2D30"/></g>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 540 B

+3
Переглянути файл
@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g stroke-linejoin="round"><path d="M14 4C14 2.89543 13.1046 2 12 2H4C2.89543 2 2 2.89543 2 4V12C2 13.1046 2.89543 14 4 14H12C13.1046 14 14 13.1046 14 12V4Z" fill="#478CBF"/><path transform="translate(4 6)" d="M3.5 1.4C3.2 0.8 2.6 0.5 2 0.5C1.1 0.5 0.5 1.3 0.5 2.3V3.7C0.5 4.7 1.1 5.5 2 5.5C2.9 5.5 3.5 4.9 3.5 4V3.5H2" stroke="white"/><path transform="translate(9 6)" d="M0.5 5.5V0.5H2C3 0.5 3.5 1.1 3.5 1.9C3.5 2.7 3 3.3 2 3.3H0.5M2 3.3L3.5 5.5" stroke="white"/></g>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 572 B

+3
Переглянути файл
@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g stroke-linejoin="round"><path d="M14 4C14 2.89543 13.1046 2 12 2H4C2.89543 2 2 2.89543 2 4V12C2 13.1046 2.89543 14 4 14H12C13.1046 14 14 13.1046 14 12V4Z" fill="#5C9DD1"/><path transform="translate(4 6)" d="M3.5 1.4C3.2 0.8 2.6 0.5 2 0.5C1.1 0.5 0.5 1.3 0.5 2.3V3.7C0.5 4.7 1.1 5.5 2 5.5C2.9 5.5 3.5 4.9 3.5 4V3.5H2" stroke="#2B2D30"/><path transform="translate(9 6)" d="M0.5 5.5V0.5H2C3 0.5 3.5 1.1 3.5 1.9C3.5 2.7 3 3.3 2 3.3H0.5M2 3.3L3.5 5.5" stroke="#2B2D30"/></g>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 576 B