packages feed

shikumi-tools-0.2.0.0: src/Shikumi/Tool/Builtin/Fs.hs

{-# LANGUAGE RankNTypes #-}

-- | Built-in filesystem tools over 'ToolEnv'.
--
-- The search tools prefer host-provided @rg@/@fd@ through 'ToolEnv.exec' when
-- available, and fall back to a bounded in-process traversal through the same
-- environment record. The fallback skips noisy directories, binary files, and
-- large files, and caps traversal depth and result count.
module Shikumi.Tool.Builtin.Fs
  ( ReadReq (..),
    ReadResp (..),
    WriteReq (..),
    WriteResp (..),
    EditReq (..),
    EditResp (..),
    GrepReq (..),
    GrepMatch (..),
    GrepResp (..),
    GlobReq (..),
    GlobResp (..),
    readTool,
    writeTool,
    editTool,
    grepTool,
    globTool,
  )
where

import Control.Lens ((^.))
import Data.Aeson (ToJSON, Value (..), eitherDecodeStrict, object, (.=))
import Data.Aeson.Key qualified as Key
import Data.Aeson.KeyMap qualified as KM
import Data.Aeson.Types (parseJSON, parseMaybe)
import Data.ByteString qualified as BS
import Data.Char (isAlphaNum)
import Data.Generics.Labels ()
import Data.List (sort)
import Data.Maybe (mapMaybe)
import Data.Proxy (Proxy (..))
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Data.Text.Encoding.Error (lenientDecode)
import Effectful (Eff)
import Effectful.Error.Static (throwError)
import GHC.Generics (Generic)
import Shikumi.Adapter (ToPrompt)
import Shikumi.Error (ShikumiError (..))
import Shikumi.Schema (FromModel (..), ToSchema (..), fromModel)
import Shikumi.Tool (Tool, mkTool)
import Shikumi.Tool.Env
  ( EnvRow,
    ExecRequest (..),
    ToolEnv (..),
  )
import Text.Regex.TDFA
  ( CompOption (..),
    Regex,
    defaultCompOpt,
    defaultExecOpt,
    makeRegexOptsM,
    matchTest,
    (=~),
  )

data ReadReq = ReadReq
  { path :: !Text,
    offset :: !(Maybe Int),
    limit :: !(Maybe Int)
  }
  deriving stock (Generic, Show, Eq)
  deriving anyclass (ToSchema, FromModel, ToPrompt)

data ReadResp = ReadResp
  { content :: !Text,
    lineCount :: !Int,
    truncated :: !Bool
  }
  deriving stock (Generic, Show, Eq)
  deriving anyclass (ToJSON)

data WriteReq = WriteReq
  { path :: !Text,
    content :: !Text
  }
  deriving stock (Generic, Show, Eq)
  deriving anyclass (ToSchema, FromModel, ToPrompt)

data WriteResp = WriteResp
  { path :: !Text,
    bytesWritten :: !Int
  }
  deriving stock (Generic, Show, Eq)
  deriving anyclass (ToJSON)

data EditReq = EditReq
  { path :: !Text,
    oldString :: !Text,
    newString :: !Text,
    replaceAll :: !(Maybe Bool)
  }
  deriving stock (Generic, Show, Eq)
  deriving anyclass (ToSchema, FromModel, ToPrompt)

data EditResp = EditResp
  { path :: !Text,
    replacements :: !Int
  }
  deriving stock (Generic, Show, Eq)
  deriving anyclass (ToJSON)

data GrepReq = GrepReq
  { patternText :: !Text,
    path :: !(Maybe Text),
    glob :: !(Maybe Text),
    ignoreCase :: !(Maybe Bool)
  }
  deriving stock (Generic, Show, Eq)
  deriving anyclass (ToPrompt)

instance ToSchema GrepReq where
  toSchema _ =
    toolInputSchema
      [ ("pattern", toSchema (Proxy :: Proxy Text)),
        ("path", toSchema (Proxy :: Proxy (Maybe Text))),
        ("glob", toSchema (Proxy :: Proxy (Maybe Text))),
        ("ignoreCase", toSchema (Proxy :: Proxy (Maybe Bool)))
      ]

instance FromModel GrepReq where
  fromModelP _ = \case
    Object obj ->
      GrepReq
        <$> requiredField "pattern" obj
        <*> optionalField "path" obj
        <*> optionalField "glob" obj
        <*> optionalField "ignoreCase" obj
    v -> Left (SchemaMismatch ("grep request must be an object, got " <> T.pack (show v)))

data GrepMatch = GrepMatch
  { file :: !Text,
    line :: !Int,
    text :: !Text
  }
  deriving stock (Generic, Show, Eq, Ord)
  deriving anyclass (ToJSON)

data GrepResp = GrepResp
  { matches :: ![GrepMatch],
    truncated :: !Bool
  }
  deriving stock (Generic, Show, Eq)
  deriving anyclass (ToJSON)

data GlobReq = GlobReq
  { patternText :: !Text,
    path :: !(Maybe Text)
  }
  deriving stock (Generic, Show, Eq)
  deriving anyclass (ToPrompt)

instance ToSchema GlobReq where
  toSchema _ =
    toolInputSchema
      [ ("pattern", toSchema (Proxy :: Proxy Text)),
        ("path", toSchema (Proxy :: Proxy (Maybe Text)))
      ]

instance FromModel GlobReq where
  fromModelP _ = \case
    Object obj ->
      GlobReq
        <$> requiredField "pattern" obj
        <*> optionalField "path" obj
    v -> Left (SchemaMismatch ("glob request must be an object, got " <> T.pack (show v)))

data GlobResp = GlobResp
  { paths :: ![Text],
    truncated :: !Bool
  }
  deriving stock (Generic, Show, Eq)
  deriving anyclass (ToJSON)

readTool :: ToolEnv -> Tool ReadReq ReadResp
readTool env =
  mkTool "read" "Read a file's contents." $ \req -> do
    bs <- envReadFile env (req ^. #path)
    let allLines = T.lines (decodeUtf8 bs)
        start = max 0 (maybe 0 id (req ^. #offset))
        afterOffset = drop start allLines
        selected = maybe afterOffset (`take` afterOffset) (req ^. #limit)
        wasTruncated = start > 0 || length selected < length afterOffset
    pure ReadResp {content = T.unlines selected, lineCount = length selected, truncated = wasTruncated}

writeTool :: ToolEnv -> Tool WriteReq WriteResp
writeTool env =
  mkTool "write" "Write UTF-8 text to a file." $ \req -> do
    let bytes = TE.encodeUtf8 (req ^. #content)
    envWriteFile env (req ^. #path) bytes
    pure WriteResp {path = req ^. #path, bytesWritten = BS.length bytes}

editTool :: ToolEnv -> Tool EditReq EditResp
editTool env =
  mkTool "edit" "Replace text in a UTF-8 file." $ \req -> do
    let old = req ^. #oldString
    if T.null old
      then throwValidation "edit: oldString must not be empty"
      else do
        original <- decodeUtf8 <$> envReadFile env (req ^. #path)
        (edited, count) <-
          if maybe False id (req ^. #replaceAll)
            then pure (replaceAllCount old (req ^. #newString) original)
            else pure (replaceFirstCount old (req ^. #newString) original)
        if count == 0
          then throwValidation "edit: oldString not found"
          else do
            envWriteFile env (req ^. #path) (TE.encodeUtf8 edited)
            pure EditResp {path = req ^. #path, replacements = count}

grepTool :: ToolEnv -> Tool GrepReq GrepResp
grepTool env =
  mkTool "grep" "Search files with a regex and return structured matches." $ \req -> do
    root <- maybe (envCwd env) pure (req ^. #path)
    fast <- commandPresent env "rg"
    if fast
      then do
        resp <- grepFast env root req
        case resp of
          Just value -> pure value
          Nothing -> grepFallback env root req
      else grepFallback env root req

globTool :: ToolEnv -> Tool GlobReq GlobResp
globTool env =
  mkTool "glob" "Find files matching a glob pattern and return structured paths." $ \req -> do
    root <- maybe (envCwd env) pure (req ^. #path)
    fast <- commandPresent env "fd"
    let pat = req ^. #patternText
    if fast
      then do
        resp <- globFast env root pat
        case resp of
          Just value -> pure value
          Nothing -> globFallback env root pat
      else globFallback env root pat

grepFast :: (EnvRow es) => ToolEnv -> Text -> GrepReq -> Eff es (Maybe GrepResp)
grepFast env root req = do
  result <-
    envExec
      env
      ExecRequest
        { command =
            T.unwords
              [ "rg --json --color never --max-filesize 5M",
                if maybe False id (req ^. #ignoreCase) then "-i" else "",
                maybe "" (\g -> "-g " <> shellQuote g) (req ^. #glob),
                "--",
                shellQuote (req ^. #patternText),
                shellQuote root
              ],
          cwd = Nothing,
          stdin = Nothing,
          timeoutMs = Just 30000
        }
  if result ^. #exitCode == 0 || result ^. #exitCode == 1
    then pure (Just (parseRgJson (result ^. #stdout)))
    else pure Nothing

globFast :: (EnvRow es) => ToolEnv -> Text -> Text -> Eff es (Maybe GlobResp)
globFast env root pat = do
  result <-
    envExec
      env
      ExecRequest
        { command =
            T.unwords
              [ "fd --glob --color never --exclude .git --exclude node_modules --exclude dist-newstyle --exclude .stack-work",
                "--",
                shellQuote pat,
                shellQuote root
              ],
          cwd = Nothing,
          stdin = Nothing,
          timeoutMs = Just 30000
        }
  if result ^. #exitCode == 0
    then do
      let found = take maxResults (filter (not . T.null) (T.lines (result ^. #stdout)))
      pure (Just GlobResp {paths = sort found, truncated = length found >= maxResults})
    else pure Nothing

grepFallback :: (EnvRow es) => ToolEnv -> Text -> GrepReq -> Eff es GrepResp
grepFallback env root req = do
  regex <- compileRegex (req ^. #patternText) (maybe False id (req ^. #ignoreCase))
  paths <- walkFiles env root
  let globOk p = maybe True (`globMatches` p) (req ^. #glob)
  matches <-
    fmap concat $
      traverse
        ( \filePath -> do
            if globOk filePath
              then grepFile env regex filePath
              else pure []
        )
        paths
  let limited = take maxResults matches
  pure GrepResp {matches = limited, truncated = length matches > maxResults}

globFallback :: (EnvRow es) => ToolEnv -> Text -> Text -> Eff es GlobResp
globFallback env root pat = do
  paths <- walkFiles env root
  let found = sort (take maxResults (filter (globMatches pat) paths))
  pure GlobResp {paths = found, truncated = length found >= maxResults}

grepFile :: (EnvRow es) => ToolEnv -> Regex -> Text -> Eff es [GrepMatch]
grepFile env regex filePath = do
  stat <- envStat env filePath
  case stat of
    Just s | s ^. #isFile && s ^. #size <= maxFileSize -> do
      bs <- envReadFile env filePath
      if isBinary bs
        then pure []
        else do
          let lineMatches = regexMatches regex
          pure
            [ GrepMatch {file = filePath, line = n, text = lineText}
            | (n, lineText) <- zip [1 ..] (T.lines (decodeUtf8 bs)),
              lineMatches lineText
            ]
    _ -> pure []

walkFiles :: (EnvRow es) => ToolEnv -> Text -> Eff es [Text]
walkFiles env root = go 0 root
  where
    go depth dir
      | depth > maxDepth = pure []
      | otherwise = do
          entries <- envReaddir env dir
          fmap concat $
            traverse
              ( \entry -> do
                  let child = joinPathText dir (entry ^. #name)
                  if entry ^. #isDir
                    then
                      if skipDirName (entry ^. #name)
                        then pure []
                        else go (depth + 1) child
                    else pure [child]
              )
              entries

parseRgJson :: Text -> GrepResp
parseRgJson output =
  let matches = take maxResults (mapMaybe parseLine (T.lines output))
   in GrepResp {matches, truncated = length matches >= maxResults}
  where
    parseLine line = do
      Object obj <- either (const Nothing) Just (eitherDecodeStrict (TE.encodeUtf8 line))
      String "match" <- KM.lookup "type" obj
      Object dat <- KM.lookup "data" obj
      Object pathObj <- KM.lookup "path" dat
      String file <- KM.lookup "text" pathObj
      Object linesObj <- KM.lookup "lines" dat
      String text <- KM.lookup "text" linesObj
      Number lineNo <- KM.lookup "line_number" dat
      n <- parseMaybe parseJSON (Number lineNo)
      pure GrepMatch {file, line = n, text = T.dropWhileEnd (== '\n') text}

compileRegex :: (EnvRow es) => Text -> Bool -> Eff es Regex
compileRegex pat ignoreCase =
  case makeRegexOptsM defaultCompOpt {caseSensitive = not ignoreCase} defaultExecOpt (T.unpack pat) of
    Just regex -> pure regex
    Nothing -> throwValidation ("grep: invalid regex pattern " <> pat)

regexMatches :: Regex -> Text -> Bool
regexMatches regex haystack = matchTest regex (T.unpack haystack)

globMatches :: Text -> Text -> Bool
globMatches pat candidate =
  T.unpack candidate =~ ("^" <> T.unpack (globToRegex pat) <> "$" :: String)

globToRegex :: Text -> Text
globToRegex = go . T.unpack
  where
    go [] = ""
    go ('*' : '*' : rest) = ".*" <> go rest
    go ('*' : rest) = "[^/]*" <> go rest
    go ('?' : rest) = "[^/]" <> go rest
    go (c : rest) = T.pack (escapeRegex c) <> go rest

replaceFirstCount :: Text -> Text -> Text -> (Text, Int)
replaceFirstCount old new src =
  let (before, after) = T.breakOn old src
   in if T.null after
        then (src, 0)
        else (before <> new <> T.drop (T.length old) after, 1)

replaceAllCount :: Text -> Text -> Text -> (Text, Int)
replaceAllCount old new src =
  let pieces = T.splitOn old src
      count = length pieces - 1
   in (T.intercalate new pieces, count)

commandPresent :: (EnvRow es) => ToolEnv -> Text -> Eff es Bool
commandPresent env cmd = do
  result <-
    envExec
      env
      ExecRequest
        { command = "command -v " <> cmd,
          cwd = Nothing,
          stdin = Nothing,
          timeoutMs = Just 5000
        }
  pure (result ^. #exitCode == 0)

decodeUtf8 :: BS.ByteString -> Text
decodeUtf8 = TE.decodeUtf8With lenientDecode

isBinary :: BS.ByteString -> Bool
isBinary = BS.elem 0 . BS.take 8192

skipDirName :: Text -> Bool
skipDirName name =
  name `elem` [".git", ".hg", ".svn", "node_modules", "dist-newstyle", ".stack-work", ".direnv"]

joinPathText :: Text -> Text -> Text
joinPathText base name
  | T.null base = name
  | "/" `T.isSuffixOf` base = base <> name
  | otherwise = base <> "/" <> name

shellQuote :: Text -> Text
shellQuote t = "'" <> T.replace "'" "'\\''" t <> "'"

escapeRegex :: Char -> String
escapeRegex c
  | isAlphaNum c || c == '/' || c == '_' || c == '-' = [c]
  | otherwise = ['\\', c]

throwValidation :: (EnvRow es) => Text -> Eff es a
throwValidation = throwError . ValidationFailure

toolInputSchema :: [(Text, Value)] -> Value
toolInputSchema fields =
  object
    [ "type" .= ("object" :: Text),
      "properties" .= object (map (\(key, value) -> Key.fromText key .= value) fields),
      "required" .= (["pattern"] :: [Text]),
      "additionalProperties" .= False
    ]

requiredField :: (FromModel a) => Text -> KM.KeyMap Value -> Either ShikumiError a
requiredField key obj =
  case KM.lookup (Key.fromText key) obj of
    Nothing -> Left (MissingField key)
    Just value -> fromModel value

optionalField :: (FromModel a) => Text -> KM.KeyMap Value -> Either ShikumiError (Maybe a)
optionalField key obj =
  case KM.lookup (Key.fromText key) obj of
    Nothing -> Right Nothing
    Just Null -> Right Nothing
    Just value -> Just <$> fromModel value

maxResults :: Int
maxResults = 1000

maxFileSize :: Integer
maxFileSize = 5 * 1024 * 1024

maxDepth :: Int
maxDepth = 25