packages feed

configurator-pg 0.1.0.6 → 0.2.0

raw patch · 16 files changed

+167/−116 lines, 16 filesdep +megaparsecdep −attoparsecPVP ok

version bump matches the API change (PVP)

Dependencies added: megaparsec

Dependencies removed: attoparsec

API changes (from Hackage documentation)

Files

CHANGELOG.md view
@@ -1,5 +1,12 @@ # Revision history for configurator-pg +## 0.2.0 -- 2020-03-10++* Rewrite file parser with Megaparsec instead of+  Attoparsec for better error messages. No expected+  change in functionality except possibly in+  pathological cases.+ ## 0.1.0.6 -- 2020-03-08  * Fix GHC 7.10 build by depending on package fail.
README.md view
@@ -18,7 +18,7 @@       to supply explicit value parsers.     - There's only `load` to read and evaluate a configuration file,       and `runParser` to extract configuration values.-  * Simpler error handling.+  * Simpler error handling and improved logging of parse errors.   * Simplified handling of configuration subsets. There's `subassocs`     and the unit tests pass, but the author didn't attempt to     understand the original implementation fully.@@ -28,9 +28,11 @@ The original configurator-ng is due to MailRank, Inc., Bryan O'Sullivan and Leon P Smith. -The low-level parser (Data.Configurator.Syntax) is mostly unchanged,-evaluation (Data.Configurator.Load) is also close to the original.-The high-level parser (Data.Configurator.Parser) is original.+The low-level parser (Data.Configurator.Syntax) was initially+mostly unchanged, but has since been rewritten with Megaparsec+for better error messages. The evaluation (Data.Configurator.Load)+is still close to the original. The high-level parser+(Data.Configurator.Parser) is original.  ## File format 
configurator-pg.cabal view
@@ -1,6 +1,6 @@ cabal-version:       1.12 name:                configurator-pg-version:             0.1.0.6+version:             0.2.0 synopsis:            Reduced parser for configurator-ng config files description:   This module provides a simplified and updated interface to the@@ -33,7 +33,7 @@                        Data.Configurator.Syntax                        Data.Configurator.Types   build-depends:       base                 >= 4.8.2 && < 4.14-                     , attoparsec           >= 0.13.1 && < 0.14+                     , megaparsec           >= 7.0.0 && < 8.1                      , containers           >= 0.5.6.2 && < 0.7                      , protolude            >= 0.1.10 && < 0.3                      , scientific           >= 0.3.4.9 && < 0.4
src/Data/Configurator/Load.hs view
@@ -5,7 +5,7 @@ import Protolude  import           Control.Exception                (throw)-import qualified Data.Attoparsec.Text             as A+import           Text.Megaparsec                  (parse, errorBundlePretty) import qualified Data.Map.Strict                  as M import           Data.Scientific                  (toBoundedInteger,                                                    toRealFloat)@@ -32,14 +32,14 @@ loadOne :: Path -> IO [Directive] loadOne path = do   s <- readFile (T.unpack path)-  case A.parseOnly topLevel s of-    Left err         -> throw $ ParseError $ "parsing " <> path <> ": " <> T.pack err+  case parse topLevel (T.unpack path) s of+    Left err         -> throw $ ParseError $ T.pack $ errorBundlePretty err     Right directives -> return directives  applyDirective :: Key -> Path -> Config -> Directive -> IO Config applyDirective prefix path config directive = case directive of   Bind key (String str) -> do-    v <- interpolate prefix str config+    v <- interpolate prefix (prefix <> key) str config     return $! M.insert (prefix <> key) (String v) config   Bind key value ->     return $! M.insert (prefix <> key) value config@@ -52,11 +52,11 @@       foldM (applyDirective prefix path') config directives   DirectiveComment _ -> return config -interpolate :: Key -> Text -> Config -> IO Text-interpolate prefix s config+interpolate :: Key -> Key -> Text -> Config -> IO Text+interpolate prefix key s config   | "$" `T.isInfixOf` s =-    case A.parseOnly interp s of-      Left err   -> throw $ ParseError $ "parsing interpolation: " <> T.pack err+    case parse interp ("<" ++ T.unpack key ++ ">") s of+      Left err   -> throw $ ParseError $ T.pack $ errorBundlePretty err       Right xs -> TL.toStrict . toLazyText . mconcat <$> mapM interpret xs   | otherwise = return s @@ -78,12 +78,13 @@         case toBoundedInteger r :: Maybe Int64 of           Just n  -> return (decimal n)           Nothing -> return (realFloat (toRealFloat r :: Double))-      Just _  -> throw $ ParseError $ "variable '" <> name <> "' is not a string or number"+      Just _  -> throw $ formatErr $ "variable '" <> name <> "' is not a string or number"       Nothing -> do         var <- System.Environment.lookupEnv (T.unpack name)         case var of-          Nothing -> throw $ ParseError $ "no such variable: '" <> name <> "'"+          Nothing -> throw $ formatErr $ "no such variable: '" <> name <> "'"           Just x  -> return (fromString x)+  formatErr err = ParseError $ "<" <> key <> ">:\n" <> err <> "\n"  relativize :: Path -> Path -> Path relativize parent child
src/Data/Configurator/Syntax.hs view
@@ -1,4 +1,5 @@ {-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TypeFamilies #-}  -- | -- Module:      Data.Configurator.Syntax@@ -20,142 +21,116 @@ import Protolude hiding (First, try)  import           Control.Monad           (fail)-import           Data.Attoparsec.Text    as A-import           Data.Char               (isAlpha, isAlphaNum, isSpace)+import           Text.Megaparsec+import           Text.Megaparsec.Char+import qualified Text.Megaparsec.Char.Lexer as Lexer+import qualified Data.Char                  as Char import           Data.Configurator.Types import qualified Data.Text               as T-import qualified Data.Text.Lazy          as L-import           Data.Text.Lazy.Builder  (fromText, singleton,-                                          toLazyText) +type Parser = Parsec Void Text+ topLevel :: Parser [Directive]-topLevel = directives <* skipLWS <* endOfInput+topLevel = skipLWS *> directives <* skipLWS <* eof  directive :: Parser Directive directive =-  mconcat [-    string "import" *> skipLWS *> (Import <$> string_)-  , string "#;" *> skipHWS *> (DirectiveComment <$> directive)-  , Bind <$> try (ident <* skipLWS <* char '=' <* skipLWS) <*> value-  , Group <$> try (ident <* skipLWS <* char '{' <* skipLWS)-          <*> directives <* skipLWS <* char '}'-  ]+  choice+   [ do try (keyword "import") <* skipLWS+        Import <$> string_+   , do ident <- identifier <* skipLWS+        choice+          [ Bind ident <$> (char '=' *> skipLWS *> value)+          , Group ident <$> brackets '{' '}' directives+          ]+   , do string "#;" *> skipHWS+        DirectiveComment <$> directive+   ]  directives :: Parser [Directive]-directives = (skipLWS *> directive <* skipHWS) `sepBy`-             (satisfy $ \c -> c == '\r' || c == '\n')--data Skip = Space | Comment+directives = (directive <* skipHWS) `sepEndBy` (eol *> skipLWS) <* skipLWS  -- | Skip lines, comments, or horizontal white space. skipLWS :: Parser ()-skipLWS = loop+skipLWS = Lexer.space space1 comment empty   where-    loop = A.takeWhile isSpace >> ((comment >> loop) <|> return ())--    comment = try beginComment >> A.takeWhile (\c -> c /= '\r' && c /= '\n')--    beginComment = do-      _ <- A.char '#'-      mc <- peekChar-      case mc of-        Just ';' -> fail ""-        _        -> return ()+    beginComment = char '#' *> notFollowedBy (char ';')+    comment = try beginComment <* takeWhileP Nothing (\c -> c /= '\r' && c /= '\n')  -- | Skip comments or horizontal white space. skipHWS :: Parser ()-skipHWS = scan Space go *> pure ()-  where go Space ' '    = Just Space-        go Space '\t'   = Just Space-        go Space '#'    = Just Comment-        go Space _      = Nothing-        go Comment '\r' = Nothing-        go Comment '\n' = Nothing-        go Comment _    = Just Comment+skipHWS = Lexer.space+            (satisfy (\c -> c == ' ' || c == '\t') >> return ())+            (Lexer.skipLineComment "#")+            empty -data IdentState = First | Follow+isIdentifier :: Char -> Bool+isIdentifier c = Char.isAlphaNum c || c == '_' || c == '-' -ident :: Parser Key-ident = do-  n <- scan First go-  when (n == "import") $-    fail $ "reserved word (" ++ show n ++ ") used as identifier"-  when (T.null n) $ fail "no identifier found"-  when (T.last n == '.') $ fail "identifier must not end with a dot"-  return n+keyword :: Text -> Parser ()+keyword kw = string kw *> notFollowedBy (satisfy isAnyIdentifier)+  where+    isAnyIdentifier c = c == '.' || isIdentifier c++identifier :: Parser Key+identifier = fst <$> match (word `sepBy1` char '.')  where-  go First c =-      if isAlpha c-      then Just Follow-      else Nothing-  go Follow c =-      if isAlphaNum c || c == '_' || c == '-'-      then Just Follow-      else if c == '.'-           then Just First-           else Nothing+  word = T.cons <$> letterChar <*> takeWhileP (Just "alphanumeric character") isIdentifier  value :: Parser Value-value = mconcat [-          string "on" *> pure (Bool True)-        , string "off" *> pure (Bool False)-        , string "true" *> pure (Bool True)-        , string "false" *> pure (Bool False)+value = choice [+          Bool <$> boolean         , String <$> string_-        , Number <$> scientific+        , Number <$> Lexer.scientific         , List <$> brackets '[' ']'                    ((value <* skipLWS) `sepBy` (char ',' <* skipLWS))         ]+ where+  boolean = choice+   [ string "on" *> pure True+   , string "off" *> pure False+   , string "true" *> pure True+   , string "false" *> pure False+   ]  string_ :: Parser Text-string_ = do-  s <- char '"' *> scan False isChar <* char '"'-  if "\\" `T.isInfixOf` s-    then unescape s-    else return s+string_ = T.pack <$> str  where-  isChar True _ = Just False-  isChar _ '"'  = Nothing-  isChar _ c    = Just (c == '\\')+  str = char '"' *> manyTill charLiteral (char '"')  brackets :: Char -> Char -> Parser a -> Parser a-brackets open close p = char open *> skipLWS *> p <* char close--embed :: Parser a -> Text -> Parser a-embed p s = case parseOnly p s of-              Left err -> fail err-              Right v  -> return v+brackets open close p = between (char open *> skipLWS) (char close) p -unescape :: Text -> Parser Text-unescape = fmap (L.toStrict . toLazyText) . embed (p mempty)+charLiteral :: Parser Char+charLiteral = choice+  [ char '\\' *> parseEscape+  , anySingle+  ]  where-  p acc = do-    h <- A.takeWhile (/='\\')-    let rest = do-          let cont c = p (acc `mappend` fromText h `mappend` singleton c)-          c <- char '\\' *> satisfy (inClass "ntru\"\\")-          case c of-            'n'  -> cont '\n'-            't'  -> cont '\t'-            'r'  -> cont '\r'-            '"'  -> cont '"'-            '\\' -> cont '\\'-            _    -> cont =<< hexQuad-    done <- atEnd-    if done-      then return (acc `mappend` fromText h)-      else rest+  parseEscape = do+    c <- oneOf ("ntru\"\\" :: [Char])+    case c of+      'n'  -> pure '\n'+      't'  -> pure '\t'+      'r'  -> pure '\r'+      '"'  -> pure '"'+      '\\' -> pure '\\'+      _    -> hexQuad  hexQuad :: Parser Char hexQuad = do-  a <- embed hexadecimal =<< A.take 4+  a <- quad   if a < 0xd800 || a > 0xdfff     then return (chr a)     else do-      b <- embed hexadecimal =<< string "\\u" *> A.take 4+      b <- string "\\u" *> quad       if a <= 0xdbff && b >= 0xdc00 && b <= 0xdfff         then return $! chr (((a - 0xd800) `shiftL` 10) + (b - 0xdc00) + 0x10000)         else fail "invalid UTF-16 surrogates"+ where+  quad     = mkNum <$> count 4 (satisfy Char.isHexDigit <?> "hexadecimal digit")+  mkNum    = foldl' step 0+  step a c = a * 16 + fromIntegral (Char.digitToInt c)  -- | Parse a string interpolation spec. --@@ -165,13 +140,13 @@ interp = reverse <$> p []  where   p acc = do-    h <- Literal <$> A.takeWhile (/='$')+    h <- Literal <$> takeWhileP Nothing (/='$')     let rest = do           let cont x = p (x : h : acc)           c <- char '$' *> satisfy (\c -> c == '$' || c == '(')           case c of             '$' -> cont (Literal (T.singleton '$'))-            _   -> (cont . Interpolate) =<< A.takeWhile1 (/=')') <* char ')'+            _   -> (cont . Interpolate) =<< takeWhile1P Nothing (/=')') <* char ')'     done <- atEnd     if done       then return (h : acc)
tests/Test.hs view
@@ -1,4 +1,5 @@ {-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}  module Main where @@ -17,13 +18,28 @@  tests :: [Test] tests =-    [ testCase "read" readTest+    [ testCase "read-simple" $ readTest "simple.cfg"+    , testCase "read-pathological" $ readTest "pathological.cfg"     , testCase "load" loadTest+    , testCase "load" loadTest     , testCase "types" typesTest     , testCase "interp" interpTest     , testCase "scoped-interp" scopedInterpTest     , testCase "import" importTest     , testCase "readme" readmeTest+    -- top level parsing errors+    , testCase "quote-error" $ parseErrorTest "err-single-quote.cfg"+    -- errors with imported files+    , testCase "import-error" $ ioErrorTest "err-import.cfg"+    , testCase "import-error-2" $ parseErrorTest "err-import-2.cfg"+    -- interpolation parsing errors+    , testCase "parse-interp-error-1" $ parseErrorTest "err-parse-interp-1.cfg"+    , testCase "parse-interp-error-2" $ parseErrorTest "err-parse-interp-2.cfg"+    , testCase "parse-interp-error-3" $ parseErrorTest "err-parse-interp-3.cfg"+    , testCase "parse-interp-error-4" $ parseErrorTest "err-parse-interp-4.cfg"+    -- interpolation interpretation errors+    , testCase "interpolate-error-1" $ parseErrorTest "err-interpolate-1.cfg"+    , testCase "interpolate-error-2" $ parseErrorTest "err-interpolate-2.cfg"     ]  withLoad :: FilePath -> (Config -> IO ()) -> IO ()@@ -32,6 +48,9 @@ testFile :: FilePath -> FilePath testFile name = "tests" </> "resources" </> name +errorFile :: FilePath -> FilePath+errorFile name = testFile name <> ".err"+ parse :: Config -> Parser Value a -> Key -> Either Text a parse cfg p key = runParser (required key p) cfg @@ -41,8 +60,8 @@ parseSub :: Config -> Parser Value a -> Key -> Either Text [(Key, a)] parseSub cfg p prefix = runParser (subassocs prefix p) cfg -readTest :: Assertion-readTest = load (testFile "pathological.cfg") >> return ()+readTest :: FilePath -> Assertion+readTest file = load (testFile file) >> return ()  loadTest :: Assertion loadTest =@@ -204,3 +223,17 @@                 [("users.alice", "alice@example.com"), ("users.bob", "bob@example.com")]                 [("passwords.alice", "secret")]))       (runParser settingsParser cfg)++parseErrorTest :: FilePath -> Assertion+parseErrorTest file = do+  err <- readFile $ errorFile file+  (load (testFile file) >> assertFailure "expected a parse error")+    `catch` \ (ParseError err') -> do+       assertEqual "" err err'++ioErrorTest :: FilePath -> Assertion+ioErrorTest file = do+  err <- readFile $ errorFile file+  (load (testFile file) >> assertFailure "expected an IO error")+    `catch` \ (ex :: IOException) -> do+       assertEqual "" err (show ex)
+ tests/resources/err-import-2.cfg view
@@ -0,0 +1,2 @@+import "err-single-quote.cfg"+
+ tests/resources/err-import.cfg view
@@ -0,0 +1,2 @@+import "not-exist.cfg"+
+ tests/resources/err-interpolate-1.cfg view
@@ -0,0 +1,4 @@+# unknown variable+first = 1+second = 2+third = "$(three)"
+ tests/resources/err-interpolate-2.cfg view
@@ -0,0 +1,4 @@+# not a number+first = [1,2,3]+second = 2+third = "$(first)"
+ tests/resources/err-parse-interp-1.cfg view
@@ -0,0 +1,1 @@+a = "$x"
+ tests/resources/err-parse-interp-2.cfg view
@@ -0,0 +1,1 @@+b = "$$$"
+ tests/resources/err-parse-interp-3.cfg view
@@ -0,0 +1,1 @@+c = "$( let's never close this"
+ tests/resources/err-parse-interp-4.cfg view
@@ -0,0 +1,1 @@+d = "$(a second $( is probably fine)"
+ tests/resources/err-single-quote.cfg view
@@ -0,0 +1,1 @@+app.settings.external_api_secret = 'some_value'
+ tests/resources/simple.cfg view
@@ -0,0 +1,16 @@+# this is a config file that is meant to eventually cover the+# good syntax except for pathological stuff++# some identifiers++a = 1+a_snakey_word = 2+WeCanDoCamelCaseToo = 3+and-hyphens-are-allowed = 4+numbers2 = 5++then.we.can.add.dots = 6++# while plain import is disallowed, we can use it otherwise+importance = 7+import.this.name = 8