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

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