packages feed

cfv (empty) → 0.1.0.0

raw patch · 12 files changed

+981/−0 lines, 12 files

Files

+ CHANGELOG.md view
@@ -0,0 +1,5 @@+# Revision history for cfv++## 0.1.0.0 -- 2026-11-10++* Initial release on Hackage.
+ LICENSE view
@@ -0,0 +1,29 @@+Copyright (c) 2026, Harley D. Eades III+++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++    * Redistributions of source code must retain the above copyright+      notice, this list of conditions and the following disclaimer.++    * Redistributions in binary form must reproduce the above+      copyright notice, this list of conditions and the following+      disclaimer in the documentation and/or other materials provided+      with the distribution.++    * Neither the name of the copyright holder nor the names of its+      contributors may be used to endorse or promote products derived+      from this software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT+HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ README.md view
@@ -0,0 +1,17 @@+# CFV: A simple file format for configuration variables++Declare configuration variables in a '.cfv' file and parse them.++An example of the contents of a CFV file is:++```+-- a comment+greeting = 'hello';                   -- single-quoted string+empty    = "";                        -- double-quoted string+now      = `date +%s`;                -- run shell command+home'    = `echo "$HOME"`;            -- access to your shells PATH+secret api_key = "password/token/key" -- a secret variable (public is default)+```++For more information on the syntax of variable declarations see +[Hackage](https://hackage-content.haskell.org/package/cfv-0.1.0.0).
+ app/Main.hs view
@@ -0,0 +1,5 @@+module Main (main) where++main :: IO ()+main = do+  putStrLn "Hello, Haskell!"  
+ cfv.cabal view
@@ -0,0 +1,100 @@+cabal-version:      3.14+name:               cfv+version:            0.1.0.0+synopsis:           A simple file format for configuration variables+license:            BSD-3-Clause+license-file:       LICENSE+author:             Harley D. Eades III+maintainer:         harley.eades@gmail.com+homepage:           https://github.com/heades/cfv+bug-reports:        https://github.com/heades/cfv/issues   +category:           Development+build-type:         Simple+extra-doc-files:+    CHANGELOG.md+    README.md+extra-source-files:+    test/files/basic.cfv+tested-with:     +    GHC == 9.8.4+    GHC == 9.10.1+    GHC == 9.10.2+    GHC == 9.10.3+    GHC == 9.12.2+    GHC == 9.12.4+    GHC == 9.14.1++description: +    Declare configuration variables in a '.cfv' file and parse them.++    An example of the contents of a CFV file is:++    > -- a comment+    > greeting = 'hello';                   -- single-quoted string+    > empty    = "";                        -- double-quoted string+    > now      = `date +%s`;                -- run shell command+    > home'    = `echo "$HOME"`;            -- access to your shells PATH+    > secret api_key = "password/token/key" -- a secret variable (public is default)++    For more information on the syntax of variable declarations see "Configuration.Cfv".++common extensions+    default-language: GHC2021+    default-extensions:+        InstanceSigs+        OverloadedStrings++common ghc-options+    ghc-options: -Wall -Widentities++common rts-options+    ghc-options: -rtsopts -threaded "-with-rtsopts=-N"++library+    import:           extensions+    import:           ghc-options+    exposed-modules:  +        Configuration.Cfv+        Configuration.Cfv.AST+        Configuration.Cfv.Parser+        Configuration.Cfv.Shell        +    build-depends:    +        base       >= 4.7    && < 5+       ,bytestring >= 0.12.2 && < 0.13+       ,containers >= 0.8    && < 0.9+       ,filepath   >= 1.5.4  && < 1.6+       ,megaparsec >= 9.8.3  && < 9.9+       ,mtl        >= 2.3.1  && < 2.4+       ,process    >= 1.6.26 && < 1.7+       ,text       >= 2.1.3  && < 2.2+    hs-source-dirs:   src+    default-language: Haskell2010++executable cfv+    import:           extensions+    import:           ghc-options+    import:           rts-options+    main-is:          Main.hs+    build-depends:+         base >= 4.7 && < 5+        ,cfv+    hs-source-dirs:   app+    default-language: Haskell2010++test-suite cfv-test+    import:           extensions+    import:           ghc-options+    import:           rts-options+    default-language: Haskell2010+    other-modules:+        CfvSpec+    type:             exitcode-stdio-1.0+    hs-source-dirs:   test+    main-is:          Spec.hs+    build-tool-depends: +        hspec-discover:hspec-discover+    build-depends:+         base  >= 4.7     && < 5+        ,hspec >= 2.11.18 && < 2.12+        ,text  >= 2.1.3   && < 2.2+        ,cfv
+ src/Configuration/Cfv.hs view
@@ -0,0 +1,67 @@+{-# LANGUAGE Safe #-}+{-|+Module      : Cfv+Description : The CFV API+Copyright   : (c) Harley Eades, 2026+Maintainer  : harley.eades@gmail.com++A CFV file is a sequence of configuration variable declarations. ++A valid variable declarations consist of one of the following:++* Strings: @name = \'...\';@ or @name = "...";@ declares a string. The escapes+  @\\\\, \\', \\", \\n, \\t, \\r, \\0@ are recognized; raw newlines are allowed.++* Commands: @name = \`cmd\`@ runs @cmd@ in the user's shell and uses its+  stdout as the value. Like shell command substitution, trailing+  newlines are stripped. @cmd@ is everything up to the next backtick (there is+  no escape for a backtick; use @$(...)@ for nested substitution). The command+  runs in the current working directory with the calling process's environment+  and a closed standard input. ++* Secrets: A declaration may be prefixed with @secret@ (@secret name = ...@) to+  mark its value as a secret. When no @secret@ attribute labels a declaration it+  is assumed to be non-sensitive (public). Note that @secret@ is only a keyword+  when a variable name follows it, so @secret = \'x\';@ still declares a public+  variable called @secret@.++Parsing a CFV file returns a list of t'Binding' which is simply a pair+@('VarName', t'Var')@ of a variable name ( t'String' ) and a variable ( t'Var' ). The latter+labels a variable's value with an attribute which currently consist of 'Public'+and 'Secret'. After parsing you are free to use these variable declarations in+any way you wish. ++This API does place one restriction on the declarations attributed+with @sercret@. The default t'Show' instance does not convert a secret variable's+value to a string, but simply translates it to @Secret ()@. This makes printing+the contents of a CFV file to a log or the screen safe.+-}+module Configuration.Cfv+  ( -- * Parsing and evaluating+    Binding+  , Var (..)+  , varAttr+  , revealVar+  , VarAttr (..)+  , parseCfvFile+  , parseCfvFileWith+  , parseEvalCfv+  , parseEvalCfvWith+    -- * Shell configuration+  , ShellConfig (..)+  , defaultShellConfig+    -- * Errors+  , CfvError (..)+  , SyntaxError (..)+  , ErrorKind (..)+  , Item (..)+  , Position (..)+  , CommandError (..)+  , CommandProblem (..)+  , renderCfvError+  ) where++import Configuration.Cfv.AST+import Configuration.Cfv.Shell+import Configuration.Cfv.Parser+
+ src/Configuration/Cfv/AST.hs view
@@ -0,0 +1,59 @@+{-|+Module      : AST+Description : Abstract syntax+Copyright   : (c) Harley Eades, 2026+Maintainer  : harley.eades@gmail.com++The abstract syntax for CFV files.+-}+module Configuration.Cfv.AST  +    (VarName+    ,VarValue+    ,Binding+    ,Var(..)+    ,VarAttr(..)+    ,varAttr+    ,revealVar) where+import Data.Text (Text)++-- | Security level for variables.+data VarAttr+  = Public  -- ^ The default; has no syntax.+  | Secret  -- ^ Declared with a @secret@ prefix.+  deriving (Eq, Ord, Show)++-- | A variables value.+type VarValue = Text++-- | A variable's final value (after any command has been run) together with its+-- attribute.+--+-- The 'Show' instance never prints a secret value, so a list of bindings can+-- be logged safely:+--+-- > [("name",Public "x"),("key",Secret ())]+--+data Var +  = Var VarAttr VarValue  +  deriving (Eq)++instance Show Var where+  showsPrec :: Int -> Var -> ShowS+  showsPrec d (Var Public v) =+    showParen (d > 10) (showString "Public " . showsPrec 11 v)+  showsPrec _ (Var Secret _) = showString "Secret ()"++-- | The variable's attribute.+varAttr :: Var -> VarAttr+varAttr (Var a _) = a++-- | The variable's value, secret or not.+revealVar :: Var -> Text+revealVar (Var _ v) = v++-- | A variables name.+type VarName = Text++-- | A variable binding: a name, attribute, and a value.+type Binding = (VarName, Var)+
+ src/Configuration/Cfv/Parser.hs view
@@ -0,0 +1,462 @@+{-# LANGUAGE ViewPatterns #-}+{-|+Module      : Parser+Description : The CFV Parser+Copyright   : (c) Harley Eades, 2026+Maintainer  : harley.eades@gmail.com++This module consists of the CFV parser API. +-}+module  Configuration.Cfv.Parser +    (parseCfvFile+    ,parseCfvFileWith+    ,CfvError (..)+    ,SyntaxError (..)+    ,ErrorKind (..)+    ,Item (..)+    ,Position (..)+    ,parseEvalCfv+    ,parseEvalCfvWith+    ,renderCfvError) where++import qualified GHC.IO.Exception as E+import Data.Text.Encoding.Error (UnicodeException)+import qualified GHC.Exception as E+import Data.Text (Text)+import Text.Megaparsec (SourcePos (..), ParseErrorBundle (..), ParseError (..), ErrorItem (..), ErrorFancy (..), Parsec, unPos, PosState (..), errorOffset, TraversableStream (reachOffset), parseError, getOffset, getSourcePos, empty, try, MonadParsec (takeWhileP, notFollowedBy, observing, eof), satisfy, (<?>), (<|>), many, optional, anySingle, option, parse)+import System.FilePath (takeExtension)+import qualified Control.Exception as E+import qualified Data.ByteString as BS+import qualified Data.Text.Encoding as TE+import qualified Data.List.NonEmpty as NE+import qualified Data.Set as Set+import qualified Text.Megaparsec.Char.Lexer as L+import Text.Megaparsec.Char (space1, string, char)+import Control.Monad (void, when)+import Data.Char (isAlphaNum, isAsciiLower, isAsciiUpper)+import qualified Data.Bifunctor as BI++import Configuration.Cfv.AST (VarAttr (..), Binding, Var (..))+import Configuration.Cfv.Shell (CommandError (..), ShellConfig, defaultShellConfig, evalValue, describeProblem)+import qualified Data.Text as DT+import Data.List.NonEmpty (NonEmpty)++-------------------------------------------------------------------------------------------------------------------+-- Public API++-- | Read, parse and evaluate a @.cfv@ file using 'defaultShellConfig'.+-- Returns the variables in source order; a variable declared more than once+-- appears more than once (so 'lookup' finds the first declaration). Commands+-- run one at a time in file order, and the first failure aborts the whole+-- operation.+--+-- Never throws for extension, read, decode, parse or command problems; they are+-- all returned as 'CfvError'. (To throw instead:+-- @either E.throwIO pure =<< parseCfvFile p@.)+parseCfvFile :: FilePath -> IO (Either CfvError [Binding])+parseCfvFile = parseCfvFileWith defaultShellConfig++-- | Like 'parseCfvFile' with an explicit shell configuration.+parseCfvFileWith :: ShellConfig -> FilePath -> IO (Either CfvError [Binding])+parseCfvFileWith cfg path = do+  parsed <- readCfvFile path+  case parsed of+    Left err    -> pure (Left err)+    Right decls -> runDeclarations cfg decls++-- | Like 'parseCfvFile', but doesn't read from a file. Useful for testing.+parseEvalCfv :: [Char] -> Text -> IO (Either CfvError [Binding])+parseEvalCfv = parseEvalCfvWith defaultShellConfig++-- | Like 'parseEvalCfv' but with an explicit shell configuration.+parseEvalCfvWith :: ShellConfig -> [Char] -> Text -> IO (Either CfvError [Binding])+parseEvalCfvWith cfg path txt = do+  let parsed = BI.first FileSyntaxError $ parseCfv path txt+  case parsed of+    Left err    -> pure (Left err)+    Right decls -> runDeclarations cfg decls++-------------------------------------------------------------------------------------------------------------------++-- | One @[secret] name = value@ declaration, as written in the file.+data Declaration = Declaration+  { declName     :: Text+  , declAttr     :: VarAttr+  , declValue    :: Value+  , declPosition :: Position+    -- ^ Where the declaration starts (its @secret@ prefix if it has one,+    -- otherwise its variable name).+  } deriving (Eq)++-- | Like the derived instance, except that the value of a 'Secret'+-- declaration is shown as @\<redacted\>@.+instance Show Declaration where+  showsPrec :: Int -> Declaration -> ShowS+  showsPrec d (Declaration n a v p) =+    showParen (d > 10) $+      showString "Declaration "+        . showsPrec 11 n . showChar ' '+        . showsPrec 11 a . showChar ' '+        . (case a of+             Public -> showsPrec 11 v+             Secret -> showString "<redacted>")+        . showChar ' '+        . showsPrec 11 p++-- | The value of a variable declaration.+data Value+  = StringValue Text   -- ^ A quoted string, already unescaped.+  | CommandValue Text  -- ^ The text between backticks, unmodified.+  deriving (Eq, Show)++-- | Everything that can go wrong when processing a CFV file.+data CfvError+  = InvalidExtension FilePath+    -- ^ The file name does not end in @.cfv@ (checked before anything is read).+  | FileReadError FilePath E.IOException+    -- ^ The file could not be opened or read.+  | FileDecodeError FilePath UnicodeException+    -- ^ The file's contents are not valid UTF-8.+  | FileSyntaxError SyntaxError+    -- ^ The contents do not parse.+  | FileCommandError CommandError Text VarAttr Position+    -- ^ A backtick command could not be run, or failed. (Only produced by+    -- functions that run commands.)+  deriving (Eq, Show)++-- | A problem with the text itself, with a location.+data SyntaxError = SyntaxError+  { syntaxErrorPosition   :: Position+    -- ^ Approximate position where the syntax error occurred.+  , syntaxErrorKind       :: ErrorKind+  -- ^ The kind of syntax error.+  , syntaxErrorSourceLine :: Maybe Text+    -- ^ The source line containing error. Always 'Nothing' when the input+    -- contains the text @secret@ anywhere (even in a comment), because the line+    -- could then contain, or be part of, a secret value. Every secret+    -- declaration contains that text, so this cannot miss one.+  } deriving (Eq, Show)++-- | The specific kind of syntax error.+data ErrorKind+  = UnexpectedInput (Maybe Item) [Item]+    -- ^ Found this (if known), but expected one of these.+  | ReservedWord Text+    -- ^ A Haskell keyword (or a lone @_@) was used as a variable name.+    -- Position: start of the word.+  | InvalidEscape (Maybe Char)+    -- ^ A backslash followed by a character that is not a known escape.+    -- Position: the backslash. 'Nothing' (character withheld) inside the value+    -- of a 'Secret' declaration.+  | UnterminatedString Char+    -- ^ A string literal with no closing quote (carries the quote character).+    -- Position: the opening quote.+  | UnterminatedCommand+    -- ^ A backtick command with no closing backtick.+    -- Position: the opening backtick.+  | OtherFailure Text+    -- ^ Anything else; should not occur with the current grammar.+  deriving (Eq, Show)++-- | Something the parser found or expected.+data Item+  = Literal Text      -- ^ Specific text, such as @";"@.+  | Description Text  -- ^ A named construct, such as @"variable name"@.+  | EndOfFile+  deriving (Eq, Ord, Show)++-- | A location in the source. Line and column are 1-based; the offset is the+-- 0-based character offset from the start of the input.+data Position = Position+  { posFile   :: FilePath+  , posLine   :: Int+  , posColumn :: Int+  , posOffset :: Int+  } deriving (Eq, Show)++instance E.Exception CfvError where+  displayException :: CfvError -> String+  displayException = DT.unpack . renderCfvError ++instance E.Exception SyntaxError where+  displayException :: SyntaxError -> String+  displayException = DT.unpack . renderSyntaxError++-- | Turn declarations into bindings, running each 'CommandValue' in the user's+-- shell. The shell is only looked up if there is at least one command.+runDeclarations :: ShellConfig -> [Declaration] -> IO (Either CfvError [Binding])+runDeclarations cfg = go []+  where+    go acc [] = pure (Right (reverse acc))+    go acc (d : ds) = do+      case declValue d of+        CommandValue cmd -> do+            result <- evalValue cfg cmd+            case result of+              Left err -> pure (Left $ FileCommandError err (declName d) (declAttr d) (declPosition d))+              Right v  -> go ((declName d, Var (declAttr d) (DT.pack v)) : acc) ds+        StringValue v -> go ((declName d, Var (declAttr d) v) : acc) ds++-- | Check the extension, read and parse a file, without running any command.+-- Never returns 'FileCommandError'.+readCfvFile :: FilePath -> IO (Either CfvError [Declaration])+readCfvFile path+  | takeExtension path /= ".cfv" = pure (Left (InvalidExtension path))+  | otherwise = do+      result <- E.try (BS.readFile path)+      pure $ case result of+        Left e -> Left (FileReadError path e)+        Right bytes -> +          case TE.decodeUtf8' bytes of+            Left e    -> Left (FileDecodeError path e)+            Right txt -> BI.first FileSyntaxError $ parseCfv path txt++-- | Pure parser; runs nothing. The 'FilePath' is only used to label error+-- positions and is not checked for an extension.+parseCfv :: FilePath -> Text -> Either SyntaxError [Declaration]+parseCfv name input = BI.first fromBundle $ parse program name input++-- | Converts a command error into a 'Text' that is appropriate for logging.+renderCommandError :: CommandError -> Text -> VarAttr -> Position -> Text+renderCommandError (CommandError cmd problem) name varAttr (Position f l c _)+  = (DT.pack f) <> ":" <> DT.show l <> ":" <> DT.show c <> ": error: variable " <> DT.show name+      <> ": " <> describeProblem problem+      <> if varAttr == Secret then "" else "\n  command: " <> cmd ++-- | Converts a error into a 'Text' that is appropriate for logging.+renderCfvError :: CfvError -> Text+renderCfvError (InvalidExtension (DT.pack->p))+  = p <> ": not a .cfv file (the file name must end in \".cfv\")"+renderCfvError (FileReadError (DT.pack->p) e)            +  = p <> ": cannot read file: " <> DT.show e+renderCfvError (FileDecodeError (DT.pack->p) e)          +  = p <> ": file is not valid UTF-8: " <> DT.show e+renderCfvError (FileSyntaxError e)            +  = renderSyntaxError e+renderCfvError (FileCommandError e n vatr p)  +  = renderCommandError e n vatr p++-- | Multi-line, compiler-style message with a caret under the error.+renderSyntaxError :: SyntaxError -> Text+renderSyntaxError (SyntaxError (Position (DT.pack->f) l c _) kind srcLine) =+  DT.intercalate "\n" $+    [ f <> ":" <> DT.show l <> ":" <> DT.show c <> ": error: " <> describeKind kind ]+      <> case srcLine of+            Just ln -> [ "  | " <> ln, "  | " <> DT.replicate (c - 1) " " <> "^" ]+            Nothing -> []++-- | Describes a syntax error in a logging appropriate fashion.+describeKind :: ErrorKind -> Text+describeKind (UnexpectedInput found expected) +  = "unexpected " <> maybe "input" describeItem found+ <> (if null expected+     then ""+     else "; expected " <> DT.intercalate ", " (map describeItem expected))+describeKind (ReservedWord w) =+  "reserved word " <> DT.show w <> " cannot be used as a variable name"+describeKind (InvalidEscape (Just c)) +  = "invalid escape sequence \\" <> DT.singleton c+describeKind (InvalidEscape Nothing) +  = "invalid escape sequence (character withheld: secret value)"+describeKind (UnterminatedString q) +  = "unterminated string literal (no closing " <> DT.singleton q <> " for this one)"+describeKind UnterminatedCommand +  = "unterminated command (no closing backtick for this one)"+describeKind (OtherFailure msg) +  = msg++-- | Converts an 'Item' to a 'Text'.+describeItem :: Item -> Text+describeItem (Literal s)     = DT.show s+describeItem (Description d) = d+describeItem EndOfFile       = "end of input"++-- | Internal custom errors raised by the parser itself.+data Custom+  = CReservedWord Text+  | CInvalidEscape (Maybe Char)+  | CUnterminatedString Char+  | CUnterminatedCommand+  deriving (Eq, Ord, Show)++-- | Converts a position in the source file into a t'Position'.+toPosition :: SourcePos -> Int -> Position+toPosition sp = Position (sourceName sp) (unPos (sourceLine sp)) (unPos (sourceColumn sp))++-- | Converts a "Text.Megaparsec" error into a t'SyntaxError'.+fromBundle :: ParseErrorBundle Text Custom -> SyntaxError+fromBundle bundle =+  SyntaxError+    { syntaxErrorPosition   = toPosition (pstateSourcePos posState) offset+    , syntaxErrorKind       = kind+    , syntaxErrorSourceLine = if "secret" `DT.isInfixOf` pstateInput (bundlePosState bundle)+                           then Nothing+                           else fmap DT.pack srcLine+    }+  where+    -- Without error recovery there is exactly one error in the bundle.+    err = NE.head (bundleErrors bundle)+    offset = errorOffset err+    (srcLine, posState) = reachOffset offset (bundlePosState bundle)+    kind = case err of+      TrivialError _ found expected ->+        UnexpectedInput+          (fromItem <$> found)+          (map fromItem (Set.toList expected))+      FancyError _ fancy -> case Set.toList fancy of+        (f : _) -> fromFancy f+        []      -> OtherFailure "unknown error"++-- | Converts a non-empty list of characters into a 'Text'.+textFromNEList :: NonEmpty Char -> Text+textFromNEList = DT.pack . NE.toList++-- | Convert an t'Text.Megaparsec.Error.ErrorItem' into an 'Item'.+fromItem :: ErrorItem Char -> Item+fromItem (Tokens ts) = Literal $ textFromNEList ts+fromItem (Label cs)  = Description $ textFromNEList cs+fromItem EndOfInput  = EndOfFile++-- | Convert an t'Text.Megaparsec.Error.ErrorFancy' into an 'ErrorKind'.+fromFancy :: ErrorFancy Custom -> ErrorKind+fromFancy (ErrorCustom (CReservedWord w))       = ReservedWord w+fromFancy (ErrorCustom (CInvalidEscape c))      = InvalidEscape c+fromFancy (ErrorCustom (CUnterminatedString q)) = UnterminatedString q+fromFancy (ErrorCustom CUnterminatedCommand)    = UnterminatedCommand+fromFancy (ErrorFail msg)                       = OtherFailure . DT.pack $ msg+fromFancy ErrorIndentation {}                   = OtherFailure "indentation error"++-- | The parser on 'Text' tokens firing errors based on 'Custom'.+type Parser = Parsec Custom Text++-- | Fail with a custom error reported at the given offset.+failAt :: Int -> Custom -> Parser a+failAt o c = parseError (FancyError o (Set.singleton (ErrorCustom c)))++-- | Get the current position during parsing.+currentPosition :: Parser Position+currentPosition = do+  off <- getOffset+  sp  <- getSourcePos+  pure (toPosition sp off)++-- | Space consumer: whitespace (incl. newlines) and @--@ line comments.+sc :: Parser ()+sc = L.space space1 lineComment empty++-- | A Haskell line comment: two or more dashes NOT followed by a symbol+-- character (so @-->@ or @--|@ is an operator, not a comment, as in Haskell).+lineComment :: Parser ()+lineComment = try $ do+  _ <- string "--"+  _ <- takeWhileP Nothing (== '-')+  notFollowedBy (satisfy isSymbolChar)+  void (takeWhileP (Just "comment text") (/= '\n'))+  where+    isSymbolChar c = c `elem` ['!','#','$','%','&','*','+','.','/','<','=','>','?','@','\\','^','|','~',':']++-- | A lexeme including our space consumer 'sc'.+lexeme :: Parser a -> Parser a+lexeme = L.lexeme sc++-- | A symbol including our space consumer 'sc'.+symbol :: Text -> Parser Text+symbol = L.symbol sc++-- | A Haskell variable identifier: starts with a lowercase letter or @_@,+-- continues with letters, digits, @_@ or @'@, and is not a reserved word.+varName :: Parser Text+varName = lexeme $ do+  start <- getOffset+  c  <- satisfy (\x -> isAsciiLower x || isAsciiUpper x || x == '_') <?> "variable name"+  cs <- takeWhileP Nothing (\x -> isAlphaNum x || x == '_' || x == '\'')+  let name = DT.cons c cs+  when (name `elem` reserved) $ failAt start (CReservedWord name)+  pure name++-- | Reserved words.+reserved :: [Text]+reserved = [ "_" ]++-- | A single- or double-quoted string. Supports the escapes+-- @\\\\ \\' \\" \\n \\t \\r \\0@. Raw newlines are allowed inside strings.+stringLit :: Parser Text+stringLit = lexeme (quoted '\'' <|> quoted '"') <?> "string literal"++-- | Parser a single or double quoted string.+quoted :: Char -- ^ The quote character.+       -> Parser Text+quoted q = do+  start <- getOffset+  _ <- char q+  body <- many (escaped start <|> plain)+  -- 'many' only stops on the closing quote or at end of input.+  closed <- optional (char q)+  case closed of+    Nothing -> failAt start (CUnterminatedString q)+    Just _  -> pure . DT.pack $ body+  where+    plain = satisfy (\c -> c /= q && c /= '\\')++    escaped start = do+      slash <- getOffset+      _ <- char '\\'+      next <- optional anySingle+      case next of+        Nothing -> failAt start (CUnterminatedString q)+        Just c  -> maybe (failAt slash (CInvalidEscape (Just c))) pure (lookup c escapes)++    escapes =+      [ ('n', '\n'), ('t', '\t'), ('r', '\r'), ('0', '\0')+      , ('\\', '\\'), ('\'', '\''), ('"', '"')+      ]++-- | A backtick-delimited shell command: everything up to the next backtick.+commandLit :: Parser Text+commandLit = lexeme body <?> "backtick command"+  where+    body = do+      start <- getOffset+      _ <- char '`'+      cmd <- takeWhileP Nothing (/= '`')+      closed <- optional (char '`')+      case closed of+        Nothing -> failAt start CUnterminatedCommand+        Just _  -> pure cmd++-- | Parse a variable attribute.+attribute :: Parser VarAttr+attribute = option Public (Secret <$ try (keyword <* notFollowedBy (char '=')))+  where+    keyword = lexeme (string "secret" <* notFollowedBy (satisfy isNameChar))+    isNameChar x = isAlphaNum x || x == '_' || x == '\''++-- | Parser a variable declaration.+declaration :: Parser Declaration+declaration = do+  pos   <- currentPosition+  attr  <- attribute+  name  <- varName+  _     <- symbol "="+  value <- withholdIfSecret attr (StringValue <$> stringLit <|> CommandValue <$> commandLit)+  case value of+    StringValue _  -> void (symbol ";")+    CommandValue _ -> void (optional (symbol ";"))+  pure (Declaration name attr value pos)++-- | Run a parser for the value of a declaration. If it fails inside a+-- 'Secret' value, strip from the error any input that was quoted in it: the+-- unexpected character (which could be the start of the secret) and the+-- character after a bad backslash.+withholdIfSecret :: VarAttr -> Parser a -> Parser a+withholdIfSecret Public p = p+withholdIfSecret Secret p = observing p >>= either (parseError . withhold) pure+  where+    withhold (TrivialError o _ expected) = TrivialError o Nothing expected+    withhold (FancyError o fancy)        = FancyError o (Set.map hide fancy)+    hide (ErrorCustom (CInvalidEscape _)) = ErrorCustom (CInvalidEscape Nothing)+    hide f                                = f++-- | A whole file: any number of declarations, with comments/whitespace anywhere.+program :: Parser [Declaration]+program = sc *> many declaration <* eof
+ src/Configuration/Cfv/Shell.hs view
@@ -0,0 +1,140 @@+{-|+Module      : Shell+Description : Command evocation on the shell+Copyright   : (c) Harley Eades, 2026+Maintainer  : harley.eades@gmail.com++This is the API for accessing the users shell. All commands are ran in the login+shell with access to the @PATH@ environment variable, but the shell's rc-file is+not executed by default, and so aliases are not useable. If you need access to+your rc-file, then define a custom t`ShellConfig` for your project.+-}+module Configuration.Cfv.Shell +  (CommandError(..)+  ,CommandProblem(..)+  ,ShellConfig(..)+  ,evalValue+  ,defaultShellConfig+  ,describeProblem) where++import qualified GHC.IO.Exception as E+import Data.Text.Encoding.Error (UnicodeException, lenientDecode)+import GHC.IO.Exception (ExitCode(..))+import qualified Data.ByteString as BS+import System.Process (CreateProcess(..), waitForProcess, proc, createProcess, StdStream (..))+import GHC.IO.Handle (Handle, hClose)+import Control.Concurrent (MVar, newEmptyMVar, forkIO, putMVar, takeMVar)+import qualified Control.Exception as E+import System.Environment (lookupEnv)+import qualified Data.Text.Encoding as TE+import Control.Exception (try)+import Data.Text (Text)+import qualified Data.Text as DT++-- | A backtick command that could not produce a value.+--+-- For a @Configuration.Cfv.AST.Secret@ variable anything that could contain or reveal the secret is+-- withheld: the command text (which may embed it) and the details of the+-- command's output (see 'CommandProblem').+data CommandError = CommandError+  { cmdCommand  :: Text    +  , cmdProblem  :: CommandProblem+  } deriving (Eq, Show)++-- | Errors produced by running a command on the shell.+data CommandProblem+  = NoShell+    -- ^ No shell configured and @SHELL@ is unset or empty.+  | ShellLaunchFailed FilePath E.IOException+    -- ^ The shell could not be started (not found, not executable, ...).+  | NonZeroExit Int Text+    -- ^ The command exited with this status (negative: killed by that signal)+    -- and this standard error output, trimmed. The output is 'Nothing' for a+    -- '@Configuration.Cfv.AST.Secret' variable.+  | OutputNotUtf8 UnicodeException+    -- ^ The command's standard output is not valid UTF-8. The exception names+    -- the offending byte, so it is 'Nothing' for a '@Configuration.Cfv.AST.Secret' variable.+  deriving (Eq, Show)++-- | Shell configuration.+data ShellConfig = ShellConfig+  { shellProgram :: Maybe FilePath+    -- ^ The shell to use. 'Nothing' means the @SHELL@ environment variable.+  , shellFlags   :: [String]+    -- ^ Arguments placed before the command text, which is passed last.+  } deriving (Eq, Show)++-- | Use @$SHELL@ as a login shell: @$SHELL -l -c cmd@. A login shell picks up+-- the user's profile (@PATH@ additions and so on). Add @"-i"@ to the flags if+-- you also need what the shell's interactive rc file sets up (aliases,+-- functions).+defaultShellConfig :: ShellConfig+defaultShellConfig = ShellConfig {shellProgram = Nothing, shellFlags = ["-l", "-c"]}++-- | Evaluate a shell command reading in its @System.IO.stdout@. +evalValue :: ShellConfig -- ^ Shell configuration+          -> Text        -- ^ Command to execute+          -> IO (Either CommandError [Char])+evalValue shellCfg cmd +  = do let failWith = Left . CommandError cmd+       mShell <- resolveShell shellCfg+       case mShell of+         Nothing -> pure (failWith NoShell)+         Just shell -> do+          result <- try $ execShell shell (shellFlags shellCfg) $ DT.unpack cmd+          pure $ case result of+                    Left e -> failWith (ShellLaunchFailed shell e)+                    Right (ExitFailure n, _, err) ->+                      failWith . NonZeroExit n . DT.strip $ TE.decodeUtf8With lenientDecode err+                    Right (ExitSuccess, out, _) +                      -> case TE.decodeUtf8' out of+                            Left e  -> failWith (OutputNotUtf8 e)+                            Right t -> Right (DT.unpack (DT.dropWhileEnd (== '\n') t))++-- | Figure out the shell's path.+resolveShell :: ShellConfig -> IO (Maybe FilePath)+resolveShell cfg = case shellProgram cfg of+  Just p  -> pure (Just p)+  Nothing -> do+    v <- lookupEnv "SHELL"+    pure $ case v of+      Just s | not (null s) -> Just s+      _                     -> Nothing          ++-- | Run @shell flags... cmd@ with stdin closed, collecting stdout and stderr as+-- raw bytes; to prevent deadlock we read both in concurrently.+execShell :: FilePath -> [String] -> String -> IO (ExitCode, BS.ByteString, BS.ByteString)+execShell shell flags cmd = do+  (mIn, mOut, mErr, ph) <-+    createProcess+      (proc shell (flags ++ [cmd]))+        { std_in = CreatePipe, std_out = CreatePipe, std_err = CreatePipe }+  case (mIn, mOut, mErr) of+    (Just hin, Just hout, Just herr) -> do+      hClose hin+      outV <- readAll hout+      errV <- readAll herr+      code <- waitForProcess ph+      (,,) code <$> takeMVar outV <*> takeMVar errV+    _ -> E.throwIO (userError "could not create pipes for the shell process")++-- | Read a handle until there is no more output to consume.+readAll :: Handle -> IO (MVar BS.ByteString)+readAll h = do+  v <- newEmptyMVar+  _ <- forkIO $ (BS.hGetContents h `E.catch` ignoreIO) >>= putMVar v+  pure v+  where+    ignoreIO :: E.IOException -> IO BS.ByteString+    ignoreIO _ = pure BS.empty++-- | Converts a command problem into a 'Text'.+describeProblem :: CommandProblem -> Text+describeProblem NoShell =+  "cannot run command: no shell configured and $SHELL is not set"+describeProblem (ShellLaunchFailed sh e) =+  "cannot start shell " <> DT.show sh <> ": " <> DT.show e+describeProblem (NonZeroExit n err) =+  "command exited with status " <> DT.show n <> (if DT.null err then "" else ": " <> err)+describeProblem (OutputNotUtf8 e) =+  "command output is not valid UTF-8: " <> DT.show e
+ test/CfvSpec.hs view
@@ -0,0 +1,90 @@+{-# OPTIONS_GHC -Wno-incomplete-uni-patterns #-}+{-|+Module      : CfvSpec+Description : Spec for Cfv+Copyright   : (c) Harley Eades, 2026+Maintainer  : harley.eades@gmail.com++-}+module CfvSpec (spec) where++import Test.Hspec ( Spec, runIO, shouldBe, it, SpecWith, describe )++import Configuration.Cfv.AST ( Var(..), VarAttr(..) )+import Configuration.Cfv.Parser+    ( parseCfvFile,+      parseEvalCfv,+      CfvError(FileSyntaxError),+      SyntaxError(..),+      Position(..),+      ErrorKind(..),+      Item(..), renderCfvError )++import System.IO (stderr)+import Data.Text.IO (hPutStrLn)++spec :: Spec+spec = do +    describe "Parser Tests:" $ do+        testParseBasicCfv+        testParseNoSemiColon+        testParseNoSemiColonMultiline+        testParseSecret+    describe "Secret Tests:" $ do+        testShowSecret++testParseBasicCfv :: SpecWith ()+testParseBasicCfv = do+    r <- runIO $ parseCfvFile "test/files/basic.cfv"    +    either +        (\err -> do runIO . hPutStrLn stderr . renderCfvError $ err+                    it "parseBasicCfv" False) +        (\_ -> it "parseBasicCfv" True) +        r    ++testParseNoSemiColon :: SpecWith ()+testParseNoSemiColon = do+    let file = "test/files/noSemiColon.cfv" +    let testConfig = "exampleVar = 'example value'"+    r <- runIO $ parseEvalCfv file testConfig+    it "parseNoSemiColon" $ r `shouldBe` (Left . FileSyntaxError $ SyntaxError +        { syntaxErrorPosition = (Position +                                { posFile = file+                                , posLine = 1+                                , posColumn = 29+                                , posOffset = 28 })+        , syntaxErrorKind = UnexpectedInput (Just EndOfFile) [Literal ";"]+        , syntaxErrorSourceLine = Just testConfig+        })++testParseNoSemiColonMultiline :: SpecWith ()+testParseNoSemiColonMultiline = do+    let file = "test/files/noSemiColonMultiLine.cfv" +    let testConfig = "exampleVar1 = 'example value 1';\nexampleVar2 = 'example value 2'\nexampleVar3 = 'example value 3';"+    r <- runIO $ parseEvalCfv file testConfig    +    it "parseNoSemiColonMultiLine" $ r `shouldBe` (Left . FileSyntaxError $ SyntaxError +        { syntaxErrorPosition = (Position +                                { posFile = file+                                , posLine = 3+                                , posColumn = 1+                                , posOffset = 65 })+        , syntaxErrorKind = UnexpectedInput (Just (Literal "e")) [Literal ";"]+        , syntaxErrorSourceLine = Just "exampleVar3 = 'example value 3';"+        })++testParseSecret :: SpecWith ()+testParseSecret = do+    let file = "test/files/secret.cfv" +    let testConfig = "secret exampleVar = \"secret value\";"+    r <- runIO $ parseEvalCfv file testConfig    +    it "testParseSecret" $ r `shouldBe` Right [("exampleVar", Var Secret "secret value")]++testShowSecret :: SpecWith ()+testShowSecret = do+    let file = "test/files/secret.cfv" +    let testConfig = "secret exampleVar = \"secret value\";"+    r <- runIO $ parseEvalCfv file testConfig    +    it "testParseSecret" $ do +        r `shouldBe` Right [("exampleVar", Var Secret "secret value")]+        let (Right r') = r+        (show r') `shouldBe` ("[(\"exampleVar\",Secret ())]")
+ test/Spec.hs view
@@ -0,0 +1,1 @@+{-# OPTIONS_GHC -F -pgmF hspec-discover #-}
+ test/files/basic.cfv view
@@ -0,0 +1,6 @@+-- Basic set of config variables.+color="red";+age='42';+language="en";+-- Get the current date from the shell.+current_date=`date`;