packages feed

hurl-workbench-core-0.1.0.0: src/HurlWorkbench/Parameter/Properties.hs

-- | The deliberately small properties-file language accepted by Hurl 8 for
--   variables and secrets. This module parses the transport format; it does
--   not decide whether a parameter is plain or secret in the workspace.
module HurlWorkbench.Parameter.Properties
  ( PropertyFileKind (..),
    PropertyError (..),
    parseVariableProperties,
    parseSecretProperties,
    renderPropertyError,
  )
where

import Data.Bifunctor (first)
import Data.ByteString (ByteString)
import Data.ByteString qualified as ByteString
import Data.Map.Strict qualified as Map
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Text.Encoding
import HurlWorkbench.Prelude
import HurlWorkbench.Workspace.Types
import HurlWorkbench.Workspace.Validate (isValidParameterName)

data PropertyFileKind = VariableProperties | SecretProperties
  deriving stock (Generic, Eq, Show)

-- | A parse error which never retains a property value. That invariant is
--   important for secret files because callers may safely render or show it.
data PropertyError
  = InvalidPropertiesUtf8 !PropertyFileKind !FilePath !Int
  | MissingPropertyEquals !PropertyFileKind !FilePath !Int
  | InvalidPropertyName !PropertyFileKind !FilePath !Int !ParameterName
  | DuplicatePropertyName !PropertyFileKind !FilePath !Int !ParameterName
  | InvalidPropertyValue !PropertyFileKind !FilePath !Int !ParameterName !HurlValueLiteralError
  deriving stock (Generic, Eq, Show)

parseVariableProperties :: FilePath -> ByteString -> Either PropertyError (Map ParameterName HurlValueLiteral)
parseVariableProperties path bytes =
  parseProperties VariableProperties path bytes $ \line name value ->
    first (InvalidPropertyValue VariableProperties path line name) (mkHurlValueLiteral value)

parseSecretProperties :: FilePath -> ByteString -> Either PropertyError (Map ParameterName Text)
parseSecretProperties path bytes =
  parseProperties SecretProperties path bytes $ \line name value ->
    hurlValueLiteralText
      <$> first (InvalidPropertyValue SecretProperties path line name) (mkHurlValueLiteral value)

parseProperties :: PropertyFileKind -> FilePath -> ByteString -> (Int -> ParameterName -> Text -> Either PropertyError value) -> Either PropertyError (Map ParameterName value)
parseProperties kind path bytes parseValue = do
  input <- case Text.Encoding.decodeUtf8' bytes of
    Left _err -> Left (InvalidPropertiesUtf8 kind path (firstInvalidUtf8Offset bytes))
    Right value -> Right value
  foldMLine Map.empty (zip [1 ..] (Text.lines input))
  where
    foldMLine values [] = Right values
    foldMLine values ((lineNumber, rawLine) : remaining) =
      let line = Text.strip rawLine
       in if Text.null line || "#" `Text.isPrefixOf` line
            then foldMLine values remaining
            else case Text.breakOn "=" line of
              (_nameText, rest) | Text.null rest -> Left (MissingPropertyEquals kind path lineNumber)
              (nameText, rest) -> do
                let name = ParameterName nameText
                    valueText = Text.drop 1 rest
                unlessEither (isValidParameterName nameText) (InvalidPropertyName kind path lineNumber name)
                unlessEither (Map.notMember name values) (DuplicatePropertyName kind path lineNumber name)
                value <- parseValue lineNumber name valueText
                foldMLine (Map.insert name value values) remaining

unlessEither :: Bool -> err -> Either err ()
unlessEither condition err = if condition then Right () else Left err

firstInvalidUtf8Offset :: ByteString -> Int
firstInvalidUtf8Offset bytes = go 0
  where
    length_ = ByteString.length bytes
    byte = ByteString.index bytes
    go offset
      | offset >= length_ = max 0 (length_ - 1)
      | current <= 0x7F = go (offset + 1)
      | current >= 0xC2 && current <= 0xDF = continuationSequence offset [continuation]
      | current == 0xE0 = continuationSequence offset [between 0xA0 0xBF, continuation]
      | current >= 0xE1 && current <= 0xEC = continuationSequence offset [continuation, continuation]
      | current == 0xED = continuationSequence offset [between 0x80 0x9F, continuation]
      | current >= 0xEE && current <= 0xEF = continuationSequence offset [continuation, continuation]
      | current == 0xF0 = continuationSequence offset [between 0x90 0xBF, continuation, continuation]
      | current >= 0xF1 && current <= 0xF3 = continuationSequence offset [continuation, continuation, continuation]
      | current == 0xF4 = continuationSequence offset [between 0x80 0x8F, continuation, continuation]
      | otherwise = offset
      where
        current = byte offset
    continuationSequence start checks = check (start + 1) checks
      where
        check next [] = go next
        check next (valid : remaining)
          | next >= length_ = start
          | valid (byte next) = check (next + 1) remaining
          | otherwise = next
    continuation = between 0x80 0xBF
    between lower upper value = value >= lower && value <= upper

renderPropertyError :: PropertyError -> Text
renderPropertyError = \case
  InvalidPropertiesUtf8 kind path offset -> prefix kind path <> "is not UTF-8 at byte " <> tshow offset
  MissingPropertyEquals kind path line -> atLine kind path line <> "must contain NAME=VALUE"
  InvalidPropertyName kind path line name -> atLine kind path line <> "has invalid parameter name " <> quote (unParameterName name)
  DuplicatePropertyName kind path line name -> atLine kind path line <> "repeats parameter " <> quote (unParameterName name)
  InvalidPropertyValue kind path line name err ->
    atLine kind path line <> "cannot transport parameter " <> quote (unParameterName name) <> ": " <> renderHurlValueLiteralError err
  where
    prefix kind path = label kind <> " file " <> quote (Text.pack path) <> " "
    atLine kind path line = prefix kind path <> "line " <> tshow line <> " "
    label VariableProperties = "variables"
    label SecretProperties = "secrets"
    quote value = "\"" <> value <> "\""

tshow :: (Show a) => a -> Text
tshow = Text.pack . show