From 576b58b0612ae201eae73aa66aad484a8dbcc657 Mon Sep 17 00:00:00 2001 From: omigamedev Date: Thu, 4 Jun 2026 16:03:03 +0200 Subject: [PATCH] Add clangd navigation helper --- .gitignore | 2 + AGENTS.md | 19 ++ docs/modernization/build-inventory.md | 10 + scripts/dev/clangd_nav.py | 442 ++++++++++++++++++++++++++ 4 files changed, 473 insertions(+) create mode 100644 scripts/dev/clangd_nav.py diff --git a/.gitignore b/.gitignore index be919ea..493032c 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,8 @@ PanoPainterPackage/_pkginfo.txt PanoPainterPackage/AppPackages/ PanoPainterPackage/BundleArtifacts/ Thumbs.db +__pycache__/ +*.pyc steam/content/ steam/output/ diff --git a/AGENTS.md b/AGENTS.md index 7843989..d518638 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,25 @@ generated build tree target before judging source changes: cmake --build --preset windows-msvc-default --config Debug --target clean ``` +## Code Navigation + +Prefer compiler-aware navigation when following C++ symbols across the legacy +flat source tree and extracted components: + +```powershell +python scripts/dev/clangd_nav.py symbols --file src/app_core/brush_ui.h --name execute_brush +python scripts/dev/clangd_nav.py definition --file src/node_panel_brush.cpp --line 511 --column 39 +python scripts/dev/clangd_nav.py references --file src/app_core/brush_ui.h --line 783 --column 45 +``` + +The helper talks to `clangd` using an existing `compile_commands.json`. It +defaults to `out/build/windows-clangcl-asan` and then `out/build/android-arm64`; +pass `--compile-commands-dir` or set `PP_CLANGD_COMPILE_COMMANDS_DIR` when using +another Ninja build tree. Use `--name` and `--max-results` to keep output small. +References are fast/current-translation-unit by default; add +`--background-index` only when broader cross-file references are worth the extra +indexing work. + ## Current Architecture Direction The desired component split is documented in the roadmap. Current extracted or diff --git a/docs/modernization/build-inventory.md b/docs/modernization/build-inventory.md index 49a1f7d..1eb8144 100644 --- a/docs/modernization/build-inventory.md +++ b/docs/modernization/build-inventory.md @@ -75,6 +75,8 @@ cmake --preset android-arm64 powershell -ExecutionPolicy Bypass -File scripts\automation\platform-build.ps1 -Presets android-arm64 powershell -ExecutionPolicy Bypass -File scripts\automation\package-smoke.ps1 -Preset windows-msvc-default -Configuration Debug cmake --fresh --preset windows-clangcl-asan +python scripts/dev/clangd_nav.py symbols --file src/app_core/brush_ui.h --name execute_brush +python scripts/dev/clangd_nav.py definition --file src/node_panel_brush.cpp --line 511 --column 39 ``` Known local toolchain state: @@ -88,6 +90,14 @@ Known local toolchain state: - clang-cl: `C:\Program Files\LLVM\bin\clang-cl.exe` reports 18.1.8, but the selected VS 2026-preview STL expects Clang 20 or newer; see DEBT-0014 before treating `windows-clangcl-asan` as a passing sanitizer gate. +- clangd/clang-query/clang-tidy are installed under `C:\Program Files\LLVM\bin`. + `scripts/dev/clangd_nav.py` provides agent-friendly symbol, definition, + declaration, implementation, reference, and hover lookup through clangd using + the current `compile_commands.json` from `windows-clangcl-asan`, + `android-arm64`, or a caller-provided build directory. It disables clangd + background indexing by default for quick current-translation-unit lookups; pass + `--background-index` when broader cross-file references are worth the extra + indexing work. - Android arm64 headless configure/build passes through root CMake and the `platform-build` automation wrapper for `pp_foundation`, `pp_assets`, `pp_paint`, `pp_document`, `pp_renderer_api`, `pp_renderer_gl`, diff --git a/scripts/dev/clangd_nav.py b/scripts/dev/clangd_nav.py new file mode 100644 index 0000000..c45bcce --- /dev/null +++ b/scripts/dev/clangd_nav.py @@ -0,0 +1,442 @@ +#!/usr/bin/env python3 +"""Small clangd navigation helper for agent-friendly C++ code lookup. + +Examples: + python scripts/dev/clangd_nav.py symbols --file src/app_core/brush_ui.h + python scripts/dev/clangd_nav.py definition --file src/node_panel_brush.cpp --line 410 --column 30 + python scripts/dev/clangd_nav.py references --file src/app_core/brush_ui.h --line 192 --column 43 +""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import queue +import subprocess +import sys +import threading +import time +from typing import Any + + +DEFAULT_BUILD_DIRS = ( + "out/build/windows-clangcl-asan", + "out/build/android-arm64", +) + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[2] + + +def _find_compile_commands_dir(repo_root: Path, requested: str | None) -> Path: + if requested: + path = Path(requested).expanduser() + if not path.is_absolute(): + path = repo_root / path + if path.is_file(): + path = path.parent + if not (path / "compile_commands.json").exists(): + raise SystemExit(f"compile_commands.json not found in {path}") + return path.resolve() + + env_dir = os.environ.get("PP_CLANGD_COMPILE_COMMANDS_DIR") + if env_dir: + return _find_compile_commands_dir(repo_root, env_dir) + + for candidate in DEFAULT_BUILD_DIRS: + path = repo_root / candidate + if (path / "compile_commands.json").exists(): + return path.resolve() + + matches = sorted((repo_root / "out" / "build").glob("*/compile_commands.json")) + if matches: + return matches[0].parent.resolve() + + raise SystemExit( + "No compile_commands.json found. Configure a Ninja CMake preset first, " + "or pass --compile-commands-dir." + ) + + +def _resolve_file(repo_root: Path, file_arg: str) -> Path: + path = Path(file_arg).expanduser() + if not path.is_absolute(): + path = repo_root / path + if not path.exists(): + raise SystemExit(f"file not found: {path}") + return path.resolve() + + +def _read_lsp_message(stream: Any) -> dict[str, Any] | None: + content_length: int | None = None + while True: + line = stream.readline() + if not line: + return None + if line in (b"\r\n", b"\n"): + break + name, _, value = line.decode("ascii", errors="replace").partition(":") + if name.lower() == "content-length": + content_length = int(value.strip()) + + if content_length is None: + return None + + payload = stream.read(content_length) + if not payload: + return None + return json.loads(payload.decode("utf-8")) + + +def _write_lsp_message(stream: Any, message: dict[str, Any]) -> None: + payload = json.dumps(message, separators=(",", ":")).encode("utf-8") + header = f"Content-Length: {len(payload)}\r\n\r\n".encode("ascii") + stream.write(header + payload) + stream.flush() + + +class ClangdClient: + def __init__( + self, + clangd: str, + compile_commands_dir: Path, + timeout_seconds: float, + background_index: bool) -> None: + self._timeout_seconds = timeout_seconds + self._next_id = 1 + self._responses: dict[int, dict[str, Any]] = {} + self._condition = threading.Condition() + self._messages: "queue.Queue[dict[str, Any]]" = queue.Queue() + clangd_args = [ + clangd, + f"--compile-commands-dir={compile_commands_dir}", + "--log=error", + ] + if not background_index: + clangd_args.append("--background-index=false") + + self._process = subprocess.Popen( + clangd_args, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + if self._process.stdin is None or self._process.stdout is None: + raise RuntimeError("failed to open clangd stdio pipes") + + self._stdin = self._process.stdin + self._stdout = self._process.stdout + self._reader = threading.Thread(target=self._reader_loop, daemon=True) + self._reader.start() + + def close(self) -> None: + try: + self.notify("exit", {}) + except Exception: + pass + try: + self._process.terminate() + self._process.wait(timeout=2) + except Exception: + self._process.kill() + + def _reader_loop(self) -> None: + while True: + try: + message = _read_lsp_message(self._stdout) + except Exception as exc: + message = {"error": {"message": f"failed to read clangd response: {exc}"}} + if message is None: + return + if "id" in message: + with self._condition: + self._responses[int(message["id"])] = message + self._condition.notify_all() + else: + self._messages.put(message) + + def request(self, method: str, params: dict[str, Any]) -> Any: + request_id = self._next_id + self._next_id += 1 + _write_lsp_message( + self._stdin, + { + "jsonrpc": "2.0", + "id": request_id, + "method": method, + "params": params, + }, + ) + deadline = time.monotonic() + self._timeout_seconds + with self._condition: + while request_id not in self._responses: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError(f"clangd request timed out: {method}") + self._condition.wait(remaining) + response = self._responses.pop(request_id) + + if "error" in response: + raise RuntimeError(response["error"].get("message", "clangd request failed")) + return response.get("result") + + def notify(self, method: str, params: dict[str, Any]) -> None: + _write_lsp_message( + self._stdin, + { + "jsonrpc": "2.0", + "method": method, + "params": params, + }, + ) + + +def _position_params(file_path: Path, line: int, column: int) -> dict[str, Any]: + if line < 1 or column < 1: + raise SystemExit("--line and --column are 1-based and must be positive") + return { + "textDocument": { "uri": file_path.as_uri() }, + "position": { "line": line - 1, "character": column - 1 }, + } + + +def _range_to_json(range_value: dict[str, Any]) -> dict[str, Any]: + start = range_value["start"] + end = range_value["end"] + return { + "start": { "line": start["line"] + 1, "column": start["character"] + 1 }, + "end": { "line": end["line"] + 1, "column": end["character"] + 1 }, + } + + +def _location_to_json(value: dict[str, Any]) -> dict[str, Any]: + if "targetUri" in value: + uri = value["targetUri"] + range_value = value.get("targetRange", value.get("targetSelectionRange")) + else: + uri = value["uri"] + range_value = value["range"] + return { + "uri": uri, + "path": _uri_to_path(uri), + "range": _range_to_json(range_value), + } + + +def _uri_to_path(uri: str) -> str: + if uri.startswith("file:///"): + raw = uri[8:] + if len(raw) >= 3 and raw[1] == ":": + return raw.replace("/", "\\") + return "/" + raw + return uri + + +def _locations_to_json(result: Any) -> list[dict[str, Any]]: + if result is None: + return [] + if isinstance(result, dict): + return [_location_to_json(result)] + return [_location_to_json(item) for item in result] + + +def _symbols_to_json(symbols: list[dict[str, Any]]) -> list[dict[str, Any]]: + def convert(symbol: dict[str, Any]) -> dict[str, Any]: + item = { + "name": symbol.get("name", ""), + "detail": symbol.get("detail", ""), + "kind": symbol.get("kind", 0), + "range": _range_to_json(symbol["range"]), + "selectionRange": _range_to_json(symbol.get("selectionRange", symbol["range"])), + } + children = symbol.get("children") + if children: + item["children"] = [convert(child) for child in children] + return item + + return [convert(symbol) for symbol in symbols or []] + + +def _flatten_symbols(symbols: list[dict[str, Any]], parent: str = "") -> list[dict[str, Any]]: + flattened: list[dict[str, Any]] = [] + for symbol in symbols: + qualified_name = f"{parent}::{symbol['name']}" if parent else symbol["name"] + item = { + "name": symbol["name"], + "qualifiedName": qualified_name, + "detail": symbol.get("detail", ""), + "kind": symbol.get("kind", 0), + "range": symbol["range"], + "selectionRange": symbol["selectionRange"], + } + flattened.append(item) + flattened.extend(_flatten_symbols(symbol.get("children", []), qualified_name)) + return flattened + + +def _limit_results(values: list[dict[str, Any]], max_results: int) -> tuple[list[dict[str, Any]], bool]: + if max_results < 1: + return values, False + return values[:max_results], len(values) > max_results + + +def _hover_to_json(result: Any) -> dict[str, Any] | None: + if not result: + return None + contents = result.get("contents") + if isinstance(contents, dict): + value = contents.get("value", "") + elif isinstance(contents, list): + value = "\n".join(str(item.get("value", item)) if isinstance(item, dict) else str(item) for item in contents) + else: + value = str(contents) + output = { "contents": value } + if "range" in result: + output["range"] = _range_to_json(result["range"]) + return output + + +def _open_document(client: ClangdClient, file_path: Path) -> None: + language_id = "cpp" + if file_path.suffix.lower() in { ".h", ".hpp", ".hh", ".hxx" }: + language_id = "cpp" + elif file_path.suffix.lower() == ".c": + language_id = "c" + + client.notify( + "textDocument/didOpen", + { + "textDocument": { + "uri": file_path.as_uri(), + "languageId": language_id, + "version": 1, + "text": file_path.read_text(encoding="utf-8", errors="replace"), + } + }, + ) + + +def run(args: argparse.Namespace) -> int: + repo_root = _repo_root() + compile_commands_dir = _find_compile_commands_dir(repo_root, args.compile_commands_dir) + file_path = _resolve_file(repo_root, args.file) + + client = ClangdClient(args.clangd, compile_commands_dir, args.timeout, args.background_index) + try: + client.request( + "initialize", + { + "processId": None, + "rootUri": repo_root.as_uri(), + "capabilities": { + "textDocument": { + "definition": { "linkSupport": True }, + "declaration": { "linkSupport": True }, + "implementation": { "linkSupport": True }, + "references": {}, + "hover": {}, + "documentSymbol": { "hierarchicalDocumentSymbolSupport": True }, + } + }, + }, + ) + client.notify("initialized", {}) + _open_document(client, file_path) + + command = args.command + result: Any + result_count: int | None = None + truncated = False + if command == "symbols": + symbols = _symbols_to_json( + client.request("textDocument/documentSymbol", { "textDocument": { "uri": file_path.as_uri() } }) + ) + if args.hierarchical: + result = symbols + result_count = len(symbols) + else: + flattened = _flatten_symbols(symbols) + if args.name: + needle = args.name.lower() + flattened = [ + symbol for symbol in flattened + if needle in symbol["qualifiedName"].lower() + ] + result_count = len(flattened) + result, truncated = _limit_results(flattened, args.max_results) + elif command == "hover": + result = _hover_to_json(client.request("textDocument/hover", _position_params(file_path, args.line, args.column))) + elif command == "references": + params = _position_params(file_path, args.line, args.column) + params["context"] = { "includeDeclaration": args.include_declaration } + locations = _locations_to_json(client.request("textDocument/references", params)) + result_count = len(locations) + result, truncated = _limit_results(locations, args.max_results) + else: + method = { + "definition": "textDocument/definition", + "declaration": "textDocument/declaration", + "implementation": "textDocument/implementation", + }[command] + locations = _locations_to_json(client.request(method, _position_params(file_path, args.line, args.column))) + result_count = len(locations) + result, truncated = _limit_results(locations, args.max_results) + + print(json.dumps( + { + "ok": True, + "command": command, + "file": str(file_path), + "compileCommandsDir": str(compile_commands_dir), + "resultCount": result_count, + "truncated": truncated, + "result": result, + }, + indent=2, + )) + return 0 + finally: + client.close() + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Navigate C++ symbols through clangd JSON-RPC.") + parser.add_argument( + "command", + choices=("definition", "declaration", "implementation", "references", "hover", "symbols"), + ) + parser.add_argument("--file", required=True, help="Source/header file to open.") + parser.add_argument("--line", type=int, default=1, help="1-based line for position commands.") + parser.add_argument("--column", type=int, default=1, help="1-based column for position commands.") + parser.add_argument( + "--compile-commands-dir", + help="Directory containing compile_commands.json. Defaults to PP_CLANGD_COMPILE_COMMANDS_DIR or known build dirs.", + ) + parser.add_argument("--clangd", default="clangd", help="clangd executable path.") + parser.add_argument("--timeout", type=float, default=20.0, help="Request timeout in seconds.") + parser.add_argument("--name", help="Case-insensitive symbol-name filter for symbols command.") + parser.add_argument("--max-results", type=int, default=100, help="Maximum locations/symbols to print; <=0 disables.") + parser.add_argument( + "--background-index", + action="store_true", + help="Allow clangd to build/use its background index for broader cross-translation-unit references.", + ) + parser.add_argument( + "--hierarchical", + action="store_true", + help="Print nested document symbols instead of the compact flat symbol list.", + ) + parser.add_argument( + "--include-declaration", + action=argparse.BooleanOptionalAction, + default=True, + help="Include declaration in references results.", + ) + return run(parser.parse_args(argv)) + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:]))