From 007efdef5a664744b9f67aa096c7a27b4cedd686 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Wed, 15 Jul 2026 10:15:13 +0800 Subject: [PATCH] core/tools/file: add glob_paths workspace search tool Stdlib Path.glob with VCS/hidden skip and match cap; paths relative to workspace root. --- src/plyngent/tools/file/glob_paths.py | 101 ++++++++++++++++++++++++++ tests/test_tools/test_glob_paths.py | 42 +++++++++++ 2 files changed, 143 insertions(+) create mode 100644 src/plyngent/tools/file/glob_paths.py create mode 100644 tests/test_tools/test_glob_paths.py diff --git a/src/plyngent/tools/file/glob_paths.py b/src/plyngent/tools/file/glob_paths.py new file mode 100644 index 0000000..7f37964 --- /dev/null +++ b/src/plyngent/tools/file/glob_paths.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from plyngent.agent import tool +from plyngent.tools.file.tree import VCS_DIR_NAMES +from plyngent.tools.workspace import WorkspaceError, get_workspace_root, resolve_path + +if TYPE_CHECKING: + from pathlib import Path + +DEFAULT_MAX_MATCHES = 200 + + +def _rel(path: Path, root: Path) -> str: + try: + return str(path.relative_to(root)) + except ValueError: + return str(path) + + +def _hidden_or_vcs(parts: tuple[str, ...]) -> bool: + return any(part in VCS_DIR_NAMES or part.startswith(".") for part in parts) + + +def _collect_glob( + base: Path, + root: Path, + pattern: str, + *, + max_matches: int, + skip_hidden_dirs: bool, +) -> tuple[list[str], bool] | str: + matches: list[str] = [] + try: + candidates = sorted(base.glob(pattern), key=lambda p: str(p).casefold()) + except (OSError, ValueError) as exc: + return f"error: glob failed: {exc}" + + for candidate in candidates: + try: + resolved = candidate.resolve() + rel = resolved.relative_to(root) + except (OSError, ValueError): + continue + # Skip anything under (or itself) VCS / hidden path components. + if skip_hidden_dirs and _hidden_or_vcs(rel.parts): + continue + if not resolved.is_file() and not resolved.is_dir(): + continue + matches.append(_rel(resolved, root)) + if len(matches) >= max_matches: + return matches, True + return matches, False + + +def _resolve_glob_base(path: str) -> tuple[Path, Path] | str: + try: + root = get_workspace_root() + base = resolve_path(path) + except WorkspaceError as exc: + return f"error: {exc}" + if not base.is_dir(): + return f"error: not a directory: {path}" + return root, base + + +@tool +def glob_paths( + pattern: str, + path: str = ".", + *, + max_matches: int = DEFAULT_MAX_MATCHES, + skip_hidden_dirs: bool = True, +) -> str: + """Find files under the workspace matching a glob pattern (e.g. ``**/*.py``). + + Search is relative to ``path`` (default workspace root). Skips VCS dirs + (``.git``, …) and hidden directories by default. Returns paths relative to + the workspace root, one per line. + """ + if not pattern.strip() or max_matches < 1: + return "error: pattern must not be empty and max_matches must be >= 1" + + resolved = _resolve_glob_base(path) + if isinstance(resolved, str): + return resolved + root, base = resolved + + result = _collect_glob( + base, root, pattern, max_matches=max_matches, skip_hidden_dirs=skip_hidden_dirs + ) + if isinstance(result, str): + return result + matches, truncated = result + if not matches: + return "(no matches)" + body = "\n".join(matches) + if truncated: + body += f"\n...[truncated at {max_matches} matches]" + return body diff --git a/tests/test_tools/test_glob_paths.py b/tests/test_tools/test_glob_paths.py new file mode 100644 index 0000000..40b8630 --- /dev/null +++ b/tests/test_tools/test_glob_paths.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from pathlib import Path + +from plyngent.tools.file.glob_paths import glob_paths +from tests.test_tools.helpers import call_sync + + +def test_glob_paths_basic(workspace: object) -> None: + assert isinstance(workspace, Path) + _ = (workspace / "a.py").write_text("x", encoding="utf-8") + (workspace / "sub").mkdir() + _ = (workspace / "sub" / "b.py").write_text("y", encoding="utf-8") + _ = (workspace / "c.txt").write_text("z", encoding="utf-8") + out = call_sync(glob_paths, "**/*.py") + assert "a.py" in out + assert "sub/b.py" in out + assert "c.txt" not in out + + +def test_glob_paths_skip_git(workspace: object) -> None: + assert isinstance(workspace, Path) + (workspace / ".git").mkdir() + _ = (workspace / ".git" / "config").write_text("x", encoding="utf-8") + _ = (workspace / "ok.py").write_text("x", encoding="utf-8") + out = call_sync(glob_paths, "**/*") + assert "ok.py" in out + assert ".git" not in out + + +def test_glob_paths_max_matches(workspace: object) -> None: + assert isinstance(workspace, Path) + for i in range(5): + _ = (workspace / f"f{i}.txt").write_text("x", encoding="utf-8") + out = call_sync(glob_paths, "*.txt", max_matches=2) + assert "truncated" in out + assert out.count("\n") >= 1 + + +def test_glob_paths_empty_pattern(workspace: object) -> None: + del workspace + assert "error" in call_sync(glob_paths, "")