packages feed

plexus-synapse-3.15.0: src/Synapse/CLI/Source.hs

{-# LANGUAGE RecordWildCards #-}
-- | SYNAPSE-10: a parameter's value from a file or stdin.
--
-- A flag's value is one shell argument, and Linux caps an argument at 128 KB
-- (@MAX_ARG_STRLEN@), so @--body "$(cat body.md)"@ fails before synapse
-- starts once the body is large. Here a value names its source instead and
-- synapse reads it after it has started, so the only limit left is the
-- backend's own:
--
-- @
-- --body @body.md      the file's contents (UTF-8), as they are
-- --body -             standard input, read once however many values ask
-- --body @@literal     a value that really begins with @ (one @ is dropped)
-- -p @params.json      the whole params object from a file; -p - from stdin
-- @
--
-- The rule applies to every flag value of every backend, after the flags are
-- paired and before any other transformation, so @--body=\@body.md@ works
-- too and a path parameter read from a file is still expanded afterwards.
-- A file that cannot be read is a refusal naming the flag, the path and the
-- escape — never a value sent as the literal text @\@body.md@.
module Synapse.CLI.Source
  ( SourceEnv(..)
  , SourceError(..)
  , mkSourceEnv
  , expandSources
  , expandValue
  , stdinWasRead
  , renderSourceError
  ) where

import Control.Exception (IOException, try)
import System.IO.Error (ioeGetErrorString)
import qualified Data.ByteString as BS
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.Text.Encoding.Error as TE
import System.FilePath (isRelative, (</>))
import System.IO (Handle, stdin)

-- | What a source needs: where relative paths resolve, what @~/@ means, how
-- stdin is read (a test hands in its own), and stdin's contents once read.
data SourceEnv = SourceEnv
  { seCwd   :: FilePath
  , seHome  :: Maybe FilePath
  , seStdin :: IO BS.ByteString
  , seCache :: IORef (Maybe Text)
  }

-- | A source that could not be read: the flag it was for (empty for @-p@),
-- the path as written, and the system's reason.
data SourceError = SourceUnreadable
  { seKey    :: Text
  , seSource :: Text
  , seWhy    :: Text
  }
  deriving (Show, Eq)

mkSourceEnv :: FilePath -> Maybe FilePath -> IO SourceEnv
mkSourceEnv cwd home = do
  cache <- newIORef Nothing
  pure SourceEnv { seCwd = cwd, seHome = home, seStdin = readToEnd stdin, seCache = cache }

-- | All of a handle, without closing it: 'BS.getContents' semi-closes
-- stdin, and the bidirectional mode's terminal check later throws on a
-- closed handle.
readToEnd :: Handle -> IO BS.ByteString
readToEnd h = go []
  where
    go acc = do
      chunk <- BS.hGetSome h 65536
      if BS.null chunk then pure (BS.concat (reverse acc)) else go (chunk : acc)

-- | Whether some value has taken stdin: the bidirectional JSON mode reads it
-- too, and the two cannot share.
stdinWasRead :: SourceEnv -> IO Bool
stdinWasRead env = maybe False (const True) <$> readIORef (seCache env)

-- | Every flag value through 'expandValue'; the first failure wins.
expandSources :: SourceEnv -> [(Text, Text)] -> IO (Either SourceError [(Text, Text)])
expandSources env = go []
  where
    go acc [] = pure (Right (reverse acc))
    go acc ((k, v) : rest) = do
      r <- expandValue env k v
      case r of
        Left e -> pure (Left e)
        Right v' -> go ((k, v') : acc) rest

-- | One value: @-@ is stdin, @\@\@x@ is the literal @\@x@, @\@path@ is that
-- file, anything else is itself.
expandValue :: SourceEnv -> Text -> Text -> IO (Either SourceError Text)
expandValue env@SourceEnv{..} key val
  | val == "-" = Right <$> readStdinOnce env
  | "@@" `T.isPrefixOf` val = pure (Right (T.drop 1 val))
  | Just path <- T.stripPrefix "@" val, not (T.null path) = do
      r <- try (BS.readFile (resolve path)) :: IO (Either IOException BS.ByteString)
      pure $ case r of
        Left e -> Left SourceUnreadable { seKey = key, seSource = val, seWhy = T.pack (ioeGetErrorString e) }
        Right bytes -> Right (decode bytes)
  | otherwise = pure (Right val)
  where
    resolve p = case (T.unpack p, seHome) of
      ('~' : '/' : rest, Just home) -> home </> rest
      (fp, _) | isRelative fp -> seCwd </> fp
              | otherwise -> fp

readStdinOnce :: SourceEnv -> IO Text
readStdinOnce SourceEnv{..} = do
  cached <- readIORef seCache
  case cached of
    Just t -> pure t
    Nothing -> do
      t <- decode <$> seStdin
      writeIORef seCache (Just t)
      pure t

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

-- | The refusal, with the escape for a value that really begins with @.
renderSourceError :: SourceError -> Text
renderSourceError SourceUnreadable{..} =
  flag <> seSource <> ": cannot read " <> T.drop 1 seSource <> " (" <> seWhy <> "). "
    <> "A value that begins with @ is read from that file; write @@" <> T.drop 1 seSource
    <> " to send the text " <> seSource <> " itself."
  where
    flag = if T.null seKey then "-p " else "--" <> T.replace "_" "-" seKey <> " "