#!/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())