packages feed

llm-simple-0.4.2.0: src/LLM/Tools/FindFiles.hs

module LLM.Tools.FindFiles
  ( findFilesToolTyped,
    FindFilesToolArgs (..),
  )
where

import Autodocodec qualified as AC
import Control.Exception (IOException, try)
import Data.Aeson (FromJSON)
import Data.List (sort)
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Data.Text qualified as T
import GHC.Generics (Generic)
import LLM.Core.Types (TypedTool (..))
import LLM.Tools.FsConfig (FsConfig, isFileHidden, isSymlink, sandboxPath, withWorkspacePaths)
import LLM.Tools.FsLimits (cheapWalkDepth, maxOutputBytes, maxVisitedEntries)
import LLM.Tools.Internal.WalkBudget (WalkBudget, budgetExhausted, budgetNote, capLines, newWalkBudget, takeEntries)
import System.Directory (doesDirectoryExist, listDirectory)
import System.FilePath ((</>))

-- | Default cap on returned paths.
defaultMaxResults :: Int
defaultMaxResults = 200

-- | Hard cap to protect the model's context window.
maxResultsCap :: Int
maxResultsCap = 2000

-- | Maximum directory depth from the search root. Sourced from
-- 'LLM.Tools.FsLimits.cheapWalkDepth' because @find_files@ only stats
-- entries, so it can afford a deeper walk than @grep@.
maxDepth :: Int
maxDepth = cheapWalkDepth

-- | What kind of filesystem entries to return.
data EntryFilter = AnyEntry | FileOnly | DirectoryOnly
  deriving (Eq, Show)

-- | 'Nothing' for an unknown kind.
parseEntryFilter :: Text -> Maybe EntryFilter
parseEntryFilter t = case T.toLower (T.strip t) of
  "file" -> Just FileOnly
  "directory" -> Just DirectoryOnly
  "dir" -> Just DirectoryOnly
  "any" -> Just AnyEntry
  "" -> Just AnyEntry
  _ -> Nothing

data FindFilesToolArgs = FindFilesToolArgs
  { _ffPattern :: Text,
    _ffPath :: Text,
    _ffType :: Text,
    _ffIncludeHidden :: Bool,
    _ffMaxResults :: Int
  }
  deriving (Generic)
  deriving (FromJSON) via (AC.Autodocodec FindFilesToolArgs)

instance AC.HasCodec FindFilesToolArgs where
  codec :: AC.JSONCodec FindFilesToolArgs
  codec =
    AC.object "find files by glob pattern" $
      FindFilesToolArgs
        <$> AC.requiredField
          "pattern"
          "Glob pattern matched against paths relative to 'path'. Supports '**' (any number of path segments, including zero), '*' (any chars except '/'), and '?' (any single char except '/'). Examples: '**/*.hs', 'src/**/Tools/*.hs', '*.cabal'."
          AC..= (._ffPattern)
        <*> AC.optionalFieldWithDefault
          "path"
          "."
          "Relative directory to search under (default '.')"
          AC..= (._ffPath)
        <*> AC.optionalFieldWithDefault
          "type"
          "any"
          "Entry kind to return: 'file', 'directory', or 'any' (default 'any')"
          AC..= (._ffType)
        <*> AC.optionalFieldWithDefault
          "includeHidden"
          False
          "Include dotfiles and dot-directories (default false)"
          AC..= (._ffIncludeHidden)
        <*> AC.optionalFieldWithDefault
          "maxResults"
          defaultMaxResults
          "Maximum number of paths to return (default 200, hard cap 2000)"
          AC..= (._ffMaxResults)

findFilesToolTyped :: FsConfig -> TypedTool ctx FindFilesToolArgs
findFilesToolTyped cfg =
  TypedTool
    { ttoolName = "find_files",
      ttoolDescription =
        "Find files and directories by glob pattern under a workspace-relative root. "
          <> "Supports '**' (any number of path segments, including zero), '*' (any chars except '/'), and '?'. "
          <> "Returns one path per line, relative to the search root, sorted. "
          <> "Skips hidden entries unless 'includeHidden' is true. "
          <> "Use 'type' to restrict to files or directories. "
          <> "Output is capped at 'maxResults' (default 200, hard cap 2000); "
          <> "the header reports whether results were truncated and how many entries were scanned. "
          <> "A search looks at most "
          <> T.pack (show maxVisitedEntries)
          <> " entries and says so if it stopped there.",
      ttoolReadonly = True,
      ttoolExecute = const (withWorkspacePaths cfg . execute cfg)
    }

execute :: FsConfig -> FindFilesToolArgs -> IO Text
execute cfg args = do
  let rel = T.unpack args._ffPath
      pat = args._ffPattern
      includeHidden = args._ffIncludeHidden
      cap = min maxResultsCap args._ffMaxResults
  case parseEntryFilter args._ffType of
    _ | T.null pat -> pure "Error: pattern must be non-empty"
    Nothing ->
      pure $ "Error: unknown type " <> T.pack (show args._ffType) <> "; use 'file', 'directory' or 'any'"
    _ | args._ffMaxResults <= 0 ->
      pure $ "Error: maxResults must be positive, got " <> T.pack (show args._ffMaxResults)
    Just kind -> do
      resolved <- sandboxPath cfg rel
      isDir <- doesDirectoryExist resolved
      if not isDir
        then pure $ "Error: path is not a directory: " <> args._ffPath
        else do
          let segs = splitGlob pat
          budget <- newWalkBudget maxVisitedEntries
          (matches, scanned, truncated) <- walk budget resolved includeHidden kind segs cap
          stopped <- budgetExhausted budget
          let note = if stopped then Just (budgetNote budget) else Nothing
          pure $ renderResults args._ffPath pat matches scanned truncated note

