psb/tests/Test/Gen/Document.hs
Pagwin 0f356e4d5e
property tests for the djot parser
Expectations come from the djot syntax reference and IR.hs rather than
from what the parser currently produces, so a failure is evidence of a
bug instead of a record of one. 55 properties: 32 inline (emphasis,
verbatim, math, raw, links, images, references, autolinks, spans,
attributes, footnotes, symbols, escapes, smart punctuation) and 23 block
(headings, quotes, breaks, code fences, divs, raw blocks, every ordered
marker style and delimiter, tight vs loose items, nesting, task lists,
definition lists, tables, definitions, block attributes, round trip).

Two rules are deliberately unasserted and noted in the module header:
headings of seven or more hashes, and the general verbatim space
stripping rule, of which only the unambiguous backtick case is tested.

Three deviations the suite found, fixed in Djot.hs:

- an unclosed verbatim span failed the construct back into plain text.
  The reference says it "extends to the end of the text"; manyTill
  cannot say that on its own, it needs the eof alternative.
- a task list checkbox was recognised without the space the reference
  requires after it, so "1. [x](5)" ate a link and turned it into a
  checked item containing a list starting at 5.
- checkboxes were recognised on ordered items, but the reference gives
  them to bullet items only.

Test.Gen.Document is now shared by both suites. Syntax grew
quoteContent and listTypeFor for the two places markdown and djot
disagree about the resulting IR, and two djot facts became generator
invariants rather than assertions: adjacent markup sharing a delimiter
is ambiguous, so runs separate it, and a blank line between two lists
makes one loose list rather than two blocks, so documents never place
lists back to back.

The test suite is no longer markdown only, so it is just "tests" now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 17:21:57 -04:00

351 lines
13 KiB
Haskell

