on master help sign in
Merge pull request #146 from sol/dev

Add support for detecting duplicate keys (see #142)

commitb145da45086c0bc902cfe3f77d1be53d1298e6e5 authorMichael Snoyman <michael@snoyman.com> dateMon 20 Aug 2018, 08:55 (8 years ago) parent78453de3d29d0b
6 files changed+153−56
ChangeLog.md+4 package.yaml+3−1 src/Data/Yaml.hs+16−5 src/Data/Yaml/Include.hs+18−4 src/Data/Yaml/Internal.hs+46−46 test/Data/Yaml/IncludeSpec.hs+66
ChangeLog.md+4
@@ -1,5 +1,9 @@
11# ChangeLog for yaml
22
3+## 0.10.0
4+
5+* Add `decodeFileWithWarnings` which returns warnings for duplicate fields
6+
37## 0.9.0
48
59* Expose style and tags on mappings and sequences in Text.Libyaml [#141](https://github.com/snoyberg/yaml/pull/141)
package.yaml+3−1
@@ -1,5 +1,5 @@
11name: yaml
2−version: 0.9.0
2+version: 0.10.0
33synopsis: Support for parsing and rendering YAML documents.
44description: README and API documentation are available at <https://www.stackage.org/package/yaml>
55category: Data
@@ -49,6 +49,7 @@ dependencies:
4949- base >=4.9.1 && <5 # GHC 8.0.2 and later
5050- bytestring >=0.9.1.4
5151- transformers >=0.1
52+- mtl
5253- conduit >=1.2.8 && <1.4
5354- resourcet >=0.3 && <1.3
5455- aeson >=0.11
@@ -133,3 +134,4 @@ tests:
133134 - mockery
134135 - base-compat
135136 - temporary
137+ - raw-strings-qq
src/Data/Yaml.hs+16−5
@@ -32,6 +32,7 @@ module Data.Yaml
3232 -- * Decoding
3333 , decodeEither'
3434 , decodeFileEither
35+ , decodeFileWithWarnings
3536 , decodeThrow
3637 , decodeFileThrow
3738 -- ** More control over decoding
@@ -163,14 +164,14 @@ decode :: FromJSON a
163164 => ByteString
164165 -> Maybe a
165166decode bs = unsafePerformIO
166− $ either (const Nothing) id
167+ $ either (const Nothing) snd
167168 <$> decodeHelper_ (Y.decode bs)
168169{-# DEPRECATED decode "Please use decodeEither or decodeThrow, which provide information on how the decode failed" #-}
169170
170171decodeFile :: FromJSON a
171172 => FilePath
172173 -> IO (Maybe a)
173−decodeFile fp = decodeHelper (Y.decodeFile fp) >>= either throwIO (return . either (const Nothing) id)
174+decodeFile fp = (fmap snd <$> decodeHelper (Y.decodeFile fp)) >>= either throwIO (return . either (const Nothing) id)
174175{-# DEPRECATED decodeFile "Please use decodeFileEither, which does not confused type-directed and runtime exceptions." #-}
175176
176177-- | A version of 'decodeFile' which should not throw runtime exceptions.
@@ -180,12 +181,22 @@ decodeFileEither
180181 :: FromJSON a
181182 => FilePath
182183 -> IO (Either ParseException a)
183−decodeFileEither = decodeHelper_ . Y.decodeFile
184+decodeFileEither = fmap (fmap snd) . decodeFileWithWarnings
185+
186+-- | A version of `decodeFileEither` that returns warnings along with the parse
187+-- result.
188+--
189+-- @since 0.10.0
190+decodeFileWithWarnings
191+ :: FromJSON a
192+ => FilePath
193+ -> IO (Either ParseException ([Warning], a))
194+decodeFileWithWarnings = decodeHelper_ . Y.decodeFile
184195
185196decodeEither :: FromJSON a => ByteString -> Either String a
186197decodeEither bs = unsafePerformIO
187198 $ either (Left . prettyPrintParseException) id
188− <$> decodeHelper (Y.decode bs)
199+ <$> (fmap snd <$> decodeHelper (Y.decode bs))
189200{-# DEPRECATED decodeEither "Please use decodeEither' or decodeThrow, which provide more useful failures" #-}
190201
191202-- | More helpful version of 'decodeEither' which returns the 'YamlException'.
@@ -194,7 +205,7 @@ decodeEither bs = unsafePerformIO
194205decodeEither' :: FromJSON a => ByteString -> Either ParseException a
195206decodeEither' = either Left (either (Left . AesonException) Right)
196207 . unsafePerformIO
197− . decodeHelper
208+ . fmap (fmap snd) . decodeHelper
198209 . Y.decode
199210
200211-- | A version of 'decodeEither'' lifted to MonadThrow
src/Data/Yaml/Include.hs+18−4
@@ -1,6 +1,10 @@
11{-# LANGUAGE CPP #-}
22{-# LANGUAGE RankNTypes #-}
3−module Data.Yaml.Include (decodeFile, decodeFileEither) where
3+module Data.Yaml.Include (
4+ decodeFile
5+, decodeFileEither
6+, decodeFileWithWarnings
7+) where
48
59#if !MIN_VERSION_directory(1, 2, 3)
610import Control.Exception (handleJust)
@@ -20,7 +24,7 @@ import Data.Text.Encoding (decodeUtf8)
2024import System.Directory
2125import System.FilePath
2226
23−import Data.Yaml.Internal (ParseException(..), decodeHelper_, decodeHelper)
27+import Data.Yaml.Internal (ParseException(..), Warning(..), decodeHelper_, decodeHelper)
2428import Text.Libyaml hiding (decodeFile)
2529import qualified Text.Libyaml as Y
2630
@@ -65,7 +69,7 @@ decodeFile
6569 :: FromJSON a
6670 => FilePath
6771 -> IO (Maybe a)
68−decodeFile fp = decodeHelper (eventsFromFile fp) >>= either throwIO (return . either (const Nothing) id)
72+decodeFile fp = (fmap snd <$> decodeHelper (eventsFromFile fp)) >>= either throwIO (return . either (const Nothing) id)
6973
7074-- | Like `Data.Yaml.decodeFileEither` but with support for relative and
7175-- absolute includes.
@@ -77,4 +81,14 @@ decodeFileEither
7781 :: FromJSON a
7882 => FilePath
7983 -> IO (Either ParseException a)
80−decodeFileEither = decodeHelper_ . eventsFromFile
84+decodeFileEither = fmap (fmap snd) . decodeFileWithWarnings
85+
86+-- | A version of `decodeFileEither` that returns warnings along with the parse
87+-- result.
88+--
89+-- @since 0.10.0
90+decodeFileWithWarnings
91+ :: FromJSON a
92+ => FilePath
93+ -> IO (Either ParseException ([Warning], a))
94+decodeFileWithWarnings = decodeHelper_ . eventsFromFile
src/Data/Yaml/Internal.hs+46−46
@@ -6,6 +6,7 @@ module Data.Yaml.Internal
66 (
77 ParseException(..)
88 , prettyPrintParseException
9+ , Warning(..)
910 , parse
1011 , decodeHelper
1112 , decodeHelper_
@@ -19,23 +20,25 @@ import Control.Applicative ((<$>), Applicative(..))
1920#endif
2021import Control.Applicative ((<|>))
2122import Control.Exception
22−import Control.Monad (liftM, ap, unless)
23−import Control.Monad.IO.Class (MonadIO, liftIO)
24−import Control.Monad.Trans.Class (MonadTrans, lift)
23+import Control.Monad (when, unless)
2524import Control.Monad.Trans.Resource (ResourceT, runResourceT)
26−import Control.Monad.Trans.State
25+import Control.Monad.RWS
2726import Data.Aeson
27+import Data.Aeson.Internal (JSONPath, JSONPathElement(..))
2828import Data.Aeson.Types hiding (parse)
2929import qualified Data.Attoparsec.Text as Atto
3030import Data.Bits (shiftL, (.|.))
3131import Data.ByteString (ByteString)
3232import Data.Char (toUpper, ord)
33+import Data.List
3334import Data.Conduit ((.|), ConduitM, runConduit)
3435import qualified Data.Conduit.List as CL
3536import qualified Data.HashMap.Strict as M
3637import qualified Data.HashSet as HashSet
3738import Data.Map (Map)
3839import qualified Data.Map as Map
40+import Data.Set (Set)
41+import qualified Data.Set as Set
3942import Data.Scientific (Scientific)
4043import Data.Text (Text, pack)
4144import qualified Data.Text as T
@@ -109,31 +112,19 @@ prettyPrintParseException pe = case pe of
109112 ]
110113 CyclicIncludes -> "Cyclic includes"
111114
112−newtype PErrorT m a = PErrorT { runPErrorT :: m (Either ParseException a) }
113−instance Monad m => Functor (PErrorT m) where
114− fmap = liftM
115−instance Monad m => Applicative (PErrorT m) where
116− pure = PErrorT . return . Right
117− (<*>) = ap
118−instance Monad m => Monad (PErrorT m) where
119− return = pure
120− (PErrorT m) >>= f = PErrorT $ do
121− e <- m
122− case e of
123− Left e' -> return $ Left e'
124− Right a -> runPErrorT $ f a
125−instance MonadTrans PErrorT where
126− lift = PErrorT . liftM Right
127−instance MonadIO m => MonadIO (PErrorT m) where
128− liftIO = lift . liftIO
129−
130115defineAnchor :: Value -> String -> ConduitM e o Parse ()
131−defineAnchor value name = lift $ modify $ Map.insert name value
116+defineAnchor value name = modify $ Map.insert name value
132117
133118lookupAnchor :: String -> ConduitM e o Parse (Maybe Value)
134−lookupAnchor name = lift $ gets (Map.lookup name)
119+lookupAnchor name = gets (Map.lookup name)
120+
121+data Warning = DuplicateKey JSONPath
122+ deriving (Eq, Show)
135123
136−type Parse = StateT (Map String Value) (ResourceT IO)
124+addWarning :: Warning -> ConduitM e o Parse ()
125+addWarning = tell . return
126+
127+type Parse = RWST JSONPath [Warning] (Map String Value) (ResourceT IO)
137128
138129requireEvent :: Event -> ConduitM Event o Parse ()
139130requireEvent e = do
@@ -199,8 +190,8 @@ parseO = do
199190 me <- CL.head
200191 case me of
201192 Just (EventScalar v tag style a) -> textToValue style tag <$> parseScalar v a style tag
202− Just (EventSequenceStart _ _ a) -> parseS a id
203− Just (EventMappingStart _ _ a) -> parseM a M.empty
193+ Just (EventSequenceStart _ _ a) -> parseS 0 a id
194+ Just (EventMappingStart _ _ a) -> parseM mempty a M.empty
204195 Just (EventAlias an) -> do
205196 m <- lookupAnchor an
206197 case m of
@@ -208,10 +199,11 @@ parseO = do
208199 Just v -> return v
209200 _ -> liftIO $ throwIO $ UnexpectedEvent me Nothing
210201
211−parseS :: Y.Anchor
202+parseS :: Int
203+ -> Y.Anchor
212204 -> ([Value] -> [Value])
213205 -> ConduitM Event o Parse Value
214−parseS a front = do
206+parseS n a front = do
215207 me <- CL.peek
216208 case me of
217209 Just EventSequenceEnd -> do
@@ -220,13 +212,14 @@ parseS a front = do
220212 mapM_ (defineAnchor res) a
221213 return res
222214 _ -> do
223− o <- parseO
224− parseS a $ front . (:) o
215+ o <- local (Index n :) parseO
216+ parseS (succ n) a $ front . (:) o
225217
226−parseM :: Y.Anchor
218+parseM :: Set Text
219+ -> Y.Anchor
227220 -> M.HashMap Text Value
228221 -> ConduitM Event o Parse Value
229−parseM a front = do
222+parseM mergedKeys a front = do
230223 me <- CL.head
231224 case me of
232225 Just EventMappingEnd -> do
@@ -243,48 +236,55 @@ parseM a front = do
243236 Just (String t) -> return t
244237 Just v -> liftIO $ throwIO $ NonStringKeyAlias an v
245238 _ -> liftIO $ throwIO $ UnexpectedEvent me Nothing
246− o <- parseO
247239
248− let al = M.insert s o front
249− al' = if s == pack "<<"
240+ (mergedKeys', al') <- local (Key s :) $ do
241+ o <- parseO
242+ let al = do
243+ when (M.member s front && Set.notMember s mergedKeys) $ do
244+ path <- reverse <$> ask
245+ addWarning (DuplicateKey path)
246+ return (Set.delete s mergedKeys, M.insert s o front)
247+ if s == pack "<<"
250248 then case o of
251− Object l -> M.union front l
252− Array l -> M.union front $ foldl mergeObjects M.empty $ V.toList l
249+ Object l -> return (merge l)
250+ Array l -> return $ merge $ foldl mergeObjects M.empty $ V.toList l
253251 _ -> al
254252 else al
255− parseM a al'
253+ parseM mergedKeys' a al'
256254 where mergeObjects al (Object om) = M.union al om
257255 mergeObjects al _ = al
258256
257+ merge xs = (Set.fromList (M.keys xs \\ M.keys front), M.union front xs)
258+
259259decodeHelper :: FromJSON a
260260 => ConduitM () Y.Event Parse ()
261− -> IO (Either ParseException (Either String a))
261+ -> IO (Either ParseException ([Warning], Either String a))
262262decodeHelper src = do
263263 -- This used to be tryAny, but the fact is that catching async
264264 -- exceptions is fine here. We'll rethrow them immediately in the
265265 -- otherwise clause.
266− x <- try $ runResourceT $ flip evalStateT Map.empty $ runConduit $ src .| parse
266+ x <- try $ runResourceT $ evalRWST (runConduit $ src .| parse) [] Map.empty
267267 case x of
268268 Left e
269269 | Just pe <- fromException e -> return $ Left pe
270270 | Just ye <- fromException e -> return $ Left $ InvalidYaml $ Just (ye :: YamlException)
271271 | otherwise -> throwIO e
272− Right y -> return $ Right $ parseEither parseJSON y
272+ Right (y, warnings) -> return $ Right (warnings, parseEither parseJSON y)
273273
274274decodeHelper_ :: FromJSON a
275275 => ConduitM () Event Parse ()
276− -> IO (Either ParseException a)
276+ -> IO (Either ParseException ([Warning], a))
277277decodeHelper_ src = do
278− x <- try $ runResourceT $ flip evalStateT Map.empty $ runConduit $ src .| parse
278+ x <- try $ runResourceT $ evalRWST (runConduit $ src .| parse) [] Map.empty
279279 return $ case x of
280280 Left e
281281 | Just pe <- fromException e -> Left pe
282282 | Just ye <- fromException e -> Left $ InvalidYaml $ Just (ye :: YamlException)
283283 | otherwise -> Left $ OtherParseException e
284− Right y -> either
284+ Right (y, warnings) -> either
285285 (Left . AesonException)
286286 Right
287− (parseEither parseJSON y)
287+ ((,) warnings <$> parseEither parseJSON y)
288288
289289-- | Strings which must be escaped so as not to be treated as non-string scalars.
290290specialStrings :: HashSet.HashSet Text
test/Data/Yaml/IncludeSpec.hs+66
@@ -1,12 +1,18 @@
1+{-# LANGUAGE QuasiQuotes #-}
12{-# LANGUAGE OverloadedStrings #-}
23module Data.Yaml.IncludeSpec (main, spec) where
34
45import Test.Hspec
56import Data.List (isPrefixOf)
67import Data.Aeson
8+import Data.Aeson.Internal (JSONPathElement(..))
79import Data.Yaml (ParseException(InvalidYaml))
810import Data.Yaml.Include
11+import Data.Yaml.Internal
912import Text.Libyaml (YamlException(YamlException))
13+import Test.Mockery.Directory
14+import Text.RawString.QQ
15+import Data.Yaml.TH (yamlQQ)
1016
1117main :: IO ()
1218main = hspec spec
@@ -52,6 +58,66 @@ spec = do
5258 (decodeFileEither "./does_not_exist.yaml" :: IO (Either ParseException Value)) >>=
5359 (`shouldSatisfy` either isYamlFileNotFoundException (const False))
5460
61+ describe "decodeFileWithWarnings" $ around_ inTempDirectory $ do
62+ it "warns on duplicate keys" $ do
63+ writeFile "foo.yaml" [r|
64+ foo: 23
65+ foo: bar
66+ |]
67+ Right result <- decodeFileWithWarnings "foo.yaml"
68+ result `shouldBe` ([DuplicateKey [Key "foo"]], [yamlQQ|
69+ foo: bar
70+ |])
71+
72+ it "warns on nested duplicate keys" $ do
73+ writeFile "foo.yaml" [r|
74+ foo:
75+ - 42
76+ - bar: 23
77+ bar: baz
78+ |]
79+ Right result <- decodeFileWithWarnings "foo.yaml"
80+ result `shouldBe` ([DuplicateKey [Key "foo", Index 1, Key "bar"]], [yamlQQ|
81+ foo:
82+ - 42
83+ - bar: baz
84+ |])
85+
86+ context "when overriding a merged key" $ do
87+ it "does not warn" $ do
88+ writeFile "foo.yaml" [r|
89+ foo-1: &my-ref
90+ bar: 23
91+ foo-2:
92+ <<: *my-ref
93+ bar: 42
94+ |]
95+ Right result <- decodeFileWithWarnings "foo.yaml"
96+ result `shouldBe` ([], [yamlQQ|
97+ foo-1:
98+ bar: 23
99+ foo-2:
100+ bar: 42
101+ |])
102+
103+ context "when overriding twice" $ do
104+ it "warns" $ do
105+ writeFile "foo.yaml" [r|
106+ foo-1: &my-ref
107+ bar: 23
108+ foo-2:
109+ <<: *my-ref
110+ bar: 42
111+ bar: 65
112+ |]
113+ Right result <- decodeFileWithWarnings "foo.yaml"
114+ result `shouldBe` ([DuplicateKey [Key "foo-2", Key "bar"]], [yamlQQ|
115+ foo-1:
116+ bar: 23
117+ foo-2:
118+ bar: 65
119+ |])
120+
55121isYamlFileNotFoundException :: ParseException -> Bool
56122isYamlFileNotFoundException (InvalidYaml (Just (YamlException msg)))
57123 | "Yaml file not found: " `isPrefixOf` msg = True