keiki-0.2.0.0: src/Keiki/Render/Validate.hs
{-# OPTIONS_GHC -Wno-partial-fields #-}
-- | Pure, cheap structural-heuristic validators for rendered keiki
-- Mermaid diagrams and Mermaid atlas documents.
--
-- These are __not__ a Mermaid parser. They scan rendered 'Text' for a small
-- set of common problems and return a deterministic, document-ordered list
-- of structured warnings. An empty list means \"no problems detected\" —
-- never \"guaranteed valid Mermaid\". The checks can miss problems Mermaid
-- would reject (false negatives) and can flag text Mermaid accepts (false
-- positives); they exist so a downstream unit test can catch the common,
-- cheap-to-detect mistakes before a rendered document is committed.
--
-- Mirrors the pure list-of-warnings house style of
-- 'Keiki.Core.validateTransducer' (EP-56), but operates on rendered 'Text'
-- rather than a 'Keiki.Core.SymTransducer', so there is no shared code.
--
-- See @docs/plans/66-pure-mermaid-diagram-and-atlas-validation-helpers.md@.
-- The warning type intentionally exposes constructor-specific record fields;
-- callers are expected to pattern-match on its constructors before reading
-- them.
module Keiki.Render.Validate
( MermaidValidationOptions (..),
defaultMermaidValidationOptions,
MermaidValidationWarning (..),
validateMermaidDiagram,
validateMermaidAtlas,
)
where
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Text qualified as T
-- | Tunable knobs for the heuristic checks. The denylist and the label
-- budget are caller-tunable so a downstream test can match its own house
-- rules.
data MermaidValidationOptions = MermaidValidationOptions
{ -- | If @'Just' n@, a transition label longer than @n@ characters
-- yields 'LabelTooLong'. 'Nothing' disables the length check.
maxLabelLength :: Maybe Int,
-- | Whether to run the suspicious-unescaped-character check at all.
checkSuspiciousChars :: Bool,
-- | The denylist of characters that commonly break Mermaid labels.
-- The literal substring @\"<br/>\"@ is always exempt from this check
-- regardless of what this set contains, because keiki emits @<br/>@
-- deliberately for multi-event and multiline labels.
suspiciousChars :: [Char]
}
deriving stock (Eq, Show)
-- | Sensible defaults: an 80-character label budget, suspicious-char
-- checking on, and the curated denylist @{ '"', '<', '>', '|', '{', '}' }@.
defaultMermaidValidationOptions :: MermaidValidationOptions
defaultMermaidValidationOptions =
MermaidValidationOptions
{ maxLabelLength = Just 80,
checkSuspiciousChars = True,
suspiciousChars = ['"', '<', '>', '|', '{', '}']
}
-- | One detected problem. Every constructor carries enough context to act
-- on the warning. Line numbers are 1-based, counted in the diagram text
-- handed to 'validateMermaidDiagram'.
data MermaidValidationWarning
= -- | The first non-blank line is not (and does not start with) @stateDiagram-v2@.
MissingStateDiagramHeader
| -- | The document has a header but no transition, declaration, or grouping line under it.
EmptyDiagram
| -- | A transition label exceeds 'maxLabelLength'.
LabelTooLong {warnLine :: Int, warnLength :: Int, warnLabel :: Text}
| -- | The same @state \"…\" as \<id\>@ identifier is declared more than once, or with conflicting display labels.
DuplicateStateId {warnStateId :: Text}
| -- | A transition label contains a denylisted character.
SuspiciousUnescapedChar {warnLine :: Int, warnChar :: Char, warnLabel :: Text}
deriving stock (Eq, Show)
-- | Validate a single rendered diagram's 'Text'. Returns warnings in
-- document order: the header check, then the empty-diagram check, then the
-- per-transition-line label checks in line order, then the duplicate-id
-- warnings in first-declaration order. An empty result means no problem was
-- detected (not a guarantee of Mermaid validity — see the module header).
validateMermaidDiagram ::
MermaidValidationOptions -> Text -> [MermaidValidationWarning]
validateMermaidDiagram opts diagram =
let numbered = zip [1 :: Int ..] (T.lines diagram)
contentLns = [(n, T.strip l) | (n, l) <- numbered, not (T.null (T.strip l))]
headerWs = case contentLns of
[] -> [MissingStateDiagramHeader]
((_, h) : _) ->
[ MissingStateDiagramHeader
| not (h == "stateDiagram-v2" || "stateDiagram-v2 " `T.isPrefixOf` h)
]
bodyLns = drop 1 contentLns
hasBody = any (\(_, l) -> isTransition l || isDecl l || isGroup l) bodyLns
emptyWs = [EmptyDiagram | not (null contentLns) && not hasBody]
labelWs =
concatMap
(\(n, l) -> labelWarnings opts n l)
[ (n, l)
| (n, l) <- numbered,
isTransition (T.strip l),
T.strip (transitionTarget l) /= "[*]"
]
dupWs = duplicateStateIdWarnings [T.strip l | (_, l) <- numbered]
in headerWs ++ emptyWs ++ labelWs ++ dupWs
where
isTransition l = " --> " `T.isInfixOf` l
isDecl l = "state \"" `T.isInfixOf` l && " as " `T.isInfixOf` l
isGroup l = "state " `T.isPrefixOf` l && "{" `T.isSuffixOf` l
-- | The target token of a transition line (between @ --> @ and @ : @, or
-- to end of line for a final marker). Used only to skip @--> [*]@ finals,
-- which carry no label.
transitionTarget :: Text -> Text
transitionTarget l =
case T.breakOn " --> " l of
(_, rest)
| not (T.null rest) ->
T.takeWhile (/= ':') (T.drop (T.length " --> ") rest)
_ -> ""
-- | Per-transition-line label checks (length + suspicious chars).
labelWarnings ::
MermaidValidationOptions -> Int -> Text -> [MermaidValidationWarning]
labelWarnings opts n line =
case extractLabel line of
Nothing -> []
Just label ->
let lenWs = case maxLabelLength opts of
Just maxN
| T.length label > maxN ->
[LabelTooLong {warnLine = n, warnLength = T.length label, warnLabel = label}]
_ -> []
charWs
| checkSuspiciousChars opts =
[ SuspiciousUnescapedChar {warnLine = n, warnChar = c, warnLabel = label}
| c <- T.unpack (stripBrTags label),
c `elem` suspiciousChars opts
]
| otherwise = []
in lenWs ++ charWs
-- | The label of a transition line: the text after the first @ : @,
-- trimmed. 'Nothing' when the line has no @ : @ (e.g. a final marker).
extractLabel :: Text -> Maybe Text
extractLabel line =
case T.breakOn " : " line of
(_, rest) | not (T.null rest) -> Just (T.strip (T.drop 3 rest))
_ -> Nothing
-- | Blank out @<br/>@ so its angle brackets are not flagged as suspicious.
stripBrTags :: Text -> Text
stripBrTags = T.replace "<br/>" " "
-- | Collect @state \"\<display\>\" as \<id\>@ declarations and report any
-- id declared twice or with conflicting display labels. Ids that merely
-- recur as transition endpoints are never reported (that is normal, and
-- keys off exactly the declaration lines EP-64's 'toMermaidWithLabels'
-- emits). Reported in first-declared (sorted-key) order.
duplicateStateIdWarnings :: [Text] -> [MermaidValidationWarning]
duplicateStateIdWarnings ls =
let decls = [d | Just d <- map parseDecl ls]
tally = foldl' (\m (i, disp) -> Map.insertWith (++) i [disp] m) Map.empty decls
in [ DuplicateStateId {warnStateId = i}
| (i, disps) <- Map.toList tally,
length disps > 1 || length (nubText disps) > 1
]
where
nubText = foldr (\x acc -> if x `elem` acc then acc else x : acc) []
-- | Parse a @state \"\<display\>\" as \<id\>@ declaration line into its
-- @(id, display)@ pair; 'Nothing' for any other line.
parseDecl :: Text -> Maybe (Text, Text)
parseDecl l0 =
case T.stripPrefix "state \"" (T.strip l0) of
Nothing -> Nothing
Just rest ->
case T.breakOn "\" as " rest of
(disp, afterDisp)
| not (T.null afterDisp) ->
Just (T.strip (T.drop (T.length "\" as ") afterDisp), disp)
_ -> Nothing
-- | Validate a multi-section atlas: split into fenced @```mermaid@ blocks
-- and run 'validateMermaidDiagram' on each, aggregating warnings in block
-- order.
validateMermaidAtlas ::
MermaidValidationOptions -> Text -> [MermaidValidationWarning]
validateMermaidAtlas opts doc =
concatMap (validateMermaidDiagram opts) (mermaidBlocks (T.lines doc))
-- | Extract the inner text of each @```mermaid@ … @```@ fenced block: the
-- lines strictly between an opening fence line whose trimmed content is
-- exactly @```mermaid@ and the next line whose trimmed content is exactly
-- @```@.
mermaidBlocks :: [Text] -> [Text]
mermaidBlocks = go
where
go [] = []
go (l : rest)
| T.strip l == "```mermaid" =
let (body, after) = break (\x -> T.strip x == "```") rest
in T.intercalate "\n" body : go (dropOne after)
| otherwise = go rest
dropOne (_ : xs) = xs
dropOne [] = []