packages feed

settei-dhall 0.1.0.0 → 0.2.0.0

raw patch · 5 files changed

+242/−28 lines, 5 filesdep +lensdep +megaparsecdep −microlensdep ~setteidep ~settei-dhallPVP ok

version bump matches the API change (PVP)

Dependencies added: lens, megaparsec

Dependencies removed: microlens

Dependency ranges changed: settei, settei-dhall

API changes (from Hackage documentation)

+ Settei.Dhall: dhallErrorColumn :: DhallSourceError -> Maybe Int
+ Settei.Dhall: dhallErrorLine :: DhallSourceError -> Maybe Int
+ Settei.Dhall: renderDhallErrorText :: DhallSourceError -> Text
+ Settei.Dhall: renderDhallErrorsText :: NonEmpty DhallSourceError -> Text

Files

CHANGELOG.md view
@@ -1,8 +1,20 @@ # Changelog for settei-dhall -## 0.1.0.0 — 2026-07-18+## 0.2.0.0 — 2026-07-19 +- Use the family-standard `lens` package in test suites instead of the leftover+  `microlens` prototype dependency.+- Add `renderDhallErrorText` and `renderDhallErrorsText` for stable, operator-readable,+  secret-safe adapter diagnostics with optional source positions.++## 0.1.0.0 — 2026-07-19+ - Initial experimental release. - Add typed Dhall-to-Settei translation with NoImports and LocalImportsWithin policies. - Retain root and transitive import-closure provenance without claiming unavailable   leaf-level import attribution.+- Document that import-policy preflight and file reads are not atomic, so an+  untrusted-writable import root remains vulnerable to a time-of-check/time-of-use race.+- Add optional one-based line and column fields to `DhallSourceError`, expose them through+  `dhallErrorLine` and `dhallErrorColumn`, and populate them for root and local-import+  parse failures.
settei-dhall.cabal view
@@ -1,6 +1,6 @@ cabal-version:      3.8 name:               settei-dhall-version:            0.1.0.0+version:            0.2.0.0 synopsis:           Dhall sources for Settei description:   Evaluate typed Dhall expressions into provenance-aware Settei sources with@@ -48,8 +48,9 @@     , directory     >=1.3.8   && <1.4     , filepath      >=1.5.4   && <1.6     , generic-lens  >=2.2     && <2.4+    , megaparsec    >=9       && <10     , scientific    >=0.3.7   && <0.4-    , settei        ==0.1.0.0+    , settei        ==0.2.0.0     , text          >=2.1     && <2.2     , transformers  >=0.6.1   && <0.7 @@ -65,7 +66,7 @@     , dhall         >=1.42.3 && <1.43     , directory     >=1.3.8  && <1.4     , filepath      >=1.5.4  && <1.6-    , microlens     >=0.4.14 && <0.6+    , lens          >=5.3    && <5.4     , tasty         >=1.5    && <1.6     , tasty-hunit   >=0.10.2 && <0.11     , temporary     >=1.3    && <1.4@@ -89,9 +90,9 @@     , directory     >=1.3.8   && <1.4     , filepath      >=1.5.4   && <1.6     , generic-lens  >=2.2     && <2.4-    , microlens     >=0.4.14  && <0.6-    , settei        ==0.1.0.0-    , settei-dhall  ==0.1.0.0+    , lens          >=5.3     && <5.4+    , settei        ==0.2.0.0+    , settei-dhall  ==0.2.0.0     , tasty         >=1.5     && <1.6     , tasty-hunit   >=0.10.2  && <0.11     , temporary     >=1.3     && <1.4
src/Settei/Dhall.hs view
@@ -14,6 +14,8 @@     DhallSourceOptions,     annotateDhallSourceOptions,     dhallErrorCategory,+    dhallErrorColumn,+    dhallErrorLine,     dhallErrorMessage,     dhallErrorName,     dhallErrorPath,@@ -29,6 +31,8 @@     dhallSourceOptions,     loadDhallSource,     loadDhallSourceDetailed,+    renderDhallErrorText,+    renderDhallErrorsText,   ) where @@ -58,6 +62,7 @@ import Settei.Prelude import System.Directory qualified as Directory import System.FilePath qualified as FilePath+import Text.Megaparsec qualified as Megaparsec  -- | Import capabilities available to version-one callers. --@@ -129,12 +134,15 @@  -- | A secret-safe Dhall input failure. ----- The error retains only a stable category, source name, optional safe path,--- and fixed message. It never retains source text or an upstream exception.+-- The error retains only a stable category, source name, optional safe path, optional+-- one-based position, and fixed message. It never retains source text or an upstream+-- exception. data DhallSourceError = DhallSourceError   { category :: !DhallErrorCategory,     name :: !Text,     path :: !(Maybe FilePath),+    line :: !(Maybe Int),+    column :: !(Maybe Int),     message :: !Text   }   deriving stock (Generic, Eq, Show)@@ -150,6 +158,8 @@ data PreflightFailure = PreflightFailure   { category :: !DhallErrorCategory,     path :: !(Maybe FilePath),+    line :: !(Maybe Int),+    column :: !(Maybe Int),     message :: !Text   }   deriving stock (Generic)@@ -187,10 +197,44 @@ dhallErrorPath :: DhallSourceError -> Maybe FilePath dhallErrorPath problem = problem ^. #path +-- | Return the one-based line associated with an error, when known.+dhallErrorLine :: DhallSourceError -> Maybe Int+dhallErrorLine problem = problem ^. #line++-- | Return the one-based column associated with an error, when known.+dhallErrorColumn :: DhallSourceError -> Maybe Int+dhallErrorColumn problem = problem ^. #column+ -- | Return a fixed secret-safe error message. dhallErrorMessage :: DhallSourceError -> Text dhallErrorMessage problem = problem ^. #message +-- | Render one Dhall input failure as a single operator-readable line.+--+-- The line leads with the stable source name, then a parenthetical containing+-- the colon-joined available path, line, and column, followed by the fixed+-- message. The parenthetical is omitted when no location is known. No raw+-- configuration value can appear because 'DhallSourceError' retains none.+renderDhallErrorText :: DhallSourceError -> Text+renderDhallErrorText problem =+  problem ^. #name+    <> locationText+    <> ": "+    <> problem ^. #message+  where+    locationText = case locationPieces of+      [] -> ""+      pieces -> " (" <> Text.intercalate ":" pieces <> ")"+    locationPieces =+      maybe [] (pure . Text.pack) (problem ^. #path)+        <> maybe [] (pure . Text.pack . show) (problem ^. #line)+        <> maybe [] (pure . Text.pack . show) (problem ^. #column)++-- | Render every Dhall input failure, one line per problem, matching+-- 'Settei.Render.renderErrorsText' in shape and trailing newline.+renderDhallErrorsText :: NonEmpty DhallSourceError -> Text+renderDhallErrorsText = Text.unlines . fmap renderDhallErrorText . NonEmpty.toList+ -- | Return a local import's canonical path. dhallImportPath :: DhallImport -> FilePath dhallImportPath imported = imported ^. #path@@ -234,7 +278,17 @@     Left problem -> pure (Left (NonEmpty.singleton problem))     Right prepared ->       case DhallParser.exprFromText (Text.unpack (prepared ^. #label)) (prepared ^. #input) of-        Left _ -> pure (failure options DhallParseError (prepared ^. #filePath) "invalid Dhall syntax")+        Left parseError ->+          let (line, column) = parseErrorPosition parseError+           in pure+                ( positionedFailure+                    options+                    DhallParseError+                    (prepared ^. #filePath)+                    line+                    column+                    "invalid Dhall syntax"+                )         Right expression -> do           resolvedResult <- resolveExpression options prepared expression           pure $ do@@ -320,10 +374,12 @@         case preflightResult of           Left problem ->             pure-              ( failure+              ( positionedFailure                   options                   (problem ^. #category)                   (problem ^. #path)+                  (problem ^. #line)+                  (problem ^. #column)                   (problem ^. #message)               )           Right imports -> do@@ -397,7 +453,14 @@             Left _ -> throwPreflight DhallImportError (Just canonical) "cannot read local import"             Right value -> pure value           imported <- case DhallParser.exprFromText canonical input of-            Left _ -> throwPreflight DhallParseError (Just canonical) "invalid syntax in local import"+            Left parseError ->+              let (line, column) = parseErrorPosition parseError+               in throwPositionedPreflight+                    DhallParseError+                    (Just canonical)+                    line+                    column+                    "invalid syntax in local import"             Right value -> pure value           visitExpression             allowedRoot@@ -497,8 +560,18 @@ importModeName DhallLocationImport = "location"  throwPreflight :: DhallErrorCategory -> Maybe FilePath -> Text -> ExceptT PreflightFailure IO a-throwPreflight category path message = throwE PreflightFailure {category, path, message}+throwPreflight category path = throwPositionedPreflight category path Nothing Nothing +throwPositionedPreflight ::+  DhallErrorCategory ->+  Maybe FilePath ->+  Maybe Int ->+  Maybe Int ->+  Text ->+  ExceptT PreflightFailure IO a+throwPositionedPreflight category path line column message =+  throwE PreflightFailure {category, path, line, column, message}+ isWithin :: FilePath -> FilePath -> Bool isWithin root pathCandidate =   let relative = FilePath.makeRelative root pathCandidate@@ -508,11 +581,42 @@           _ -> True  failure :: DhallSourceOptions -> DhallErrorCategory -> Maybe FilePath -> Text -> Either (NonEmpty DhallSourceError) a-failure options category path message = Left (NonEmpty.singleton (singleError options category path message))+failure options category path = positionedFailure options category path Nothing Nothing +positionedFailure ::+  DhallSourceOptions ->+  DhallErrorCategory ->+  Maybe FilePath ->+  Maybe Int ->+  Maybe Int ->+  Text ->+  Either (NonEmpty DhallSourceError) a+positionedFailure options category path line column message =+  Left (NonEmpty.singleton (positionedError options category path line column message))+ singleError :: DhallSourceOptions -> DhallErrorCategory -> Maybe FilePath -> Text -> DhallSourceError-singleError options category path message =-  DhallSourceError {category, name = options ^. #name, path, message}+singleError options category path = positionedError options category path Nothing Nothing++positionedError ::+  DhallSourceOptions ->+  DhallErrorCategory ->+  Maybe FilePath ->+  Maybe Int ->+  Maybe Int ->+  Text ->+  DhallSourceError+positionedError options category path line column message =+  DhallSourceError {category, name = options ^. #name, path, line, column, message}++parseErrorPosition :: DhallParser.ParseError -> (Maybe Int, Maybe Int)+parseErrorPosition parseError =+  let bundle = DhallParser.unwrap parseError+      offset = Megaparsec.errorOffset (NonEmpty.head (Megaparsec.bundleErrors bundle))+      reached = Megaparsec.reachOffsetNoLine offset (Megaparsec.bundlePosState bundle)+      position = Megaparsec.pstateSourcePos reached+   in ( Just (Megaparsec.unPos (Megaparsec.sourceLine position)),+        Just (Megaparsec.unPos (Megaparsec.sourceColumn position))+      )  firstOne ::   DhallSourceOptions ->
test/Settei/DhallPrototypeTest.hs view
@@ -3,6 +3,7 @@ module Settei.DhallPrototypeTest (tests) where  import Control.Exception (SomeException, try)+import Control.Lens qualified as Lens import Control.Monad (foldM, unless, when) import Control.Monad.IO.Class (liftIO) import Control.Monad.Trans.Except (ExceptT, runExceptT, throwE)@@ -17,7 +18,6 @@ import Dhall.Core qualified as Dhall import Dhall.Import qualified as DhallImport import Dhall.Parser qualified as DhallParser-import Lens.Micro qualified as Lens import System.Directory qualified as Directory import System.FilePath qualified as FilePath import System.IO.Temp (withSystemTempDirectory)
test/Settei/DhallTest.hs view
@@ -25,7 +25,8 @@ tests =   testGroup     "Settei.Dhall"-    [ testCase "a typed record becomes a nested source" $ do+    [ errorRenderingTests,+      testCase "a typed record becomes a nested source" $ do         loaded <-           expectDetailed             noImportOptions@@ -105,7 +106,8 @@           annotations ^. at "dhall.import-count" @?= Just "2"           annotations ^. at "dhall.provenance-precision"             @?= Just "root-and-import-closure; leaf-level import attribution unavailable after normalization"-          result <- expectResolution (resolve defaultResolveOptions [input] (required portSetting))+          let result = resolve defaultResolveOptions [input] (required portSetting)+          _ <- expectResolution result           let rendered = renderResolutionText (result ^. #report)           assertBool "text report omitted Dhall root" (Text.pack application `Text.isInfixOf` rendered)           assertBool@@ -181,14 +183,13 @@       testCase "higher Dhall sources override leaves through the core resolver" $ do         low <- expectSource (dhallSourceOptions "low" NoImports) (dhallExpression "low" "{ service = { host = \"old.internal\", port = 7000 } }")         high <- expectSource (dhallSourceOptions "high" NoImports) (dhallExpression "high" "{ service = { port = 9000 } }")-        result <--          expectResolution-            ( resolve+        let result =+              resolve                 defaultResolveOptions                 [low, high]                 ((,) <$> required hostSetting <*> required portSetting)-            )-        result ^. #value @?= ("old.internal", 9000),+        value <- expectResolution result+        value @?= ("old.internal", 9000),       testCase "stable error phases and Show output never retain source secrets" $ do         let cases =               [ (dhallExpression "parse" ("{ password = \"" <> secretSentinel <> "\", broken ="), DhallParseError),@@ -206,13 +207,40 @@                 (not (secretSentinel `Text.isInfixOf` Text.pack (show problem)))           )           cases,+      testCase "root parse errors carry exact one-based positions" $ do+        firstProblem <- expectError noImportOptions (dhallExpression "broken" "{ port =")+        dhallErrorCategory firstProblem @?= DhallParseError+        dhallErrorLine firstProblem @?= Just 1+        dhallErrorColumn firstProblem @?= Just 9+        secondProblem <-+          expectError+            noImportOptions+            (dhallExpression "broken2" "let a = 1\nin { port = }")+        dhallErrorCategory secondProblem @?= DhallParseError+        dhallErrorLine secondProblem @?= Just 2+        dhallErrorColumn secondProblem @?= Just 13+        dhallErrorMessage secondProblem @?= "invalid Dhall syntax",+      testCase "local-import parse errors retain the imported-file position" $+        withSystemTempDirectory "settei-dhall-parse-position" $ \root -> do+          let imported = root FilePath.</> "broken.dhall"+          TextIO.writeFile imported "let a = 1\nin { port = }"+          problem <-+            expectError+              (localOptions root)+              (dhallExpression "importing root" "./broken.dhall")+          dhallErrorCategory problem @?= DhallParseError+          dhallErrorPath problem @?= Just imported+          dhallErrorLine problem @?= Just 2+          dhallErrorColumn problem @?= Just 13+          dhallErrorMessage problem @?= "invalid syntax in local import",       testCase "resolved secret values are redacted while Dhall provenance remains" $ do         input <-           expectSource             noImportOptions             (dhallExpression "secret root" ("{ database = { password = \"" <> secretSentinel <> "\" } }"))-        result <- expectResolution (resolve defaultResolveOptions [input] (required passwordSetting))-        result ^. #value @?= secretSentinel+        let result = resolve defaultResolveOptions [input] (required passwordSetting)+        value <- expectResolution result+        value @?= secretSentinel         let textOutput = renderResolutionText (result ^. #report)             jsonOutput = renderResolutionJson (result ^. #report)         assertBool "secret reached text report" (not (secretSentinel `Text.isInfixOf` textOutput))@@ -220,6 +248,75 @@         assertBool "Dhall provenance was redacted with the value" ("secret root" `Text.isInfixOf` textOutput)     ] +errorRenderingTests :: TestTree+errorRenderingTests =+  testGroup+    "error rendering"+    [ testCase "IO" $ do+        problem <- expectError noImportOptions (DhallFile "test/fixtures/does-not-exist.dhall")+        dhallErrorCategory problem @?= DhallIoError+        assertBool+          "IO renderer omitted its stable located prefix"+          ("application Dhall (" `Text.isPrefixOf` renderDhallErrorText problem)+        assertBool+          "IO renderer omitted its fixed message"+          (": cannot read Dhall root file" `Text.isSuffixOf` renderDhallErrorText problem),+      rendererCase+        "parse"+        DhallParseError+        (dhallExpression "broken" "{ port =")+        "application Dhall (1:9): invalid Dhall syntax",+      rendererCase+        "import policy"+        DhallImportPolicyError+        (dhallExpression "disabled import" "env:HOME as Text")+        "application Dhall: imports are disabled",+      testCase "import resolution" $+        withSystemTempDirectory "settei-dhall-render-cycle" $ \root -> do+          TextIO.writeFile (root FilePath.</> "a.dhall") "./b.dhall"+          TextIO.writeFile (root FilePath.</> "b.dhall") "./a.dhall"+          problem <- expectError (localOptions root) (dhallExpression "cycle" "./a.dhall")+          dhallErrorCategory problem @?= DhallImportError+          renderDhallErrorText problem @?= "application Dhall: Dhall import resolution failed",+      rendererCase+        "type"+        DhallTypeError+        (dhallExpression "type" "{ broken = if True then True else 1 }")+        "application Dhall: Dhall expression does not type-check",+      rendererCase+        "conversion"+        DhallConversionError+        (dhallExpression "conversion" "{ bytes = 0x\"00\" }")+        "application Dhall: Dhall value is not JSON-compatible",+      rendererCase+        "invalid key"+        DhallInvalidKey+        (dhallExpression "key" "{ `service.port` = 8080 }")+        "application Dhall: Dhall record field is not a valid Settei key segment",+      rendererCase+        "top-level type"+        DhallTopLevelType+        (dhallExpression "top-level" "[ 1, 2 ]")+        "application Dhall: top-level Dhall value must be a record",+      testCase "a missing path and position omit the location parenthetical" $ do+        problem <- expectError noImportOptions (dhallExpression "type" "1 + True")+        dhallErrorCategory problem @?= DhallTypeError+        assertBool+          "position-free Dhall renderer invented a location"+          (not ("application Dhall (" `Text.isPrefixOf` renderDhallErrorText problem)),+      testCase "plural rendering is singular rendering plus a newline" $ do+        problem <- expectError noImportOptions (dhallExpression "broken" "{ port =")+        renderDhallErrorsText (NonEmpty.singleton problem)+          @?= renderDhallErrorText problem <> "\n"+    ]++rendererCase :: String -> DhallErrorCategory -> DhallRoot -> Text -> TestTree+rendererCase label expectedCategory root expected =+  testCase label $ do+    problem <- expectError noImportOptions root+    dhallErrorCategory problem @?= expectedCategory+    renderDhallErrorText problem @?= expected+ noImportOptions :: DhallSourceOptions noImportOptions = dhallSourceOptions "application Dhall" NoImports @@ -248,8 +345,8 @@   Right (Just found) -> pure found   _ -> assertFailure "expected Dhall candidate" >> error "unreachable" -expectResolution :: Either (NonEmpty ConfigError) a -> IO a-expectResolution = \case+expectResolution :: ResolveResult a -> IO a+expectResolution result = case result ^. #answer of   Left _ -> assertFailure "expected Dhall resolution to succeed" >> error "unreachable"   Right value -> pure value