180 lines
7.8 KiB
Python
Executable File
180 lines
7.8 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Search only source files relevant to the active incremental batch.
|
|
|
|
The command intentionally avoids recursive searches from the repository root.
|
|
By default it resolves source paths from ``module-batch-manifest.json`` and
|
|
searches those files (or a single cited directory) with ripgrep.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
BATCH = Path(os.environ.get("FREECAD_SOURCE_BATCH", str(ROOT / "docs/current/module-batch-manifest.json")))
|
|
RUNTIME_DIRS = (
|
|
ROOT / "freecad-source/src/WebAssembly/FreeCADCoreProbe",
|
|
ROOT / "web/src",
|
|
)
|
|
|
|
|
|
def ripgrep() -> str:
|
|
# The scheduler's rg wrapper rejects directories. This helper already
|
|
# validates workspace scope and bounds output, so use the real executable.
|
|
real = os.environ.get("FREECAD_REAL_RG")
|
|
if real and Path(real).is_file():
|
|
return real
|
|
wrapper = (ROOT / "tools/scheduler-bin").resolve()
|
|
search_path = os.pathsep.join(
|
|
entry for entry in os.get_exec_path() if Path(entry).resolve() != wrapper
|
|
)
|
|
executable = shutil.which("rg", path=search_path)
|
|
if not executable:
|
|
raise SystemExit("FAST_SOURCE_SEARCH: ripgrep executable unavailable")
|
|
return executable
|
|
|
|
|
|
def batch_paths() -> list[Path]:
|
|
try:
|
|
payload = json.loads(BATCH.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
return []
|
|
if not isinstance(payload, dict):
|
|
return []
|
|
paths: set[Path] = set()
|
|
sources = [str(row[3]) for row in payload.get("records", []) if isinstance(row, list) and len(row) >= 4 and row[3]]
|
|
sources.extend(source for source in payload.get("sourcePaths", []) if isinstance(source, str))
|
|
for source in sources:
|
|
source = source.replace("\\", "/")
|
|
for base in (ROOT, ROOT / "freecad-source"):
|
|
candidate = (base / source).resolve()
|
|
try:
|
|
candidate.relative_to(ROOT.resolve())
|
|
except ValueError:
|
|
continue
|
|
if candidate.is_file():
|
|
paths.add(candidate)
|
|
break
|
|
if candidate.is_dir():
|
|
paths.update(list_source_files(candidate))
|
|
break
|
|
return sorted(paths)
|
|
|
|
|
|
def list_source_files(directory: Path, limit: int = 5000) -> list[Path]:
|
|
try:
|
|
result = subprocess.run(
|
|
[ripgrep(), "--files", "--sort", "path", str(directory)],
|
|
cwd=ROOT,
|
|
text=True,
|
|
capture_output=True,
|
|
check=False,
|
|
)
|
|
except OSError as error:
|
|
raise SystemExit(f"FAST_SOURCE_SEARCH: file enumeration failed: {error}")
|
|
if result.returncode not in (0, 1):
|
|
raise SystemExit(f"FAST_SOURCE_SEARCH: file enumeration failed: {result.stderr.strip()}")
|
|
files = []
|
|
for line in result.stdout.splitlines():
|
|
path = (ROOT / line).resolve()
|
|
if not path.is_relative_to(ROOT.resolve()):
|
|
continue
|
|
if path.is_file() and path.suffix in {".cpp", ".cxx", ".h", ".hpp", ".py", ".cmake", ".ts", ".tsx", ".mjs"}:
|
|
files.append(path)
|
|
if len(files) > limit:
|
|
raise SystemExit(f"FAST_SOURCE_SEARCH: more than {limit} source files in {directory}; cite a narrower --path")
|
|
return files
|
|
|
|
|
|
def resolve_explicit(value: str) -> list[Path]:
|
|
candidate = (ROOT / value).resolve() if not Path(value).is_absolute() else Path(value).resolve()
|
|
try:
|
|
candidate.relative_to(ROOT.resolve())
|
|
except ValueError:
|
|
raise SystemExit(f"path outside workspace: {value}")
|
|
if candidate.is_file():
|
|
return [candidate]
|
|
if candidate.is_dir():
|
|
return list_source_files(candidate)
|
|
return []
|
|
|
|
|
|
def main() -> int:
|
|
marker = os.environ.get("FREECAD_SOURCE_BUDGET_MARKER", "")
|
|
if marker and Path(marker).is_file():
|
|
print(
|
|
"SCHEDULER_SOURCE_BUDGET_REACHED: search was not executed. Reuse excerpts or use "
|
|
"python3 tools/read_implementation_source.py FILE --start LINE --lines 60 --reason 'missing anchor' "
|
|
"for an exact current-packet file.",
|
|
file=sys.stderr,
|
|
)
|
|
return 64
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("pattern", nargs="?", help="ripgrep pattern or literal symbol")
|
|
parser.add_argument("--pattern", dest="pattern_option", help="named form of the search pattern")
|
|
parser.add_argument("extra_paths", nargs="*", help="optional explicit workspace-relative paths (compatibility form; --path is preferred)")
|
|
parser.add_argument("--path", action="append", default=[], help="explicit workspace-relative file or directory; repeatable")
|
|
parser.add_argument("--paths", nargs="+", default=[], help="compatibility alias accepting one or more explicit paths")
|
|
parser.add_argument("--glob", action="append", default=[], help="forward a ripgrep glob while retaining workspace scoping")
|
|
parser.add_argument("-n", action="store_true", help=argparse.SUPPRESS)
|
|
parser.add_argument("--runtime", action="store_true", help="also search the small runtime adapter directories")
|
|
parser.add_argument("--fixed-strings", action="store_true", help="treat pattern as a literal string")
|
|
parser.add_argument("--context", type=int, default=2, help="lines of context around each match")
|
|
parser.add_argument("--max-count", "--max-results", dest="max_count", type=int, default=80, help="global maximum matching lines")
|
|
parse = parser.parse_intermixed_args if hasattr(parser, "parse_intermixed_args") else parser.parse_args
|
|
args = parse()
|
|
if args.pattern_option is not None:
|
|
if args.pattern is not None:
|
|
args.extra_paths.insert(0, args.pattern)
|
|
args.pattern = args.pattern_option
|
|
if args.pattern is None:
|
|
parser.error("a positional pattern or --pattern is required")
|
|
|
|
explicit_paths = [*args.path, *args.paths, *args.extra_paths]
|
|
# Explicit scope must not be crowded out by unrelated batch matches.
|
|
paths: set[Path] = set() if explicit_paths else set(batch_paths())
|
|
for value in explicit_paths:
|
|
paths.update(resolve_explicit(value))
|
|
if args.runtime:
|
|
for directory in RUNTIME_DIRS:
|
|
paths.update(list_source_files(directory))
|
|
paths = {path for path in paths if path.is_file()}
|
|
if not paths:
|
|
print("FAST_SOURCE_SEARCH: no scoped source files; cite --path or prepare a batch manifest", file=sys.stderr)
|
|
return 2
|
|
|
|
command = [ripgrep(), "-n", "-H", "-S", "--no-heading", "--color", "never", "--max-count", str(max(1, args.max_count))]
|
|
if args.fixed_strings:
|
|
command.append("--fixed-strings")
|
|
if args.context > 0:
|
|
command.extend(["-C", str(min(args.context, 12))])
|
|
for pattern in args.glob:
|
|
command.extend(["--glob", pattern])
|
|
command.extend([args.pattern, "--", *map(str, sorted(paths))])
|
|
result = subprocess.run(command, cwd=ROOT, text=True, capture_output=True, check=False)
|
|
if result.returncode == 1:
|
|
# ripgrep uses 1 for a valid search with no matches. Treat that as a
|
|
# successful exploratory step so an `rg ... && edit` chain does not
|
|
# become a scheduler failure merely because the symbol is absent.
|
|
print(f"FAST_SOURCE_SEARCH: files={len(paths)} pattern={args.pattern!r} matches=0", file=sys.stderr)
|
|
return 0
|
|
output_lines = result.stdout.splitlines()
|
|
global_limit = max(1, args.max_count)
|
|
for line in output_lines[:global_limit]:
|
|
print(line)
|
|
if result.stderr:
|
|
print(result.stderr, end="", file=sys.stderr)
|
|
truncated = max(0, len(output_lines) - global_limit)
|
|
print(f"FAST_SOURCE_SEARCH: files={len(paths)} pattern={args.pattern!r} lines={min(len(output_lines), global_limit)} truncated={truncated}", file=sys.stderr)
|
|
return result.returncode
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|