Files
panopainter/scripts/dev/clangd_nav.py

609 lines
22 KiB
Python

#!/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 symbols --file src/app_core/brush_ui.h --name-regex "execute_.*preset"
python scripts/dev/clangd_nav.py symbols --file src/app_core/document_export.h --detail-regex "Export.*Plan"
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 --path-regex "src[\\\\/]app_core"
python scripts/dev/clangd_nav.py self-test
"""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import queue
import re
import subprocess
import sys
import threading
import time
from typing import Any, Pattern
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 _compile_optional_regex(pattern: str | None, option_name: str, ignore_case: bool) -> Pattern[str] | None:
if not pattern:
return None
flags = re.IGNORECASE if ignore_case else 0
try:
return re.compile(pattern, flags)
except re.error as exc:
raise SystemExit(f"invalid {option_name}: {exc}") from exc
def _regex_matches(regex: Pattern[str] | None, value: str) -> bool:
return regex is None or regex.search(value) is not None
def _filter_flat_symbols(
symbols: list[dict[str, Any]],
name_substring: str | None,
name_regex: Pattern[str] | None,
detail_regex: Pattern[str] | None,
) -> list[dict[str, Any]]:
filtered = symbols
if name_substring:
needle = name_substring.lower()
filtered = [
symbol for symbol in filtered
if needle in symbol["qualifiedName"].lower()
]
if name_regex:
filtered = [
symbol for symbol in filtered
if name_regex.search(symbol["qualifiedName"])
]
if detail_regex:
filtered = [
symbol for symbol in filtered
if detail_regex.search(symbol.get("detail", ""))
]
return filtered
def _filter_locations(
locations: list[dict[str, Any]],
path_regex: Pattern[str] | None,
) -> list[dict[str, Any]]:
if not path_regex:
return locations
return [
location for location in locations
if _regex_matches(path_regex, location.get("path", ""))
or _regex_matches(path_regex, location.get("uri", ""))
]
def _run_self_test() -> int:
name_regex = _compile_optional_regex(r"node(panel|dialog)::open_.*", "--name-regex", True)
detail_regex = _compile_optional_regex(r"export.*plan", "--detail-regex", True)
path_regex = _compile_optional_regex(r"src[\\/]app(_dialogs)?\.cpp$", "--path-regex", True)
case_sensitive_regex = _compile_optional_regex(r"Brush", "--name-regex", False)
symbols = [
{
"qualifiedName": "NodePanel::open_project",
"detail": "void()",
},
{
"qualifiedName": "NodeDialog::open_export",
"detail": "ExportTargetPlan()",
},
{
"qualifiedName": "Brush::open_project",
"detail": "BrushPlan()",
},
]
name_matches = _filter_flat_symbols(symbols, None, name_regex, None)
detail_matches = _filter_flat_symbols(symbols, None, None, detail_regex)
case_sensitive_matches = _filter_flat_symbols(symbols, None, case_sensitive_regex, None)
locations = [
{
"path": r"D:\Dev\panopainter\src\app.cpp",
"uri": "file:///D:/Dev/panopainter/src/app.cpp",
},
{
"path": r"D:\Dev\panopainter\src\app_dialogs.cpp",
"uri": "file:///D:/Dev/panopainter/src/app_dialogs.cpp",
},
{
"path": r"D:\Dev\panopainter\docs\modernization\roadmap.md",
"uri": "file:///D:/Dev/panopainter/docs/modernization/roadmap.md",
},
]
path_matches = _filter_locations(locations, path_regex)
checks = {
"nameRegexMatchesAlternationAndWildcard": len(name_matches) == 2,
"detailRegexMatchesSymbolDetail": len(detail_matches) == 1
and detail_matches[0]["qualifiedName"] == "NodeDialog::open_export",
"caseSensitiveRegexHonorsNoIgnoreCase": len(case_sensitive_matches) == 1
and case_sensitive_matches[0]["qualifiedName"] == "Brush::open_project",
"pathRegexFiltersLocations": len(path_matches) == 2
and all("src" in location["path"] for location in path_matches),
}
ok = all(checks.values())
print(json.dumps(
{
"ok": ok,
"command": "self-test",
"checks": checks,
},
indent=2,
))
return 0 if ok else 1
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:
if args.command == "self-test":
return _run_self_test()
if not args.file:
raise SystemExit("--file is required for clangd navigation commands")
symbol_filters = [args.name, args.name_regex, args.detail_regex]
if any(symbol_filters) and args.command != "symbols":
raise SystemExit("--name, --name-regex, and --detail-regex are only supported by the symbols command")
if args.path_regex and args.command not in { "definition", "declaration", "implementation", "references" }:
raise SystemExit("--path-regex is only supported by location commands")
if args.hierarchical and any(symbol_filters):
raise SystemExit("--name, --name-regex, and --detail-regex require flat symbols; omit --hierarchical")
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)
name_regex = _compile_optional_regex(args.name_regex, "--name-regex", args.ignore_case)
detail_regex = _compile_optional_regex(args.detail_regex, "--detail-regex", args.ignore_case)
path_regex = _compile_optional_regex(args.path_regex, "--path-regex", args.ignore_case)
if args.command == "references" and not args.background_index and not args.allow_incomplete_references:
raise SystemExit(
"references may be incomplete without clangd background indexing. "
"Pass --background-index for a broader best-effort query or "
"--allow-incomplete-references for current-translation-unit lookup."
)
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)
flattened = _filter_flat_symbols(flattened, args.name, name_regex, detail_regex)
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))
locations = _filter_locations(locations, path_regex)
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)))
locations = _filter_locations(locations, path_regex)
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),
"backgroundIndex": args.background_index,
"referenceCompleteness": (
"not-applicable" if command != "references"
else ("best-effort-background-index" if args.background_index else "current-translation-unit-only")
),
"filters": {
"name": args.name,
"nameRegex": args.name_regex,
"detailRegex": args.detail_regex,
"pathRegex": args.path_regex,
"ignoreCase": args.ignore_case,
},
"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", "self-test"),
)
parser.add_argument("--file", 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("--name-regex", help="Regex filter for symbols command, matched against qualifiedName.")
parser.add_argument("--detail-regex", help="Regex filter for symbols command, matched against detail.")
parser.add_argument("--path-regex", help="Regex filter for definition/declaration/implementation/references paths.")
parser.add_argument(
"--ignore-case",
action=argparse.BooleanOptionalAction,
default=True,
help="Use case-insensitive --name-regex matching. Enabled by default.",
)
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(
"--allow-incomplete-references",
action="store_true",
help="Permit current-translation-unit-only references when --background-index is not enabled.",
)
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:]))