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
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

View File

@@ -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

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
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:

View File

@@ -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,

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