-- ---------------------------------------------------------------------------
-- Filesystem walk
-- ---------------------------------------------------------------------------

-- | Result of the walk: matched relative paths (sorted within each directory),
-- count of entries inspected, and whether the cap truncated the output.
walk ::
  WalkBudget ->
  FilePath ->
  Bool ->
  EntryFilter ->
  [Text] ->
  Int ->
  IO ([FilePath], Int, Bool)
walk budget root includeHidden kind pat cap = do
  WalkState acc _ scanned truncated <- go root [] 0 (WalkState [] 0 0 False)
  pure (reverse acc, scanned, truncated)
  where
    -- relSegs is the path from root to current dir, in reverse order
    -- depth is current directory depth (0 = root)
    go dir relSegs depth st@(WalkState acc n scanned _)
      | n >= cap = pure (WalkState acc n scanned True)
      | depth > maxDepth = pure st
      | otherwise = do
          entries <- sort <$> safeListDirectory dir
          let visible =
                if includeHidden
                  then entries
                  else filter (not . isFileHidden) entries
          allowed <- takeEntries budget visible
          step allowed dir relSegs depth st

    step [] _ _ _ st = pure st
    step (name : rest) dir relSegs depth (WalkState acc n scanned truncated)
      | n >= cap = pure (WalkState acc n (scanned + 1) True)
      | otherwise = do
          let full = dir </> name
              entryRel = reverse (T.pack name : relSegs)
              relPath = T.unpack (T.intercalate "/" entryRel)
          -- Skip symlinks: they could point outside the sandbox.
          isLink <- isSymlink full
          if isLink
            then step rest dir relSegs depth (WalkState acc n (scanned + 1) truncated)
            else do
              isDir <- doesDirectoryExist full
              let entryKind = if isDir then DirectoryOnly else FileOnly
                  kindOk = case kind of
                    AnyEntry -> True
                    FileOnly -> entryKind == FileOnly
                    DirectoryOnly -> entryKind == DirectoryOnly
                  matched = kindOk && matchGlob pat entryRel
                  st'
                    | matched = WalkState (relPath : acc) (n + 1) (scanned + 1) truncated
                    | otherwise = WalkState acc n (scanned + 1) truncated
              st'' <-
                if isDir
                  then go full (T.pack name : relSegs) (depth + 1) st'
                  else pure st'
              step rest dir relSegs depth st''

-- | Walk state: matches so far (newest first), their count, entries
-- inspected, and whether the cap truncated the output.
data WalkState = WalkState [FilePath] Int Int Bool

safeListDirectory :: FilePath -> IO [FilePath]
safeListDirectory path = do
  r <- try (listDirectory path) :: IO (Either IOException [FilePath])
  case r of
    Right xs -> pure xs
    Left _ -> pure []

-- ---------------------------------------------------------------------------
-- Glob matcher
-- ---------------------------------------------------------------------------

-- | Split a glob into path segments. Leading "./" is dropped so callers
-- can write either "**/*.hs" or "./**/*.hs".
splitGlob :: Text -> [Text]
splitGlob pat =
  let stripped = fromMaybe pat (T.stripPrefix "./" pat)
   in filter (not . T.null) (T.splitOn "/" stripped)

-- | Match a list of glob segments against a list of path segments.
-- '**' may match zero or more path segments; other segments must match
-- exactly one segment via 'matchSegment'.
matchGlob :: [Text] -> [Text] -> Bool
matchGlob [] [] = True
matchGlob [] _ = False
matchGlob ("**" : ps) ss =
  -- '**' matches zero segments, or one+ segments then continues
  matchGlob ps ss
    || case ss of
      [] -> False
      (_ : rest) -> matchGlob ("**" : ps) rest
matchGlob _ [] = False
matchGlob (p : ps) (s : ss) = matchSegment (T.unpack p) (T.unpack s) && matchGlob ps ss

-- | Match a single glob segment (no '/' allowed in either side).
-- Supports '*' (any run of chars, possibly empty) and '?' (one char).
-- On a mismatch only the most recent '*' takes one more char, which is
-- enough for this glob syntax and keeps matching O(pattern * name) instead
-- of exponential in the number of stars.
matchSegment :: String -> String -> Bool
matchSegment pat0 str0 = go pat0 str0 Nothing
  where
    -- lastStar: the pattern after the most recent '*' and the input it
    -- was last retried from.
    go ('*' : ps) ss _ = go ps ss (Just (ps, ss))
    go (p : ps) (s : ss) lastStar
      | p == '?' || p == s = go ps ss lastStar
    go [] [] _ = True
    go _ _ (Just (ps, _ : ss)) = go ps ss (Just (ps, ss))
    go _ _ _ = False

-- ---------------------------------------------------------------------------
-- Rendering
-- ---------------------------------------------------------------------------

renderResults :: Text -> Text -> [FilePath] -> Int -> Bool -> Maybe Text -> Text
renderResults rootDisplay pat allMatches scanned truncated0 note =
  let (body, cut) = capLines maxOutputBytes (map T.pack allMatches)
      matches = body
      truncated = truncated0 || cut
      header =
        "=== find_files "
          <> T.pack (show pat)
          <> " in "
          <> rootDisplay
          <> " | "
          <> T.pack (show (length matches))
          <> " match(es) across "
          <> T.pack (show scanned)
          <> " entr"
          <> (if scanned == 1 then "y" else "ies")
          <> ( if cut
                 then " | output truncated at " <> T.pack (show maxOutputBytes) <> " bytes"
                 else if truncated then " | truncated (raise maxResults to see more)" else ""
             )
          <> maybe "" (" | " <>) note
          <> " ==="
   in T.unlines (header : body)