packages feed

settei-kdl-0.2.0.0: src/Settei/Kdl.hs

{-# LANGUAGE ImportQualifiedPost #-}

-- |
-- Module: Settei.Kdl
-- Description: Canonical, span-preserving KDL v2 sources for Settei.
module Settei.Kdl
  ( KdlErrorCategory (..),
    KdlSourceError,
    KdlSourceOptions,
    KdlSpan,
    annotateKdlSourceOptions,
    decodeKdlSource,
    fromKubernetesMountedFile,
    kdlErrorCategory,
    kdlErrorContext,
    kdlErrorMessage,
    kdlErrorName,
    kdlErrorPath,
    kdlErrorRelatedSpan,
    kdlErrorSpan,
    kdlSourceAnnotations,
    kdlSourceName,
    kdlSourceOptions,
    kdlSourcePath,
    kdlSpanColumn,
    kdlSpanEndColumn,
    kdlSpanEndLine,
    kdlSpanLine,
    readKdlSource,
    renderKdlErrorText,
    renderKdlErrorsText,
    withKdlSourcePath,
  )
where

import Control.Exception (IOException, displayException, try)
import Control.Monad (foldM, when)
import Data.Bifunctor (first)
import Data.Generics.Labels ()
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
import KDL.Parser qualified as KDLParser
import KDL.Types qualified as KDL
import Settei
import Settei.Prelude

-- | Stable classes of adapter failure. No constructor retains a raw KDL value.
data KdlErrorCategory
  = KdlSyntaxError
  | KdlIoError
  | KdlDuplicateProperty
  | KdlInvalidName
  | KdlUnsupportedAnnotation
  | KdlUnsupportedValue
  | KdlMixedNodeShape
  | KdlPropertyChildCollision
  deriving stock (Generic, Eq, Ord, Show)

-- | A complete one-based KDL source span.
data KdlSpan = KdlSpan
  { line :: !Int,
    column :: !Int,
    endLine :: !Int,
    endColumn :: !Int
  }
  deriving stock (Generic, Eq, Ord, Show)

-- | A secret-safe KDL input failure.
data KdlSourceError = KdlSourceError
  { category :: !KdlErrorCategory,
    name :: !Text,
    path :: !(Maybe FilePath),
    location :: !(Maybe KdlSpan),
    relatedLocation :: !(Maybe KdlSpan),
    context :: !Text,
    message :: !Text
  }
  deriving stock (Generic, Eq, Show)

-- | How one KDL v2 document should be named and annotated in provenance.
data KdlSourceOptions = KdlSourceOptions
  { name :: !Text,
    path :: !(Maybe FilePath),
    annotations :: !(Map Text Text)
  }
  deriving stock (Generic, Eq)

type LocationMap = Map [Text] KdlSpan

-- | Start options for an in-memory KDL document.
kdlSourceOptions :: Text -> KdlSourceOptions
kdlSourceOptions name = KdlSourceOptions {name, path = Nothing, annotations = Map.empty}

-- | Attach a real or logical path to an in-memory KDL document.
withKdlSourcePath :: FilePath -> KdlSourceOptions -> KdlSourceOptions
withKdlSourcePath path options = options & #path .~ Just path

-- | Add trusted, secret-safe origin metadata.
annotateKdlSourceOptions :: Map Text Text -> KdlSourceOptions -> KdlSourceOptions
annotateKdlSourceOptions annotations options = options & #annotations %~ (annotations <>)

-- | Attach a trusted Kubernetes reference for a mounted KDL file.
--
-- This performs no cluster lookup. Setting sensitivity remains the sole redaction policy.
fromKubernetesMountedFile :: KubernetesRef -> KdlSourceOptions -> KdlSourceOptions
fromKubernetesMountedFile reference =
  annotateKdlSourceOptions (kubernetesAnnotations reference)

-- | Return the stable source name.
kdlSourceName :: KdlSourceOptions -> Text
kdlSourceName options = options ^. #name

-- | Return the optional file path.
kdlSourcePath :: KdlSourceOptions -> Maybe FilePath
kdlSourcePath options = options ^. #path

-- | Return caller-supplied, secret-safe annotations.
kdlSourceAnnotations :: KdlSourceOptions -> Map Text Text
kdlSourceAnnotations options = options ^. #annotations

-- | Return an error's stable category.
kdlErrorCategory :: KdlSourceError -> KdlErrorCategory
kdlErrorCategory problem = problem ^. #category

-- | Return the source name associated with an error.
kdlErrorName :: KdlSourceError -> Text
kdlErrorName problem = problem ^. #name

-- | Return the file path associated with an error, when known.
kdlErrorPath :: KdlSourceError -> Maybe FilePath
kdlErrorPath problem = problem ^. #path

-- | Return the smallest trustworthy primary span for an error.
kdlErrorSpan :: KdlSourceError -> Maybe KdlSpan
kdlErrorSpan problem = problem ^. #location

-- | Return a second trustworthy span for a collision, when available.
kdlErrorRelatedSpan :: KdlSourceError -> Maybe KdlSpan
kdlErrorRelatedSpan problem = problem ^. #relatedLocation

-- | Return the structural path associated with an error.
kdlErrorContext :: KdlSourceError -> Text
kdlErrorContext problem = problem ^. #context

-- | Return a concise message that never includes a raw scalar or source excerpt.
kdlErrorMessage :: KdlSourceError -> Text
kdlErrorMessage problem = problem ^. #message

-- | Return a span's one-based start line.
kdlSpanLine :: KdlSpan -> Int
kdlSpanLine value = value ^. #line

-- | Return a span's one-based start column.
kdlSpanColumn :: KdlSpan -> Int
kdlSpanColumn value = value ^. #column

-- | Return a span's one-based inclusive end line.
kdlSpanEndLine :: KdlSpan -> Int
kdlSpanEndLine value = value ^. #endLine

-- | Return a span's one-based inclusive end column.
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
-- ambiguous mixtures fail explicitly. The parser's rendered source excerpt is discarded
-- on syntax failure so structured errors remain safe before setting sensitivity is known.
decodeKdlSource :: KdlSourceOptions -> Text -> Either (NonEmpty KdlSourceError) Source
decodeKdlSource options input =
  first NonEmpty.singleton $ do
    document <- first (syntaxError options) (KDLParser.parseWith (parseConfig options) input)
    (root, locations) <- translateDocument options document
    pure
      ( annotateSourceAt
          (spanAnnotationsFor locations)
          ( locateSource
              (sourceLocationFor options locations)
              ( annotateSource
                  (Map.singleton "kdl.version" "2" <> options ^. #annotations)
                  (source (options ^. #name) (FileSource "KDL v2") root)
              )
          )
      )

-- | Read one file and pass it through the same strict pure translator.
--
-- The supplied file path replaces any path already present in the options.
readKdlSource :: KdlSourceOptions -> FilePath -> IO (Either (NonEmpty KdlSourceError) Source)
readKdlSource options filePath = do
  let fileOptions = withKdlSourcePath filePath options
  contents <- try @IOException (TextIO.readFile filePath)
  pure $ case contents of
    Left exception ->
      Left
        ( NonEmpty.singleton
            ( kdlError
                fileOptions
                KdlIoError
                Nothing
                Nothing
                []
                (Text.pack (displayException exception))
            )
        )
    Right input -> decodeKdlSource fileOptions input

parseConfig :: KdlSourceOptions -> KDLParser.ParseConfig
parseConfig options =
  KDLParser.ParseConfig
    { KDLParser.filepath = maybe "" id (options ^. #path),
      KDLParser.includeSpans = True
    }

syntaxError :: KdlSourceOptions -> Text -> KdlSourceError
syntaxError options rendered =
  kdlError
    options
    KdlSyntaxError
    (parseErrorLocation rendered)
    Nothing
    []
    "invalid KDL v2 syntax"

parseErrorLocation :: Text -> Maybe KdlSpan
parseErrorLocation rendered = do
  header <- listToMaybe (Text.lines rendered)
  case reverse (filter (not . Text.null) (Text.splitOn ":" header)) of
    columnText : lineText : _ -> do
      line <- parsePositiveInt lineText
      column <- parsePositiveInt columnText
      pure KdlSpan {line, column, endLine = line, endColumn = column}
    _ -> Nothing

parsePositiveInt :: Text -> Maybe Int
parsePositiveInt value = case TextRead.decimal value of
  Right (number, rest)
    | number > 0 && Text.null rest -> Just number
  _ -> Nothing

translateDocument :: KdlSourceOptions -> KDL.Document -> Either KdlSourceError (RawValue, LocationMap)
translateDocument options document =
  translateNodeList options [] (nodeListNodes document)

translateNodeList ::
  KdlSourceOptions ->
  [Text] ->
  [KDL.Node] ->
  Either KdlSourceError (RawValue, LocationMap)
translateNodeList options prefix nodes = do
  groups <- groupNodes options prefix nodes
  translated <- traverse (translateGroup options prefix) (Map.toAscList groups)
  pure
    ( RawObject (Map.fromList [(name, value) | (name, value, _) <- translated]),
      mconcat [locations | (_, _, locations) <- translated]
    )

groupNodes ::
  KdlSourceOptions ->
  [Text] ->
  [KDL.Node] ->
  Either KdlSourceError (Map Text [KDL.Node])
groupNodes options prefix = foldM addNode Map.empty
  where
    addNode groups node = do
      name <- validateIdentifier options prefix (nodeName node)
      pure
        ( groups
            & at name
            %~ \existing -> Just (maybe [node] (<> [node]) existing)
        )

translateGroup ::
  KdlSourceOptions ->
  [Text] ->
  (Text, [KDL.Node]) ->
  Either KdlSourceError (Text, RawValue, LocationMap)
translateGroup options prefix (name, nodes) = case nodes of
  [] -> pure (name, RawNull, Map.empty)
  [node] -> do
    (value, locations) <- translateNode options (prefix <> [name]) node
    pure (name, value, locations)
  firstNode : remainingNodes -> do
    values <- traverse (fmap fst . translateNode options (prefix <> [name])) (firstNode : remainingNodes)
    let groupSpan = coverSpans (nodeLocation firstNode :| fmap nodeLocation remainingNodes)
    pure
      ( name,
        RawArray values,
        Map.singleton (prefix <> [name]) groupSpan
      )

translateNode ::
  KdlSourceOptions ->
  [Text] ->
  KDL.Node ->
  Either KdlSourceError (RawValue, LocationMap)
translateNode options path node = do
  rejectNodeAnnotation options path node
  properties <- validateProperties options path (nodeEntries node)
  let arguments = [entryValue entry | entry <- nodeEntries node, entryName entry == Nothing]
      childNodes = nodeChildren node
  when (not (null arguments) && (not (Map.null properties) || not (null childNodes))) $
    Left
      ( kdlError
          options
          KdlMixedNodeShape
          (Just (nodeLocation node))
          Nothing
          path
          "positional arguments cannot be combined with properties or children"
      )
  case arguments of
    firstArgument : remainingArguments -> do
      values <- traverse (translateValue options path) (firstArgument : remainingArguments)
      let value = case values of
            [single] -> single
            many -> RawArray many
          location = coverSpans (valueLocation firstArgument :| fmap valueLocation remainingArguments)
      pure (value, Map.singleton path location)
    []
      | Map.null properties && null childNodes ->
          pure (RawNull, Map.singleton path (nodeLocation node))
      | otherwise -> translateObjectNode options path node properties childNodes

translateObjectNode ::
  KdlSourceOptions ->
  [Text] ->
  KDL.Node ->
  Map Text KDL.Entry ->
  [KDL.Node] ->
  Either KdlSourceError (RawValue, LocationMap)
translateObjectNode options path node properties childNodes = do
  childGroups <- groupNodes options path childNodes
  case firstCollision properties childGroups of
    Just (name, property, child) ->
      Left
        ( kdlError
            options
            KdlPropertyChildCollision
            (entryNameLocation property)
            (Just (identifierLocation (nodeName child)))
            (path <> [name])
            "a property and child use the same field name"
        )
    Nothing -> pure ()
  (propertyObject, propertyLocations) <- translateProperties options path properties
  (childValue, childLocations) <- translateNodeList options path childNodes
  let childObject = case childValue of
        RawObject object -> object
        _ -> Map.empty
  pure
    ( RawObject (propertyObject <> childObject),
      Map.singleton path (nodeLocation node) <> propertyLocations <> childLocations
    )

translateProperties ::
  KdlSourceOptions ->
  [Text] ->
  Map Text KDL.Entry ->
  Either KdlSourceError (Map Text RawValue, LocationMap)
translateProperties options path properties = do
  translated <-
    traverse
      ( \(name, entry) -> do
          value <- translateValue options (path <> [name]) (entryValue entry)
          pure (name, value, Map.singleton (path <> [name]) (valueLocation (entryValue entry)))
      )
      (Map.toAscList properties)
  pure
    ( Map.fromList [(name, value) | (name, value, _) <- translated],
      mconcat [locations | (_, _, locations) <- translated]
    )

validateProperties ::
  KdlSourceOptions ->
  [Text] ->
  [KDL.Entry] ->
  Either KdlSourceError (Map Text KDL.Entry)
validateProperties options path = foldM addProperty Map.empty
  where
    addProperty properties entry = case entryName entry of
      Nothing -> pure properties
      Just identifier -> do
        name <- validateIdentifier options path identifier
        case properties ^. at name of
          Just earlier ->
            Left
              ( kdlError
                  options
                  KdlDuplicateProperty
                  (Just (identifierLocation identifier))
                  (entryNameLocation earlier)
                  (path <> [name])
                  "duplicate property"
              )
          Nothing -> pure (properties & at name ?~ entry)

firstCollision :: Map Text KDL.Entry -> Map Text [KDL.Node] -> Maybe (Text, KDL.Entry, KDL.Node)
firstCollision properties childGroups =
  listToMaybe
    [ (name, property, child)
    | name <- Map.keys properties,
      Just property <- [properties ^. at name],
      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
      | 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
    KDL.NegInf -> unsupported
    KDL.NaN -> unsupported
  where
    unsupported =
      Left
        ( kdlError
            options
            KdlUnsupportedValue
            (Just (valueLocation value))
            Nothing
            path
            "non-finite KDL numbers are not supported"
        )

rejectNodeAnnotation :: KdlSourceOptions -> [Text] -> KDL.Node -> Either KdlSourceError ()
rejectNodeAnnotation options path node = case nodeAnnotation node of
  Nothing -> Right ()
  Just annotation ->
    Left
      ( kdlError
          options
          KdlUnsupportedAnnotation
          (Just (annotationLocation annotation))
          Nothing
          path
          "KDL node type annotations are not supported"
      )

rejectValueAnnotation :: KdlSourceOptions -> [Text] -> KDL.Value -> Either KdlSourceError ()
rejectValueAnnotation options path value = case valueAnnotation value of
  Nothing -> Right ()
  Just annotation ->
    Left
      ( kdlError
          options
          KdlUnsupportedAnnotation
          (Just (annotationLocation annotation))
          Nothing
          path
          "KDL value type annotations are not supported"
      )

validateIdentifier ::
  KdlSourceOptions ->
  [Text] ->
  KDL.Identifier ->
  Either KdlSourceError Text
validateIdentifier options prefix identifier =
  case mkKey (NonEmpty.singleton name) of
    Right _ -> Right name
    Left _ ->
      Left
        ( kdlError
            options
            KdlInvalidName
            (Just (identifierLocation identifier))
            Nothing
            (prefix <> [name])
            "KDL names must be non-empty Settei key segments without dots"
        )
  where
    name = identifierText identifier

kdlError ::
  KdlSourceOptions ->
  KdlErrorCategory ->
  Maybe KdlSpan ->
  Maybe KdlSpan ->
  [Text] ->
  Text ->
  KdlSourceError
kdlError options category location relatedLocation context message =
  KdlSourceError
    { category,
      name = options ^. #name,
      path = options ^. #path,
      location,
      relatedLocation,
      context = renderContext context,
      message
    }

renderContext :: [Text] -> Text
renderContext = foldl (\rendered segment -> rendered <> "." <> segment) "$"

sourceLocationFor :: KdlSourceOptions -> LocationMap -> Key -> Maybe SourceLocation
sourceLocationFor options locations key = do
  found <- locations ^. at (NonEmpty.toList (keySegments key))
  pure
    SourceLocation
      { path = maybe (options ^. #name) Text.pack (options ^. #path),
        line = Just (found ^. #line),
        column = Just (found ^. #column)
      }

spanAnnotationsFor :: LocationMap -> Key -> Map Text Text
spanAnnotationsFor locations key = case locations ^. at (NonEmpty.toList (keySegments key)) of
  Nothing -> Map.empty
  Just found ->
    Map.fromList
      [ ("kdl.span.start-line", renderInt (found ^. #line)),
        ("kdl.span.start-column", renderInt (found ^. #column)),
        ("kdl.span.end-line", renderInt (found ^. #endLine)),
        ("kdl.span.end-column", renderInt (found ^. #endColumn))
      ]

renderInt :: Int -> Text
renderInt = Text.pack . show

coverSpans :: NonEmpty KdlSpan -> KdlSpan
coverSpans (firstSpan :| remaining) = foldl cover firstSpan remaining
  where
    cover start finish =
      KdlSpan
        { line = start ^. #line,
          column = start ^. #column,
          endLine = finish ^. #endLine,
          endColumn = finish ^. #endColumn
        }

nodeListNodes :: KDL.NodeList -> [KDL.Node]
nodeListNodes KDL.NodeList {KDL.nodes = nodes} = nodes

nodeAnnotation :: KDL.Node -> Maybe KDL.Ann
nodeAnnotation KDL.Node {KDL.ann = annotation} = annotation

nodeName :: KDL.Node -> KDL.Identifier
nodeName KDL.Node {KDL.name = name} = name

nodeEntries :: KDL.Node -> [KDL.Entry]
nodeEntries KDL.Node {KDL.entries = entries} = entries

nodeChildren :: KDL.Node -> [KDL.Node]
nodeChildren KDL.Node {KDL.children = childList} = maybe [] nodeListNodes childList

nodeLocation :: KDL.Node -> KdlSpan
nodeLocation KDL.Node {KDL.ext = KDL.NodeExtension {KDL.span = location}} = fromKdlSpan location

entryName :: KDL.Entry -> Maybe KDL.Identifier
entryName KDL.Entry {KDL.name = name} = name

entryNameLocation :: KDL.Entry -> Maybe KdlSpan
entryNameLocation entry = identifierLocation <$> entryName entry

entryValue :: KDL.Entry -> KDL.Value
entryValue KDL.Entry {KDL.value = value} = value

valueAnnotation :: KDL.Value -> Maybe KDL.Ann
valueAnnotation KDL.Value {KDL.ann = annotation} = annotation

valueData :: KDL.Value -> KDL.ValueData
valueData KDL.Value {KDL.data_ = value} = value

valueLocation :: KDL.Value -> KdlSpan
valueLocation KDL.Value {KDL.ext = KDL.ValueExtension {KDL.span = location}} = fromKdlSpan location

annotationLocation :: KDL.Ann -> KdlSpan
annotationLocation KDL.Ann {KDL.ext = KDL.AnnExtension {KDL.span = location}} = fromKdlSpan location

identifierText :: KDL.Identifier -> Text
identifierText KDL.Identifier {KDL.value = value} = value

identifierLocation :: KDL.Identifier -> KdlSpan
identifierLocation KDL.Identifier {KDL.ext = KDL.IdentifierExtension {KDL.span = location}} = fromKdlSpan location

fromKdlSpan :: KDL.Span -> KdlSpan
fromKdlSpan
  KDL.Span
    { KDL.startLine = line,
      KDL.startCol = column,
      KDL.endLine = endLine,
      KDL.endCol = endColumn
    } = KdlSpan {line, column, endLine, endColumn}