settei-kdl 0.1.0.0 → 0.2.0.0
raw patch · 5 files changed
+200/−18 lines, 5 filesdep +scientificdep ~setteidep ~settei-kdlPVP ok
version bump matches the API change (PVP)
Dependencies added: scientific
Dependency ranges changed: settei, settei-kdl
API changes (from Hackage documentation)
+ Settei.Kdl: renderKdlErrorText :: KdlSourceError -> Text
+ Settei.Kdl: renderKdlErrorsText :: NonEmpty KdlSourceError -> Text
Files
- CHANGELOG.md +8/−1
- settei-kdl.cabal +5/−4
- src/Settei/Kdl.hs +68/−1
- test/Settei/KdlCharacterizationTest.hs +33/−2
- test/Settei/KdlTest.hs +86/−10
CHANGELOG.md view
@@ -1,7 +1,14 @@ # Changelog for settei-kdl -## 0.1.0.0 — 2026-07-18+## 0.2.0.0 — 2026-07-19 +- Add `renderKdlErrorText` and `renderKdlErrorsText` for stable, operator-readable,+ secret-safe adapter diagnostics, including related spans when present.++## 0.1.0.0 — 2026-07-19+ - Initial experimental release. - Add the canonical KDL v2 mapping with exact spans, deterministic cardinality, explicit ambiguity errors, and mounted-file Kubernetes annotations.+- Reject numeric values whose base-10 exponent magnitude exceeds 4096 instead of+ attempting an unbounded exact conversion at load time.
settei-kdl.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.8 name: settei-kdl-version: 0.1.0.0+version: 0.2.0.0 synopsis: KDL v2 sources for Settei description: Translate a canonical KDL v2 document into a provenance-aware Settei@@ -42,7 +42,8 @@ , containers >=0.6.8 && <0.8 , generic-lens >=2.2 && <2.4 , kdl-hs >=1.1.1 && <1.2- , settei ==0.1.0.0+ , scientific >=0.3.7 && <0.4+ , settei ==0.2.0.0 , text >=2.1 && <2.2 test-suite settei-kdl-tests@@ -60,8 +61,8 @@ , base >=4.21 && <5 , containers >=0.6.8 && <0.8 , generic-lens >=2.2 && <2.4- , settei ==0.1.0.0- , settei-kdl ==0.1.0.0+ , settei ==0.2.0.0+ , settei-kdl ==0.2.0.0 , tasty >=1.5 && <1.6 , tasty-hunit >=0.10.2 && <0.11 , text >=2.1 && <2.2
src/Settei/Kdl.hs view
@@ -27,6 +27,8 @@ kdlSpanEndLine, kdlSpanLine, readKdlSource,+ renderKdlErrorText,+ renderKdlErrorsText, withKdlSourcePath, ) where@@ -38,6 +40,7 @@ import Data.List.NonEmpty qualified as NonEmpty import Data.Map.Strict qualified as Map import Data.Maybe (listToMaybe)+import Data.Scientific (base10Exponent) import Data.Text qualified as Text import Data.Text.IO qualified as TextIO import Data.Text.Read qualified as TextRead@@ -164,6 +167,49 @@ kdlSpanEndColumn :: KdlSpan -> Int kdlSpanEndColumn value = value ^. #endColumn +-- | Render one KDL input failure as a single operator-readable line.+--+-- The line leads with the stable source name, then a parenthetical of the+-- colon-joined available pieces (path, primary span start line, start column),+-- then the structural context and fixed message. When a second trustworthy span+-- exists it is appended as @; also at LINE:COLUMN@. No raw configuration value+-- can appear because 'KdlSourceError' retains none.+renderKdlErrorText :: KdlSourceError -> Text+renderKdlErrorText problem =+ problem ^. #name+ <> locationText+ <> " at "+ <> problem ^. #context+ <> ": "+ <> problem ^. #message+ <> relatedText+ where+ locationText = case locationPieces of+ [] -> ""+ pieces -> " (" <> Text.intercalate ":" pieces <> ")"+ locationPieces =+ maybe [] (pure . Text.pack) (problem ^. #path)+ <> maybe [] spanPieces (problem ^. #location)+ spanPieces spanValue =+ [ Text.pack (show (spanValue ^. #line)),+ Text.pack (show (spanValue ^. #column))+ ]+ relatedText =+ maybe+ ""+ ( \spanValue ->+ "; also at "+ <> Text.pack (show (spanValue ^. #line))+ <> ":"+ <> Text.pack (show (spanValue ^. #column))+ )+ (problem ^. #relatedLocation)++-- | Render every KDL input failure, one line per problem, matching+-- 'Settei.Render.renderErrorsText' in shape and trailing newline.+renderKdlErrorsText :: NonEmpty KdlSourceError -> Text+renderKdlErrorsText = Text.unlines . fmap renderKdlErrorText . NonEmpty.toList+ -- | Decode one KDL v2 document into a Settei file source. -- -- Type annotations, non-finite numbers, duplicate properties, invalid key segments, and@@ -407,12 +453,33 @@ Just (child : _) <- [childGroups ^. at name] ] +-- | Largest absolute base-10 exponent accepted for a numeric value.+--+-- 'toRational' materializes @10 ^ exponent@ as an exact 'Integer', so cost+-- grows with the exponent's magnitude rather than the input's length. This+-- adapter-local bound leaves core 'RawNumber' values unbounded.+maximumScalarExponent :: Int+maximumScalarExponent = 4096+ translateValue :: KdlSourceOptions -> [Text] -> KDL.Value -> Either KdlSourceError RawValue translateValue options path value = do rejectValueAnnotation options path value case valueData value of KDL.String text -> Right (RawText text)- KDL.Number number -> Right (RawNumber (toRational number))+ KDL.Number number+ | scalarExponent < negate maximumScalarExponent || scalarExponent > maximumScalarExponent ->+ Left+ ( kdlError+ options+ KdlUnsupportedValue+ (Just (valueLocation value))+ Nothing+ path+ "numeric value exponent is out of the supported range"+ )+ | otherwise -> Right (RawNumber (toRational number))+ where+ scalarExponent = base10Exponent number KDL.Bool boolean -> Right (RawBool boolean) KDL.Null -> Right RawNull KDL.Inf -> unsupported
test/Settei/KdlCharacterizationTest.hs view
@@ -7,7 +7,7 @@ import Settei import Settei.Kdl import Settei.Prelude-import Test.Tasty (TestTree, testGroup)+import Test.Tasty (TestTree, localOption, mkTimeout, testGroup) import Test.Tasty.HUnit (assertBool, testCase, (@?=)) tests :: TestTree@@ -56,7 +56,31 @@ assertBool "syntax error omitted its column" (maybe False ((> 0) . kdlSpanColumn) (kdlErrorSpan problem)) assertBool "syntax error retained the source excerpt"- (not (sentinel `Text.isInfixOf` Text.pack (show problem)))+ (not (sentinel `Text.isInfixOf` Text.pack (show problem))),+ localOption (mkTimeout 10000000) $+ testGroup+ "bounded numeric value conversion"+ [ testCase "huge positive exponents are rejected quickly" $ do+ problem <- expectError "huge 1e1000000000\n"+ expectExponentError "$.huge" problem,+ testCase "huge negative exponents are rejected quickly" $ do+ problem <- expectError "tiny 1e-1000000000\n"+ expectExponentError "$.tiny" problem,+ testCase "positive boundary exponents convert exactly" $ do+ input <- expectSource "edge 1e4096\n"+ expectValue "edge" input (RawNumber (10 ^ (4096 :: Integer) % 1)),+ testCase "negative boundary exponents convert exactly" $ do+ input <- expectSource "edge 1e-4096\n"+ expectValue "edge" input (RawNumber (1 % 10 ^ (4096 :: Integer))),+ testCase "exponents immediately above the bound are rejected" $ do+ problem <- expectError "over 1e4097\n"+ expectExponentError "$.over" problem,+ testCase "ordinary decimals and hex values remain exact" $ do+ input <- expectSource "rate 1.5e-3\nmask 0x1A\ncoefficient 12345678901234567890\n"+ expectValue "rate" input (RawNumber (3 % 2000))+ expectValue "mask" input (RawNumber 26)+ expectValue "coefficient" input (RawNumber 12345678901234567890)+ ] ] sourceOptions :: KdlSourceOptions@@ -76,6 +100,13 @@ expectValue keyText input expected = case lookupSource (validKey keyText) input of Right (Just found) -> assertBool "unexpected KDL raw value" (candidateValue found == expected) _ -> fail "expected KDL candidate"++expectExponentError :: Text -> KdlSourceError -> IO ()+expectExponentError expectedContext problem = do+ kdlErrorCategory problem @?= KdlUnsupportedValue+ fmap kdlSpanLine (kdlErrorSpan problem) @?= Just 1+ kdlErrorContext problem @?= expectedContext+ kdlErrorMessage problem @?= "numeric value exponent is out of the supported range" validKey :: Text -> Key validKey value = either (error . show) id (parseKey value)
test/Settei/KdlTest.hs view
@@ -15,7 +15,8 @@ tests = testGroup "Settei.Kdl"- [ testCase "nested nodes become segmented keys with exact spans" $ do+ [ errorRenderingTests,+ testCase "nested nodes become segmented keys with exact spans" $ do input <- expectSource "runtime {\n environment \"production\"\n}\nservice {\n http {\n host \"0.0.0.0\"\n port 8080\n }\n}\n" host <- expectCandidate serviceHttpHost input assertBool "host did not remain text" (candidateValue host == RawText "0.0.0.0")@@ -65,14 +66,13 @@ testCase "higher KDL overrides leaves and replaces arrays through core" $ do low <- expectNamedSource "low" "service { host \"old.internal\"; port 7000; names \"one\" \"two\"; }\n" high <- expectNamedSource "high" "service { port 9000; names \"three\" \"four\"; }\n"- result <-- expectResolution- ( resolve+ let result =+ resolve defaultResolveOptions [low, high] ((,,) <$> required hostSetting <*> required portSetting <*> required namesSetting)- )- result ^. #value @?= ("old.internal", 9000, ["three", "four"])+ value <- expectResolution result+ value @?= ("old.internal", 9000, ["three", "four"]) case result ^. #report . #nodes . at servicePort of Just node -> do node ^. #origin . _Just . #name @?= "high"@@ -82,7 +82,8 @@ let reference = kubernetesRef SecretObject (Just "production") "database-config" (Just "config.kdl") options = fromKubernetesMountedFile reference sourceOptions input <- expectSourceWith options ("database { password \"" <> secretSentinel <> "\"; }\n")- result <- expectResolution (resolve defaultResolveOptions [input] (required passwordSetting))+ let result = resolve defaultResolveOptions [input] (required passwordSetting)+ _ <- expectResolution result let textOutput = renderResolutionText (result ^. #report) jsonOutput = renderResolutionJson (result ^. #report) assertBool "secret reached text output" (not (secretSentinel `Text.isInfixOf` textOutput))@@ -106,6 +107,78 @@ host ^? to candidateOrigin . #location . _Just . #path @?= Just (Text.pack fixturePath) ] +errorRenderingTests :: TestTree+errorRenderingTests =+ testGroup+ "error rendering"+ [ rendererCase+ "syntax"+ KdlSyntaxError+ "service { broken [\n"+ "application (application.kdl:1:18) at $: invalid KDL v2 syntax",+ testCase "IO errors render a stable located prefix" $ do+ result <- readKdlSource (kdlSourceOptions "missing") "test/fixtures/does-not-exist.kdl"+ case result of+ Left errors -> do+ let problem = NonEmpty.head errors+ kdlErrorCategory problem @?= KdlIoError+ assertBool+ "IO renderer omitted its stable prefix"+ ( "missing (test/fixtures/does-not-exist.kdl) at $: "+ `Text.isPrefixOf` renderKdlErrorText problem+ )+ Right _ -> fail "expected missing KDL file to fail",+ rendererCase+ "duplicate property"+ KdlDuplicateProperty+ "service port=8000 port=9000\n"+ "application (application.kdl:1:19) at $.service.port: duplicate property; also at 1:9",+ rendererCase+ "invalid name"+ KdlInvalidName+ "\"service.port\" 8080\n"+ "application (application.kdl:1:1) at $.service.port: KDL names must be non-empty Settei key segments without dots",+ rendererCase+ "unsupported annotation"+ KdlUnsupportedAnnotation+ "(record)service { port 8080 }\n"+ "application (application.kdl:1:1) at $.service: KDL node type annotations are not supported",+ rendererCase+ "unsupported value"+ KdlUnsupportedValue+ "ratio #inf\n"+ "application (application.kdl:1:7) at $.ratio: non-finite KDL numbers are not supported",+ rendererCase+ "mixed node shape"+ KdlMixedNodeShape+ "service \"api\" port=8080\n"+ "application (application.kdl:1:1) at $.service: positional arguments cannot be combined with properties or children",+ rendererCase+ "property-child collision"+ KdlPropertyChildCollision+ "service port=7000 {\n port 8000\n}\n"+ "application (application.kdl:1:9) at $.service.port: a property and child use the same field name; also at 2:3",+ testCase "a missing path omits it while retaining the primary span" $ do+ problem <-+ expectErrorWith+ (kdlSourceOptions "memory")+ "service \"api\" port=8080\n"+ assertBool+ "position-only KDL renderer omitted its location"+ ("memory (1:1)" `Text.isPrefixOf` renderKdlErrorText problem),+ testCase "plural rendering is singular rendering plus a newline" $ do+ problem <- expectError "service \"api\" port=8080\n"+ renderKdlErrorsText (NonEmpty.singleton problem)+ @?= renderKdlErrorText problem <> "\n"+ ]++rendererCase :: String -> KdlErrorCategory -> Text -> Text -> TestTree+rendererCase label expectedCategory input expected =+ testCase label $ do+ problem <- expectError input+ kdlErrorCategory problem @?= expectedCategory+ renderKdlErrorText problem @?= expected+ sourceOptions :: KdlSourceOptions sourceOptions = withKdlSourcePath "application.kdl" (kdlSourceOptions "application") @@ -122,7 +195,10 @@ Right sourceValue -> pure sourceValue expectError :: Text -> IO KdlSourceError-expectError input = case decodeKdlSource sourceOptions input of+expectError = expectErrorWith sourceOptions++expectErrorWith :: KdlSourceOptions -> Text -> IO KdlSourceError+expectErrorWith options input = case decodeKdlSource options input of Left errors -> pure (NonEmpty.head errors) Right _ -> fail "expected KDL decoding to fail" @@ -136,8 +212,8 @@ found <- expectCandidate key input assertBool "unexpected KDL raw value" (candidateValue found == expected) -expectResolution :: Either (NonEmpty ConfigError) a -> IO a-expectResolution = \case+expectResolution :: ResolveResult a -> IO a+expectResolution result = case result ^. #answer of Left errors -> fail (Text.unpack (renderErrorsText errors)) Right value -> pure value