609 lines
22 KiB
Python
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:]))
|