From be4b88dec83d8aa987cb6de65909abf62f900a2f Mon Sep 17 00:00:00 2001 From: omigamedev Date: Thu, 4 Jun 2026 17:12:14 +0200 Subject: [PATCH] Add regex filters to clangd navigation --- AGENTS.md | 13 +- docs/modernization/build-inventory.md | 15 ++- docs/modernization/roadmap.md | 5 +- scripts/dev/clangd_nav.py | 170 ++++++++++++++++++++++---- tests/CMakeLists.txt | 7 ++ 5 files changed, 182 insertions(+), 28 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fa61029..b4a3497 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,16 +69,23 @@ 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 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 511 --column 39 -python scripts/dev/clangd_nav.py references --file src/app_core/brush_ui.h --line 783 --column 45 +python scripts/dev/clangd_nav.py references --file src/app_core/brush_ui.h --line 783 --column 45 --path-regex "src[\\/]app_core" +python scripts/dev/clangd_nav.py self-test ``` 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. -Use `--name-regex` for regex filtering against `qualifiedName`; matching is -case-insensitive by default, and `--no-ignore-case` makes it case-sensitive. +Use `--name-regex` for regex filtering against `qualifiedName`, +`--detail-regex` for symbol detail/signature filtering, and `--path-regex` for +definition/declaration/implementation/reference location filtering. Regex +matching is case-insensitive by default, and `--no-ignore-case` makes it +case-sensitive. Run `python scripts/dev/clangd_nav.py self-test` or the +`panopainter_clangd_nav_regex_self_test` CTest before relying on regex behavior +after tool changes. Treat symbol, hover, declaration, definition, and implementation lookups as the reliable path. Reference lookups are riskier because a one-shot clangd process may not have a complete project index; the helper refuses reference queries diff --git a/docs/modernization/build-inventory.md b/docs/modernization/build-inventory.md index de806ce..5b472cc 100644 --- a/docs/modernization/build-inventory.md +++ b/docs/modernization/build-inventory.md @@ -77,7 +77,10 @@ powershell -ExecutionPolicy Bypass -File scripts\automation\package-smoke.ps1 -P 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 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 511 --column 39 +python scripts/dev/clangd_nav.py references --file src/app_core/brush_ui.h --line 783 --column 45 --path-regex "src[\\/]app_core" +python scripts/dev/clangd_nav.py self-test ``` Known local toolchain state: @@ -97,9 +100,15 @@ Known local toolchain state: the current `compile_commands.json` from `windows-clangcl-asan`, `android-arm64`, or a caller-provided build directory. Symbol, hover, declaration, definition, and implementation lookups are the reliable use case. - Symbol listing supports substring filtering with `--name` and regex filtering - against `qualifiedName` with `--name-regex`; regex matching is case-insensitive - by default and can be made case-sensitive with `--no-ignore-case`. + Symbol listing supports substring filtering with `--name`, regex filtering + against `qualifiedName` with `--name-regex`, and symbol detail/signature + regex filtering with `--detail-regex`. Definition, declaration, + implementation, and reference location output supports `--path-regex` for + path/URI filtering. Regex matching is case-insensitive by default and can be + made case-sensitive with `--no-ignore-case`. The helper exposes + `python scripts/dev/clangd_nav.py self-test`, also registered as + `panopainter_clangd_nav_regex_self_test`, so regex filter behavior can be + validated without clangd or a compile database. Reference lookup is guarded because a one-shot clangd process may not have a complete project index: pass `--background-index` for broader best-effort results or `--allow-incomplete-references` for explicitly diff --git a/docs/modernization/roadmap.md b/docs/modernization/roadmap.md index 4c9c970..ecafed4 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -317,7 +317,10 @@ with JSON automation commands for app document-open routing, app session dirty-state and save decisions, creating a `pp_document` model, metadata-only PPI project loading, and inspecting image signatures, PPI headers, and layout XML; full document/app integration is debt-tracked as DEBT-0010 and full PPI -body parsing is debt-tracked as DEBT-0013. +body parsing is debt-tracked as DEBT-0013. Agent code navigation now includes +`scripts/dev/clangd_nav.py` with symbol/detail/path regex filters and a +`panopainter_clangd_nav_regex_self_test` CTest so broad symbol-family searches +can be validated before they guide refactors. Implementation tasks: diff --git a/scripts/dev/clangd_nav.py b/scripts/dev/clangd_nav.py index a981267..5e7f021 100644 --- a/scripts/dev/clangd_nav.py +++ b/scripts/dev/clangd_nav.py @@ -4,8 +4,10 @@ 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 + 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 @@ -20,7 +22,7 @@ import subprocess import sys import threading import time -from typing import Any +from typing import Any, Pattern DEFAULT_BUILD_DIRS = ( @@ -301,6 +303,120 @@ def _hover_to_json(result: Any) -> dict[str, Any] | None: 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" }: @@ -322,16 +438,27 @@ def _open_document(client: ClangdClient, file_path: Path) -> None: 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 = None - if args.name_regex: - try: - name_regex = re.compile(args.name_regex, re.IGNORECASE if args.ignore_case else 0) - except re.error as exc: - raise SystemExit(f"invalid --name-regex: {exc}") from exc + 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( @@ -375,17 +502,7 @@ def run(args: argparse.Namespace) -> int: 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() - ] - if name_regex: - flattened = [ - symbol for symbol in flattened - if name_regex.search(symbol["qualifiedName"]) - ] + 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": @@ -394,6 +511,7 @@ def run(args: argparse.Namespace) -> int: 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: @@ -403,6 +521,7 @@ def run(args: argparse.Namespace) -> int: "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) @@ -417,6 +536,13 @@ def run(args: argparse.Namespace) -> int: "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, @@ -432,9 +558,9 @@ 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"), + choices=("definition", "declaration", "implementation", "references", "hover", "symbols", "self-test"), ) - parser.add_argument("--file", required=True, help="Source/header file to open.") + 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( @@ -445,6 +571,8 @@ def main(argv: list[str]) -> int: 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, diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 0227e5c..ffc2142 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,3 +1,10 @@ +find_package(Python3 COMPONENTS Interpreter REQUIRED) + +add_test(NAME panopainter_clangd_nav_regex_self_test + COMMAND "${Python3_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/scripts/dev/clangd_nav.py" self-test) +set_tests_properties(panopainter_clangd_nav_regex_self_test PROPERTIES + LABELS "tooling;desktop-fast") + add_library(pp_test_harness INTERFACE) target_include_directories(pp_test_harness INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}")