From 0b95b74f73d8e20cbbc37200133f159d61ae8d9d Mon Sep 17 00:00:00 2001 From: Will Thompson Date: Thu, 19 Mar 2026 15:36:21 +0000 Subject: [PATCH] ci: Add LSP diagnostics checker The Godot editor acts as a Language Server Protocol server, and can issue diagnostics about GDScript files. Add a script that connects to this GDScript LSP server, requests diagnostics for every `.gd` file in the project (except a hardcoded list of exceptions), then outputs them. It can either launch (and terminate) its own headless instance of Godot, or connect to an existing one. When run in GitHub Actions, emit them in the [format][0] that causes them to show up in the pull request diff view against the line that triggered them, at a level corresponding to how bad Godot believes them to be. [0]: https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands#setting-a-notice-message Run this script during the export job (where the godot binary is available) but do not fail the export if it fails: we still want to be able to test a change, even if a function is missing a return type. Add types to Kenney spritesheet importer script. Don't check addons, script templates (which are not necessarily well-formed GDScript files), or scripts in StoryQuests. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/export.yml | 5 + .../import_spritesheets.gd | 12 +- tools/check-gdscript-lsp-diagnostics.py | 291 ++++++++++++++++++ 3 files changed, 302 insertions(+), 6 deletions(-) create mode 100644 tools/check-gdscript-lsp-diagnostics.py diff --git a/.github/workflows/export.yml b/.github/workflows/export.yml index 66a8214d62..17046376e9 100644 --- a/.github/workflows/export.yml +++ b/.github/workflows/export.yml @@ -68,6 +68,11 @@ jobs: with: godot_version: "4.6.1" + - name: Check GDScript diagnostics + run: | + python tools/check-gdscript-lsp-diagnostics.py --godot build/godot || \ + echo "::warning::GDScript diagnostics check failed (exit code $?)" + - name: Upload web artifact uses: actions/upload-artifact@v7 with: diff --git a/assets/third_party/inputs/atlas_kenney_input_prompts_1.4/import_spritesheets.gd b/assets/third_party/inputs/atlas_kenney_input_prompts_1.4/import_spritesheets.gd index c52e3ace62..3a6ed42d7a 100644 --- a/assets/third_party/inputs/atlas_kenney_input_prompts_1.4/import_spritesheets.gd +++ b/assets/third_party/inputs/atlas_kenney_input_prompts_1.4/import_spritesheets.gd @@ -35,14 +35,14 @@ func import(spritesheet_xml_file: String) -> void: create_atlas_textures(folder, full_image, atlas) -func create_atlas_textures(folder: String, full_image: Texture2D, atlas: Dictionary): +func create_atlas_textures(folder: String, full_image: Texture2D, atlas: Dictionary) -> bool: for sprite: Dictionary in atlas.sprites: if not create_atlas_texture(folder, full_image, sprite): return false return true -func create_atlas_texture(folder: String, full_image: Texture2D, sprite: Dictionary): +func create_atlas_texture(folder: String, full_image: Texture2D, sprite: Dictionary) -> bool: var name := "%s/%s.%s" % [folder, sprite.name, "tres"] var texture: AtlasTexture if ResourceLoader.exists(name, "AtlasTexture"): @@ -69,7 +69,7 @@ func create_atlas_texture(folder: String, full_image: Texture2D, sprite: Diction func save_resource(name: String, texture: AtlasTexture) -> bool: - var status = ResourceSaver.save(texture, name) + var status := ResourceSaver.save(texture, name) if status != OK: printerr("Failed to save resource " + name) return false @@ -81,19 +81,19 @@ func read_kenney_sprite_sheet(source_file: String) -> Dictionary: var sprites: Array[Dictionary] var parser := XMLParser.new() if OK == parser.open(source_file): - var read = parser.read() + var read := parser.read() if read == OK: atlas["sprites"] = sprites while read != ERR_FILE_EOF: if parser.get_node_type() == XMLParser.NODE_ELEMENT: - var node_name = parser.get_node_name() + var node_name := parser.get_node_name() match node_name: "TextureAtlas": atlas["imagePath"] = source_file.get_base_dir().path_join( parser.get_named_attribute_value("imagePath") ) "SubTexture": - var sprite = {} + var sprite := {} sprite["name"] = parser.get_named_attribute_value("name") sprite["x"] = float(parser.get_named_attribute_value("x")) sprite["y"] = float(parser.get_named_attribute_value("y")) diff --git a/tools/check-gdscript-lsp-diagnostics.py b/tools/check-gdscript-lsp-diagnostics.py new file mode 100644 index 0000000000..dd7da5d55d --- /dev/null +++ b/tools/check-gdscript-lsp-diagnostics.py @@ -0,0 +1,291 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: The Threadbare Authors +# SPDX-License-Identifier: MPL-2.0 +""" +Check GDScript files for diagnostics using Godot's built-in LSP server. + +Usage: + python tools/check-gdscript-lsp-diagnostics.py [--godot GODOT] [--port PORT] [file1.gd ...] + +If no files are given, all .gd files under the current directory are checked, +except for a hardcoded list of exceptions. +""" + +import argparse +import json +import os +import pathlib +import select +import signal +import socket +import subprocess +import sys +import time +from contextlib import contextmanager + +LSP_INITIALIZE_TIMEOUT = 10 # seconds to wait for Godot LSP to accept connections +DIAGNOSTIC_TIMEOUT = 5 # seconds to wait for diagnostics after last message +EXCLUDED = [ + "script_templates", + "scenes/quests/story_quests", +] + + +@contextmanager +def godot_lsp(executable: str, port: int, project_root: pathlib.Path): + """Launch Godot as a headless LSP server and terminate it on exit.""" + cmd = [ + executable, + "--headless", + "--editor", + "--lsp-port", + str(port), + "--path", + str(project_root), + ] + print(f"Launching: {' '.join(cmd)}", file=sys.stderr) + proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + yield proc + finally: + proc.send_signal(signal.SIGTERM) + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + + +def path_to_uri(path: pathlib.Path) -> str: + return path.absolute().as_uri() + + +class LspClient: + def __init__(self, sock: socket.socket): + self._sock = sock + self._msg_id = 0 + + def __enter__(self): + return self + + def __exit__(self, *_): + try: + self._sock.close() + except OSError: + pass + + def _send(self, msg: dict) -> None: + body = json.dumps(msg, separators=(",", ":")) + frame = f"Content-Length: {len(body)}\r\n\r\n{body}".encode() + self._sock.sendall(frame) + + def request(self, method: str, params: dict) -> int: + self._msg_id += 1 + self._send( + {"jsonrpc": "2.0", "id": self._msg_id, "method": method, "params": params} + ) + return self._msg_id + + def notify(self, method: str, params: dict) -> None: + self._send({"jsonrpc": "2.0", "method": method, "params": params}) + + def recv(self) -> dict | None: + """Read one LSP message. Returns None on EOF.""" + raw = b"" + while b"\r\n\r\n" not in raw: + chunk = self._sock.recv(1) + if not chunk: + return None + raw += chunk + + header, _ = raw.split(b"\r\n\r\n", 1) + content_length = None + for line in header.split(b"\r\n"): + if line.lower().startswith(b"content-length:"): + content_length = int(line.split(b":", 1)[1].strip()) + if content_length is None: + raise ValueError(f"No Content-Length in LSP header: {header!r}") + + body = b"" + while len(body) < content_length: + chunk = self._sock.recv(content_length - len(body)) + if not chunk: + return None + body += chunk + + return json.loads(body) + + def recv_until_idle(self, timeout: float) -> list[dict]: + """Collect messages until no new message arrives within timeout seconds.""" + messages = [] + last_message_time = time.monotonic() + while True: + remaining = timeout - (time.monotonic() - last_message_time) + if remaining <= 0: + break + ready = select.select([self._sock], [], [], remaining) + if not ready[0]: + break + msg = self.recv() + if msg is None: + break + last_message_time = time.monotonic() + messages.append(msg) + return messages + + +def connect_with_retry(port: int, deadline: float, proc=None) -> socket.socket: + while True: + try: + return socket.create_connection(("127.0.0.1", port), timeout=1) + except (ConnectionRefusedError, TimeoutError, OSError): + if time.monotonic() > deadline: + raise TimeoutError(f"Timed out waiting for Godot LSP on port {port}.") + if proc is not None and proc.poll() is not None: + raise RuntimeError(f"Godot exited early (code {proc.returncode}).") + time.sleep(0.2) + + +def run( + port: int, project_root: pathlib.Path, gd_files: list[pathlib.Path], proc=None +) -> dict[str, list]: + deadline = time.monotonic() + LSP_INITIALIZE_TIMEOUT + sock = connect_with_retry(port, deadline, proc) + sock.setblocking(False) + + with LspClient(sock) as lsp: + # Handshake + init_id = lsp.request( + "initialize", + { + "processId": os.getpid(), + "rootUri": path_to_uri(project_root), + "capabilities": { + "textDocument": { + "publishDiagnostics": {"relatedInformation": False} + } + }, + }, + ) + + # Wait for initialize response + while True: + ready = select.select([sock], [], [], 10) + if not ready[0]: + raise TimeoutError("No response to initialize request.") + msg = lsp.recv() + if msg is None: + raise RuntimeError("LSP server closed connection.") + if msg.get("id") == init_id: + break + + lsp.notify("initialized", {}) + + for path in gd_files: + lsp.notify( + "textDocument/didOpen", + { + "textDocument": { + "uri": path_to_uri(path), + "languageId": "gdscript", + "version": 1, + "text": path.read_text(encoding="utf-8"), + } + }, + ) + + diagnostics: dict[str, list] = {} + for msg in lsp.recv_until_idle(DIAGNOSTIC_TIMEOUT): + if msg.get("method") == "textDocument/publishDiagnostics": + params = msg.get("params", {}) + uri = params.get("uri", "") + diags = params.get("diagnostics", []) + if diags: + diagnostics[uri] = diags + elif uri in diagnostics: + del diagnostics[uri] + + for path in gd_files: + lsp.notify( + "textDocument/didClose", {"textDocument": {"uri": path_to_uri(path)}} + ) + + return diagnostics + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--godot", default="godot", help="Godot executable (default: godot)" + ) + parser.add_argument( + "--port", type=int, default=6005, help="LSP port (default: 6005)" + ) + parser.add_argument( + "--no-launch", + action="store_true", + help="Don't launch Godot; connect to an already-running LSP server", + ) + parser.add_argument("files", nargs="*", help=".gd files to check") + args = parser.parse_args() + + project_root = pathlib.Path(".").absolute() + + if args.files: + gd_files = [pathlib.Path(f).absolute() for f in args.files] + else: + gd_files = sorted( + p + for p in project_root.rglob("*.gd") + if not any(p.is_relative_to(project_root / e) for e in EXCLUDED) + ) + + if not gd_files: + print("No .gd files found.", file=sys.stderr) + return 0 + + try: + if args.no_launch: + diagnostics = run(args.port, project_root, gd_files) + else: + with godot_lsp(args.godot, args.port, project_root) as proc: + diagnostics = run(args.port, project_root, gd_files, proc) + except (TimeoutError, RuntimeError) as e: + print(str(e), file=sys.stderr) + return 1 + + if not diagnostics: + print(f"No diagnostics in {len(gd_files)} files.") + return 0 + + # Severity 1=error, 2=warning, 3=info, 4=hint + severity_names = {1: "error", 2: "warning", 3: "info", 4: "hint"} + # GitHub Actions workflow command levels (info/hint fall back to "notice") + gha_levels = {1: "error", 2: "warning", 3: "notice", 4: "notice"} + in_gha = "GITHUB_ACTIONS" in os.environ + exit_code = 0 + + for uri, diags in sorted(diagnostics.items()): + try: + rel = pathlib.Path(uri.removeprefix("file://")).relative_to(project_root) + except ValueError: + rel = uri + for d in diags: + severity = d.get("severity", 1) + name = severity_names.get(severity, "diagnostic") + start = d.get("range", {}).get("start", {}) + line = start.get("line", 0) + 1 + col = start.get("character", 0) + 1 + message = d.get("message", "") + if in_gha: + level = gha_levels.get(severity, "notice") + print(f"::{level} file={rel},line={line},col={col}::{message}") + else: + print(f"{rel}:{line}:{col}: {name}: {message}") + if severity <= 2: # error or warning + exit_code = 1 + + return exit_code + + +if __name__ == "__main__": + sys.exit(main())