{-# LANGUAGE OverloadedStrings #-}
-- | Generating whole documents.
--
-- A `DocSpec` is a description of a document that can be turned into both the
-- source text to feed the parser and the `IR.Document` that source is supposed
-- to parse to. Generating the spec rather than a source string is what lets the
-- two stay in step under shrinking, a shrunk spec re-renders and re-derives its
-- own expectation.
--
-- Rendering goes through a `Syntax` so the Djot suite can reuse the spec type
-- and the expectations, and only supply its own surface syntax.
--
-- The grammar here is deliberately a subset of what Markdown.hs accepts. It
-- leaves out the constructs that do not currently round trip:
--
-- * fenced code blocks, `fencedCodeBlock` inverts its language test and
-- reports Nothing for a fence that names a language
-- * multi line block quotes, the first line's plain text runs past the
-- newline and swallows the following lines
-- * underlines and images, both are only recognised at the very start of an
-- inline run because _ and ! are missing from the set of characters plain
-- text stops at
--
-- Adding any of those to the generator once the parser handles them is a
-- one-constructor change, which is the point of generating documents rather
-- than writing the cases out by hand.
module Test.Gen.Document
( DocSpec (..),
BlockSpec (..),
ItemSpec (..),
InlineSpec (..),
Marker (..),
Run,
mkRun,
Syntax (..),
markdownSyntax,
djotSyntax,
render,
expected,
)
where
import Data.Text (Text)
import qualified Data.Text as T
import IR
import Test.Gen (shrinkTextWith, wordsOf)
import Test.QuickCheck (Arbitrary (arbitrary, shrink), Gen, chooseInt, elements, frequency, shrinkList, sized, vectorOf)
newtype DocSpec = DocSpec [BlockSpec]
deriving (Show)
data BlockSpec
= ParagraphS Run
| HeadingS Int Run
| -- a single line, see the note above about multi line quotes
QuoteS Run
| ListS Marker [ItemSpec]
deriving (Show)
-- | A list item, optionally followed by a nested list of its own. The nested
-- list's items are flat, one level of nesting is enough to exercise the
-- indentation handling.
data ItemSpec = ItemS Run (Maybe (Marker, [Run]))
deriving (Show)
-- | The character a list marker is written with. Carried in the spec so that a
-- failure reports which one was in play, and so shrinking can normalise it.
data Marker
= Bullet Char
| Numbered Char
deriving (Show)
data InlineSpec
= PlainS Text
| BoldS Text
| ItalicS Text
| CrossedS Text
| CodeS Text
| LinkS Text Text
deriving (Show)
-- | An inline run. Adjacent plain pieces are merged, the parser would produce a
-- single text node for them and the expectation has to match.
type Run = [InlineSpec]
mkRun :: [InlineSpec] -> Run
mkRun = separate . merge
where
merge (PlainS a : PlainS b : rest) = merge (PlainS (a <> b) : rest)
merge (x : rest) = x : merge rest
merge [] = []
-- two markup elements written back to back are ambiguous whenever they
-- share a delimiter character: `a``b` is a verbatim span that failed to
-- close, not two spans, and the reference says as much ("opening and
-- closing backticks must match in length"). a space between them costs the
-- test nothing and makes every generated run mean one thing.
separate (a : b : rest)
| not (isPlain a) && not (isPlain b) = a : PlainS " " : separate (b : rest)
separate (x : rest) = x : separate rest
separate [] = []
isPlain (PlainS _) = True
isPlain _ = False
-- * generation
instance Arbitrary DocSpec where
arbitrary = sized $ \size -> do
count <- chooseInt (1, 1 + min 3 (size `div` 8))
DocSpec . mkBlocks <$> vectorOf count blockSpec
-- an empty document is legitimate, `document` is a `many`
shrink (DocSpec blocks) = DocSpec . mkBlocks <$> shrinkList shrinkBlock blocks
-- | In djot a blank line between two items only makes the list loose, so two
-- lists written one after the other are a single list rather than two blocks.
-- A document therefore never places two lists next to each other; dropping the
-- second is the normalisation that survives shrinking, inserting a separator
-- between them would grow the value the shrinker just made smaller.
mkBlocks :: [BlockSpec] -> [BlockSpec]
mkBlocks (first@(ListS _ _) : ListS _ _ : rest) = mkBlocks (first : rest)
mkBlocks (block : rest) = block : mkBlocks rest
mkBlocks [] = []
blockSpec :: Gen BlockSpec
blockSpec =
frequency
[ (4, ParagraphS <$> runSpec),
(2, HeadingS <$> chooseInt (1, 6) <*> runSpec),
(2, QuoteS <$> runSpec),
(2, ListS <$> markerSpec <*> itemsSpec)
]
markerSpec :: Gen Marker
markerSpec =
frequency
[ (1, Bullet <$> elements "-*+"),
(1, Numbered <$> elements ".)")
]
itemsSpec :: Gen [ItemSpec]
itemsSpec = do
count <- chooseInt (1, 3)
vectorOf count itemSpec
itemSpec :: Gen ItemSpec
itemSpec = ItemS <$> runSpec <*> frequency [(3, pure Nothing), (1, Just <$> nested)]
where
nested = do
marker <- markerSpec
count <- chooseInt (1, 2)
runs <- vectorOf count runSpec
pure (marker, runs)
runSpec :: Gen Run
runSpec = do
count <- chooseInt (1, 4)
mkRun <$> vectorOf count inlineSpec
inlineSpec :: Gen InlineSpec
inlineSpec =
frequency
[ (5, PlainS <$> wordsOf),
(1, BoldS <$> wordsOf),
(1, ItalicS <$> wordsOf),
(1, CrossedS <$> wordsOf),
(1, CodeS <$> wordsOf),
-- a url with a space in it would be parsed as a url plus a title
(1, LinkS <$> wordsOf <*> word)
]
where
word = T.filter (/= ' ') <$> wordsOf
-- * shrinking
shrinkBlock :: BlockSpec -> [BlockSpec]
shrinkBlock (ParagraphS run) = ParagraphS <$> shrinkRun run
shrinkBlock (HeadingS level run) =
[ParagraphS run]
<> [HeadingS level' run | level' <- [1 .. level - 1]]
<> (HeadingS level <$> shrinkRun run)
shrinkBlock (QuoteS run) = [ParagraphS run] <> (QuoteS <$> shrinkRun run)
shrinkBlock (ListS marker items) =
[ParagraphS run | ItemS run _ <- take 1 items]
<> (ListS <$> shrinkMarker marker <*> pure items)
-- `some listItem`, a list with no items is not a list
<> [ListS marker items' | items' <- shrinkList shrinkItem items, not (null items')]
shrinkMarker :: Marker -> [Marker]
shrinkMarker (Bullet c) = [Bullet '-' | c /= '-']
shrinkMarker (Numbered c) = [Numbered '.' | c /= '.']
shrinkItem :: ItemSpec -> [ItemSpec]
shrinkItem (ItemS run nested) =
[ItemS run Nothing | Just _ <- [nested]]
<> (ItemS <$> shrinkRun run <*> pure nested)
<> [ItemS run (Just (marker, runs')) | Just (marker, runs) <- [nested], runs' <- shrinkList shrinkRun runs, not (null runs')]
-- | A run is never empty, an empty one renders to nothing at all and the block
-- around it stops being the block the spec describes.
shrinkRun :: Run -> [Run]
shrinkRun run = [mkRun shrunk | shrunk <- shrinkList shrinkInline run, not (null shrunk)]
shrinkInline :: InlineSpec -> [InlineSpec]
shrinkInline (PlainS t) = PlainS <$> shrinkContent t
shrinkInline (BoldS t) = [PlainS t] <> (BoldS <$> shrinkContent t)
shrinkInline (ItalicS t) = [PlainS t] <> (ItalicS <$> shrinkContent t)
shrinkInline (CrossedS t) = [PlainS t] <> (CrossedS <$> shrinkContent t)
shrinkInline (CodeS t) = [PlainS t] <> (CodeS <$> shrinkContent t)
shrinkInline (LinkS t url) =
[PlainS t]
<> [LinkS t' url | t' <- shrinkContent t]
<> [LinkS t url' | url' <- shrinkContent url, not (T.any (== ' ') url')]
shrinkContent :: Text -> [Text]
shrinkContent = shrinkTextWith (\t -> not (T.null t) && T.last t /= ' ')
-- * rendering
-- | The surface syntax a spec is written out in, and the two places where the
-- two syntaxes disagree about what the result should be. The rest of the
-- expectations are shared.
data Syntax = Syntax
{ renderInline :: InlineSpec -> Text,
renderHeading :: Int -> Text -> Text,
renderQuote :: Text -> Text,
-- | marker, depth, one based index within the list, content
renderItem :: Marker -> Int -> Int -> Text -> Text,
blockSeparator :: Text,
-- | how the single line of a quote appears inside the BlockQuote. Djot
-- parses a quote's contents as blocks, so it is a Paragraph; the Markdown
-- parser collects inlines and wraps them in a Transparent.
quoteContent :: [InlineText] -> Element,
-- | djot reads the start number and style off the first marker, the
-- Markdown parser does not record either
listTypeFor :: Marker -> ListType
}
markdownSyntax :: Syntax
markdownSyntax =
Syntax
{ renderInline = \case
PlainS t -> t
BoldS t -> "**" <> t <> "**"
ItalicS t -> "*" <> t <> "*"
CrossedS t -> "~~" <> t <> "~~"
CodeS t -> "`" <> t <> "`"
LinkS t url -> "[" <> t <> "](" <> url <> ")",
renderHeading = \level content -> T.replicate level "#" <> " " <> content,
renderQuote = \content -> "> " <> content,
renderItem = \marker depth index content ->
T.replicate depth " " <> marked marker index <> " " <> content,
blockSeparator = "\n\n",
quoteContent = Transparent,
listTypeFor = \case
Bullet _ -> Unordered {style = Nothing}
Numbered _ -> Ordered {start_number = Nothing, style = Nothing}
}
-- | Djot. Strong is @*@ and emphasis @_@, delete has to be written in its
-- braced form, and the rest lines up with the markdown spelling.
djotSyntax :: Syntax
djotSyntax =
Syntax
{ renderInline = \case
PlainS t -> t
BoldS t -> "*" <> t <> "*"
ItalicS t -> "_" <> t <> "_"
CrossedS t -> "{-" <> t <> "-}"
CodeS t -> "`" <> t <> "`"
LinkS t url -> "[" <> t <> "](" <> url <> ")",
renderHeading = \level content -> T.replicate level "#" <> " " <> content,
renderQuote = \content -> "> " <> content,
renderItem = \marker depth index content ->
T.replicate depth " " <> marked marker index <> " " <> content,
blockSeparator = "\n\n",
quoteContent = \content -> Paragraph (P content) mempty,
listTypeFor = \case
Bullet _ -> Unordered {style = Nothing}
-- the generator numbers items from one, and a decimal marker carries no
-- style
Numbered _ -> Ordered {start_number = Just 1, style = Nothing}
}
marked :: Marker -> Int -> Text
marked (Bullet c) _ = T.singleton c
marked (Numbered c) index = T.pack (show index) <> T.singleton c
render :: Syntax -> DocSpec -> Text
render syntax (DocSpec blocks) = T.intercalate syntax.blockSeparator $ map (renderBlock syntax) blocks
renderBlock :: Syntax -> BlockSpec -> Text
renderBlock syntax = \case
ParagraphS run -> renderRun syntax run
HeadingS level run -> syntax.renderHeading level (renderRun syntax run)
QuoteS run -> syntax.renderQuote (renderRun syntax run)
ListS marker items -> T.intercalate "\n" $ renderItems syntax marker 0 items
renderItems :: Syntax -> Marker -> Int -> [ItemSpec] -> [Text]
renderItems syntax marker depth items = concat $ zipWith one [1 ..] items
where
one index (ItemS run nested) =
syntax.renderItem marker depth index (renderRun syntax run)
: case nested of
Nothing -> []
Just (child_marker, runs) ->
renderItems syntax child_marker (depth + 1) [ItemS run' Nothing | run' <- runs]
-- | Inline elements are written with nothing between them. Plain pieces carry
-- their own spacing and adjacent plain pieces have already been merged, so a
-- separator here would be text the expectation does not account for.
renderRun :: Syntax -> Run -> Text
renderRun syntax = T.concat . map syntax.renderInline
-- * expectations
expected :: Syntax -> DocSpec -> Document
expected syntax (DocSpec blocks) = Doc $ map (expectedBlock syntax) blocks
expectedBlock :: Syntax -> BlockSpec -> Element
expectedBlock syntax = \case
ParagraphS run -> Paragraph (P (expectedRun run)) mempty
HeadingS level run -> Heading (H {level, text = expectedRun run}) mempty
QuoteS run -> BlockQuote (Q [syntax.quoteContent (expectedRun run)]) mempty
ListS marker items -> List (expectedList syntax marker items) mempty
expectedList :: Syntax -> Marker -> [ItemSpec] -> List
expectedList syntax marker items =
L {list_type = syntax.listTypeFor marker, items = map (expectedItem syntax) items}
-- | A tight item's paragraph is unwrapped by both parsers, so its content is a
-- Transparent regardless of syntax.
expectedItem :: Syntax -> ItemSpec -> ListItem
expectedItem syntax (ItemS run nested) = LI {content = Transparent (expectedRun run) : child}
where
child = case nested of
Nothing -> []
Just (marker, runs) ->
[List (expectedList syntax marker [ItemS run' Nothing | run' <- runs]) mempty]
expectedRun :: Run -> [InlineText]
expectedRun = map expectedInline
expectedInline :: InlineSpec -> InlineText
expectedInline = \case
PlainS t -> Text t
BoldS t -> Bold [Text t] mempty
ItalicS t -> Italic [Text t] mempty
CrossedS t -> Crossed [Text t] mempty
CodeS t -> InlineCode t mempty
LinkS t url -> Link {linkText = [Text t], url, title = Nothing, misc_attrs = mempty}