Add regex filters to clangd navigation

This commit is contained in:
2026-06-04 17:12:14 +02:00
parent 104358bc62
commit be4b88dec8
5 changed files with 182 additions and 28 deletions

View File

@@ -69,16 +69,23 @@ flat source tree and extracted components:
```powershell ```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 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/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 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 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`; 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 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. another Ninja build tree. Use `--name` and `--max-results` to keep output small.
Use `--name-regex` for regex filtering against `qualifiedName`; matching is Use `--name-regex` for regex filtering against `qualifiedName`,
case-insensitive by default, and `--no-ignore-case` makes it case-sensitive. `--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 Treat symbol, hover, declaration, definition, and implementation lookups as the
reliable path. Reference lookups are riskier because a one-shot clangd process reliable path. Reference lookups are riskier because a one-shot clangd process
may not have a complete project index; the helper refuses reference queries may not have a complete project index; the helper refuses reference queries

View File

@@ -77,7 +77,10 @@ powershell -ExecutionPolicy Bypass -File scripts\automation\package-smoke.ps1 -P
cmake --fresh --preset windows-clangcl-asan 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 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/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 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: Known local toolchain state:
@@ -97,9 +100,15 @@ Known local toolchain state:
the current `compile_commands.json` from `windows-clangcl-asan`, the current `compile_commands.json` from `windows-clangcl-asan`,
`android-arm64`, or a caller-provided build directory. Symbol, hover, `android-arm64`, or a caller-provided build directory. Symbol, hover,
declaration, definition, and implementation lookups are the reliable use case. declaration, definition, and implementation lookups are the reliable use case.
Symbol listing supports substring filtering with `--name` and regex filtering Symbol listing supports substring filtering with `--name`, regex filtering
against `qualifiedName` with `--name-regex`; regex matching is case-insensitive against `qualifiedName` with `--name-regex`, and symbol detail/signature
by default and can be made case-sensitive with `--no-ignore-case`. 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 Reference lookup is guarded because a one-shot clangd process may not have a
complete project index: pass `--background-index` for broader best-effort complete project index: pass `--background-index` for broader best-effort
results or `--allow-incomplete-references` for explicitly results or `--allow-incomplete-references` for explicitly

View File

@@ -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 dirty-state and save decisions, creating a `pp_document` model, metadata-only
PPI project loading, and inspecting image signatures, PPI headers, and layout 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 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: Implementation tasks:

View File

@@ -4,8 +4,10 @@
Examples: 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
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/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 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 from __future__ import annotations
@@ -20,7 +22,7 @@ import subprocess
import sys import sys
import threading import threading
import time import time
from typing import Any from typing import Any, Pattern
DEFAULT_BUILD_DIRS = ( DEFAULT_BUILD_DIRS = (
@@ -301,6 +303,120 @@ def _hover_to_json(result: Any) -> dict[str, Any] | None:
return output 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: def _open_document(client: ClangdClient, file_path: Path) -> None:
language_id = "cpp" language_id = "cpp"
if file_path.suffix.lower() in { ".h", ".hpp", ".hh", ".hxx" }: 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: 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() repo_root = _repo_root()
compile_commands_dir = _find_compile_commands_dir(repo_root, args.compile_commands_dir) compile_commands_dir = _find_compile_commands_dir(repo_root, args.compile_commands_dir)
file_path = _resolve_file(repo_root, args.file) file_path = _resolve_file(repo_root, args.file)
name_regex = None name_regex = _compile_optional_regex(args.name_regex, "--name-regex", args.ignore_case)
if args.name_regex: detail_regex = _compile_optional_regex(args.detail_regex, "--detail-regex", args.ignore_case)
try: path_regex = _compile_optional_regex(args.path_regex, "--path-regex", args.ignore_case)
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
if args.command == "references" and not args.background_index and not args.allow_incomplete_references: if args.command == "references" and not args.background_index and not args.allow_incomplete_references:
raise SystemExit( raise SystemExit(
@@ -375,17 +502,7 @@ def run(args: argparse.Namespace) -> int:
result_count = len(symbols) result_count = len(symbols)
else: else:
flattened = _flatten_symbols(symbols) flattened = _flatten_symbols(symbols)
if args.name: flattened = _filter_flat_symbols(flattened, args.name, name_regex, detail_regex)
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"])
]
result_count = len(flattened) result_count = len(flattened)
result, truncated = _limit_results(flattened, args.max_results) result, truncated = _limit_results(flattened, args.max_results)
elif command == "hover": elif command == "hover":
@@ -394,6 +511,7 @@ def run(args: argparse.Namespace) -> int:
params = _position_params(file_path, args.line, args.column) params = _position_params(file_path, args.line, args.column)
params["context"] = { "includeDeclaration": args.include_declaration } params["context"] = { "includeDeclaration": args.include_declaration }
locations = _locations_to_json(client.request("textDocument/references", params)) locations = _locations_to_json(client.request("textDocument/references", params))
locations = _filter_locations(locations, path_regex)
result_count = len(locations) result_count = len(locations)
result, truncated = _limit_results(locations, args.max_results) result, truncated = _limit_results(locations, args.max_results)
else: else:
@@ -403,6 +521,7 @@ def run(args: argparse.Namespace) -> int:
"implementation": "textDocument/implementation", "implementation": "textDocument/implementation",
}[command] }[command]
locations = _locations_to_json(client.request(method, _position_params(file_path, args.line, args.column))) 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_count = len(locations)
result, truncated = _limit_results(locations, args.max_results) result, truncated = _limit_results(locations, args.max_results)
@@ -417,6 +536,13 @@ def run(args: argparse.Namespace) -> int:
"not-applicable" if command != "references" "not-applicable" if command != "references"
else ("best-effort-background-index" if args.background_index else "current-translation-unit-only") 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, "resultCount": result_count,
"truncated": truncated, "truncated": truncated,
"result": result, "result": result,
@@ -432,9 +558,9 @@ def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description="Navigate C++ symbols through clangd JSON-RPC.") parser = argparse.ArgumentParser(description="Navigate C++ symbols through clangd JSON-RPC.")
parser.add_argument( parser.add_argument(
"command", "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("--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("--column", type=int, default=1, help="1-based column for position commands.")
parser.add_argument( 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("--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", 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("--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( parser.add_argument(
"--ignore-case", "--ignore-case",
action=argparse.BooleanOptionalAction, action=argparse.BooleanOptionalAction,

View File

@@ -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) add_library(pp_test_harness INTERFACE)
target_include_directories(pp_test_harness INTERFACE target_include_directories(pp_test_harness INTERFACE
"${CMAKE_CURRENT_SOURCE_DIR}") "${CMAKE_CURRENT_SOURCE_DIR}")