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 +13/−1
- settei-dhall.cabal +7/−6
- src/Settei/Dhall.hs +113/−9
- test/Settei/DhallPrototypeTest.hs +1/−1
- test/Settei/DhallTest.hs +108/−11
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