Newer
Older
navi-1 / navi / tools / _filesystem / read.py
"""`filesystem_read` — reading, listing, searching and comparing files."""

from __future__ import annotations

import asyncio
import difflib
import re
import stat
from pathlib import Path

from .._internal.base import ToolContext, ToolResult
from .base import FilesystemBaseTool
from .helpers import (
    _FIND_MAX_RESULTS,
    _GREP_MAX_RESULTS,
    _LIST_MAX_ENTRIES,
    _READ_PAGE_BYTES,
    _READ_PAGE_LINES,
    _READ_WARN_BYTES,
    _fmt_size,
    _fmt_time,
    _is_binary,
    _iter_search_files,
    _number_diff,
    _number_lines,
)

_READ_ACTIONS = (
    "read",
    "list",
    "find",
    "find_up",
    "grep",
    "info",
    "exists",
    "diff",
)
_WRITE_ACTIONS = (
    "write",
    "append",
    "edit",
    "edit_lines",
    "insert",
    "mkdir",
    "move",
    "copy",
    "delete",
)


class FilesystemReadTool(FilesystemBaseTool):
    name = "filesystem_read"
    description = (
        "Read and search files and directories. read returns line-numbered text and "
        "pages large files; use numbered=false to copy exact text into edit. Actions: "
        "read, list, find, find_up, grep, info, exists, diff. "
        "Details: tool_manual('filesystem_read')."
    )
    parameters = {
        "type": "object",
        "properties": {
            "action": {
                "type": "string",
                "enum": list(_READ_ACTIONS),
                "description": "Read-only operation to perform.",
            },
            "path": {
                "type": "string",
                "description": "File or directory path (~ is expanded).",
            },
            "offset": {
                "type": "integer",
                "description": "First line for read, 1-based.",
            },
            "limit": {
                "type": "integer",
                "description": "Max lines for read.",
            },
            "numbered": {
                "type": "boolean",
                "description": (
                    "Number lines in read output (default true). Use false to copy "
                    "exact text into edit."
                ),
            },
            "recursive": {
                "type": "boolean",
                "description": "Full recursive tree for list (default false).",
            },
            "pattern": {
                "type": "string",
                "description": "Glob for find (e.g. '*.py'), or search text for grep.",
            },
            "name": {
                "type": "string",
                "description": "Exact filename for find_up (e.g. 'pyproject.toml').",
            },
            "glob": {
                "type": "string",
                "description": "Optional filename filter for grep (e.g. '*.py').",
            },
            "regex": {
                "type": "boolean",
                "description": "Treat grep pattern as a regex (default false = literal).",
            },
            "case_sensitive": {
                "type": "boolean",
                "description": "Case-sensitive grep (default false).",
            },
            "context": {
                "type": "integer",
                "description": "Context lines around grep matches and diff hunks.",
            },
            "max_results": {
                "type": "integer",
                "description": "Max matches for grep/find (defaults are small).",
            },
            "include_ignored": {
                "type": "boolean",
                "description": (
                    "Also search .git, node_modules, .venv and similar directories "
                    "(default false)."
                ),
            },
            "other": {
                "type": "string",
                "description": "Second file for diff (compared with path).",
            },
        },
        "required": ["action", "path"],
    }

    async def execute(self, params: dict, ctx: ToolContext | None = None) -> ToolResult:
        action = params.get("action", "")
        raw_path = params.get("path", "")
        path = self._resolve_path(raw_path, ctx)
        if (err := self._path_result(raw_path, path)) is not None:
            return err
        assert path is not None

        try:
            match action:
                case "read":
                    return await asyncio.to_thread(self._read, path, params)
                case "list":
                    return await asyncio.to_thread(self._list, path, params)
                case "find":
                    return await asyncio.to_thread(self._find, path, params)
                case "find_up":
                    return await asyncio.to_thread(self._find_up, path, params)
                case "grep":
                    return await asyncio.to_thread(self._grep, path, params)
                case "info":
                    return await asyncio.to_thread(self._info, path)
                case "exists":
                    return ToolResult(
                        success=True, output="true" if path.exists() else "false"
                    )
                case "diff":
                    return await asyncio.to_thread(self._diff, path, params, ctx)
                case action if action in _WRITE_ACTIONS:
                    return self._redirect(action, "filesystem_write")
                case "query":
                    return ToolResult(
                        success=False,
                        output="'query' is in file_ai: call file_ai with action='ask'.",
                        error="wrong_tool",
                    )
                case "smart_edit":
                    return ToolResult(
                        success=False,
                        output="'smart_edit' is in file_ai: call file_ai with action='edit'.",
                        error="wrong_tool",
                    )
                case _:
                    return ToolResult(
                        success=False,
                        output=(
                            f"Unknown action: {action!r}. "
                            f"filesystem_read actions: {', '.join(_READ_ACTIONS)}."
                        ),
                        error="invalid_action",
                    )
        except PermissionError as e:
            return ToolResult(success=False, output=f"Permission denied: {e}", error=str(e))
        except Exception as e:
            return ToolResult(success=False, output=f"Filesystem error: {e}", error=str(e))

    # ── read ────────────────────────────────────────────────────────────────

    def _read(self, path: Path, params: dict) -> ToolResult:
        if not path.exists():
            return ToolResult(success=False, output=f"File not found: {path}", error="not_found")
        if path.is_dir():
            return ToolResult(
                success=False,
                output=f"Path is a directory. Use action='list' for {path}.",
                error="is_directory",
            )
        if _is_binary(path):
            return ToolResult(
                success=False,
                output=(
                    f"Binary file, not text: {path}. "
                    "Use action='info' for metadata or image_view for images."
                ),
                error="binary_file",
            )

        file_size = path.stat().st_size
        text = path.read_text(encoding="utf-8", errors="replace")
        lines = text.splitlines()
        total = len(lines)

        offset = params.get("offset")
        limit = params.get("limit")
        if offset is not None and (not isinstance(offset, int) or offset < 1):
            return ToolResult(
                success=False, output="'offset' must be an integer >= 1.", error="invalid_offset"
            )
        if limit is not None and (not isinstance(limit, int) or limit < 1):
            return ToolResult(
                success=False, output="'limit' must be an integer >= 1.", error="invalid_limit"
            )

        explicit = offset is not None or limit is not None
        start = max(0, (offset or 1) - 1)
        if total and start >= total:
            return ToolResult(
                success=False,
                output=(
                    f"offset={offset} is beyond the end of {path.name} "
                    f"({total} lines). Use offset between 1 and {total}."
                ),
                error="offset_out_of_range",
            )

        if limit is not None:
            end = min(total, start + limit)
        elif explicit:
            end = total
        elif total > _READ_PAGE_LINES or file_size > _READ_PAGE_BYTES:
            end = min(total, _READ_PAGE_LINES)
        else:
            end = total

        paged = end < total
        selected = lines[start:end]
        numbered = params.get("numbered", True)

        if total == 0:
            header = f"[{path}  |  empty file  |  {_fmt_size(file_size)}]\n"
            return ToolResult(success=True, output=header + "(empty file)")
        if paged or explicit:
            header = (
                f"[{path}  |  lines {start + 1}–{end} of {total}"
                f"  |  {_fmt_size(file_size)}]\n"
            )
        else:
            header = f"[{path}  |  {total} lines  |  {_fmt_size(file_size)}]\n"

        warn = (
            f"⚠ Large file ({_fmt_size(file_size)}); showing a page.\n"
            if file_size > _READ_WARN_BYTES and not explicit
            else ""
        )
        body = (
            _number_lines(selected, start + 1)
            if numbered
            else "\n".join(selected)
        )
        output = header + warn + body
        if paged and numbered:
            output += f"\n\n[more: call read with offset={end + 1}]"
        return ToolResult(success=True, output=output)

    # ── list / find / info ──────────────────────────────────────────────────

    def _list(self, path: Path, params: dict) -> ToolResult:
        if not path.exists():
            return ToolResult(success=False, output=f"Path not found: {path}", error="not_found")
        if path.is_file():
            return self._info(path)

        recursive = params.get("recursive", False)
        raw_entries = list(path.rglob("*") if recursive else path.iterdir())
        raw_entries.sort(key=lambda e: (e.is_file(), str(e).lower()))

        truncated = len(raw_entries) > _LIST_MAX_ENTRIES
        entries = raw_entries[:_LIST_MAX_ENTRIES]
        lines = []
        for entry in entries:
            try:
                info = entry.stat()
                rel = entry.relative_to(path)
                if entry.is_dir():
                    if not recursive:
                        try:
                            count = sum(1 for _ in entry.iterdir())
                            lines.append(f"d  {rel}/  ({count} items)")
                        except PermissionError:
                            lines.append(f"d  {rel}/")
                    else:
                        lines.append(f"d  {rel}/")
                else:
                    lines.append(
                        f"   {rel!s:<48} {_fmt_size(info.st_size):>10}  {_fmt_time(info.st_mtime)}"
                    )
            except Exception:
                lines.append(f"?  {entry.name}")

        note = "  ⚠ truncated" if truncated else ""
        header = f"[{path}  |  {len(entries)} entries{note}]\n"
        return ToolResult(success=True, output=header + ("\n".join(lines) or "(empty directory)"))

    def _find(self, path: Path, params: dict) -> ToolResult:
        pattern = params.get("pattern")
        if not pattern:
            return ToolResult(
                success=False,
                output="'pattern' is required for find, e.g. '*.py'.",
                error="missing_pattern",
            )
        if not path.exists():
            return ToolResult(success=False, output=f"Path not found: {path}", error="not_found")
        if not path.is_dir():
            return ToolResult(
                success=False,
                output="find needs a directory path; use read for a single file.",
                error="not_a_directory",
            )

        max_results = params.get("max_results", _FIND_MAX_RESULTS)
        if not isinstance(max_results, int) or max_results < 1:
            max_results = _FIND_MAX_RESULTS
        max_results = min(max_results, _FIND_MAX_RESULTS)

        matches: list[Path] = []
        try:
            for candidate in path.rglob(pattern):
                matches.append(candidate)
                if len(matches) >= max_results:
                    break
        except Exception as e:
            return ToolResult(success=False, output=f"Find error: {e}", error=str(e))

        if not matches:
            return ToolResult(
                success=True,
                output=f"No matches for '{pattern}' in {path}. Try a broader glob.",
            )

        matches.sort()
        lines = []
        for match in matches:
            try:
                size = _fmt_size(match.stat().st_size) if match.is_file() else "<dir>"
                lines.append(f"{match.relative_to(path)}  ({size})")
            except Exception:
                lines.append(str(match))

        extra = (
            f"  ⚠ showing first {max_results}"
            if len(matches) >= max_results
            else ""
        )
        header = f"[{len(matches)} matches for '{pattern}' in {path}{extra}]\n"
        return ToolResult(success=True, output=header + "\n".join(lines))

    def _find_up(self, path: Path, params: dict) -> ToolResult:
        filename = params.get("name")
        if not filename:
            return ToolResult(
                success=False,
                output="'name' is required for find_up, e.g. 'pyproject.toml'.",
                error="missing_name",
            )
        current = path if path.is_dir() else path.parent
        checked = []
        while True:
            target = current / filename
            checked.append(str(target))
            if target.exists():
                return ToolResult(success=True, output=str(target))
            parent = current.parent
            if parent == current:
                return ToolResult(
                    success=True, output=f"not found (searched: {', '.join(checked)})"
                )
            current = parent

    def _info(self, path: Path) -> ToolResult:
        if not path.exists():
            return ToolResult(success=False, output=f"Not found: {path}", error="not_found")

        info = path.stat()
        kind = "symlink" if path.is_symlink() else ("directory" if path.is_dir() else "file")
        lines = [
            f"path:     {path}",
            f"type:     {kind}",
            f"size:     {_fmt_size(info.st_size)}",
            f"modified: {_fmt_time(info.st_mtime)}",
            f"created:  {_fmt_time(info.st_ctime)}",
            f"mode:     {stat.filemode(info.st_mode)}",
        ]
        if path.is_file():
            if _is_binary(path):
                lines.append("lines:    (binary or unreadable)")
            else:
                try:
                    text = path.read_text(encoding="utf-8", errors="replace")
                    lines.append(f"lines:    {len(text.splitlines())}")
                except Exception:
                    lines.append("lines:    (binary or unreadable)")
        elif path.is_dir():
            try:
                children = list(path.iterdir())
                lines.append(
                    f"contents: {sum(c.is_file() for c in children)} files, "
                    f"{sum(c.is_dir() for c in children)} dirs (top level)"
                )
            except Exception:
                pass
        return ToolResult(success=True, output="\n".join(lines))

    # ── grep ────────────────────────────────────────────────────────────────

    def _grep(self, path: Path, params: dict) -> ToolResult:
        pattern = params.get("pattern", "").strip()
        if not pattern:
            return ToolResult(
                success=False, output="'pattern' is required for grep.", error="missing_pattern"
            )
        if not path.exists():
            return ToolResult(success=False, output=f"Not found: {path}", error="not_found")

        use_regex = params.get("regex", False)
        case_sensitive = params.get("case_sensitive", False)
        glob_filter = params.get("glob", "") or ""
        context = params.get("context", 0)
        if not isinstance(context, int) or context < 0:
            context = 0
        context = min(context, 5)
        max_results = params.get("max_results", _GREP_MAX_RESULTS)
        if not isinstance(max_results, int) or max_results < 1:
            max_results = _GREP_MAX_RESULTS
        max_results = min(max_results, 500)
        include_ignored = bool(params.get("include_ignored", False))

        flags = 0 if case_sensitive else re.IGNORECASE
        try:
            compiled = re.compile(pattern if use_regex else re.escape(pattern), flags)
        except re.error as e:
            return ToolResult(
                success=False, output=f"Invalid regex: {e}", error="invalid_pattern"
            )

        matches: list[str] = []
        files_searched = 0
        hit_count = 0
        capped = False

        for fpath in _iter_search_files(path, glob_filter, include_ignored):
            if hit_count >= max_results:
                capped = True
                break
            if _is_binary(fpath):
                continue
            files_searched += 1
            try:
                text = fpath.read_text(encoding="utf-8", errors="replace")
            except Exception:
                continue
            lines = text.splitlines()
            hits = [i for i, line in enumerate(lines) if compiled.search(line)]
            if not hits:
                continue
            rel = fpath.relative_to(path) if path.is_dir() else fpath.name
            hit_set = set(hits)
            wanted: set[int] = set()
            for index in hits:
                if hit_count >= max_results:
                    capped = True
                    break
                hit_count += 1
                for around in range(max(0, index - context), min(len(lines), index + context + 1)):
                    wanted.add(around)
            for index in sorted(wanted):
                if index in hit_set:
                    matches.append(f"{rel}:{index + 1}: {lines[index]}")
                else:
                    matches.append(f"{rel}-{index + 1}- {lines[index]}")
            if capped:
                break

        if not matches:
            return ToolResult(
                success=True,
                output=(
                    f"No matches for '{pattern}' in {path}. "
                    "Try a shorter pattern, regex=true, a different path, "
                    "or include_ignored=true."
                ),
            )

        note = f"  ⚠ showing first {max_results}" if capped else ""
        header = (
            f"[{hit_count} matches for '{pattern}' in {path} "
            f"({files_searched} files searched){note}]\n"
        )
        return ToolResult(success=True, output=header + "\n".join(matches))

    # ── diff ────────────────────────────────────────────────────────────────

    def _diff(self, path: Path, params: dict, ctx: ToolContext | None) -> ToolResult:
        other_raw = params.get("other") or params.get("destination")
        if not other_raw:
            return ToolResult(
                success=False,
                output="'other' is required for diff (the second file to compare).",
                error="missing_other",
            )
        other = self._resolve_path(other_raw, ctx)
        if other is None:
            return ToolResult(
                success=False,
                output=f"Access denied: '{other_raw}' is outside allowed paths.",
                error="access_denied",
            )
        if not path.exists():
            return ToolResult(success=False, output=f"Not found: {path}", error="not_found")
        if not other.exists():
            return ToolResult(success=False, output=f"Not found: {other}", error="not_found")
        if path.is_dir() or other.is_dir():
            return ToolResult(
                success=False, output="diff works on files, not directories.", error="is_directory"
            )

        context = params.get("context", 3)
        if not isinstance(context, int) or context < 0:
            context = 3
        context = min(context, 20)

        try:
            a_lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
            b_lines = other.read_text(encoding="utf-8", errors="replace").splitlines()
        except Exception as e:
            return ToolResult(success=False, output=f"Read error: {e}", error=str(e))

        diff = list(
            difflib.unified_diff(
                a_lines,
                b_lines,
                fromfile=f"a/{path.name}",
                tofile=f"b/{other.name}",
                n=context,
                lineterm="",
            )
        )
        if not diff:
            return ToolResult(success=True, output="Files are identical.")
        return ToolResult(success=True, output="\n".join(_number_diff(diff)))