psb/tests/Test/Gen/Document.hs
Pagwin 628f01217e
restructure the tests and assert on whole trees
The suite is now one tasty binary over shared modules, so the Djot
tests can sit next to the Markdown ones without another copy of the
harness:

  tests/Main.hs             the TestTree
  tests/Test/Harness.hs     running a parser inside a property
  tests/Test/Gen.hs         generators, shrinkers, escaping
  tests/Test/Gen/Document.hs  whole document generation
  tests/Markdown/Parse.hs   the Markdown properties

Generated values moved from `pick` to `Arbitrary` instances on
newtypes. `pick` embeds its value with `forAll (return a)` and cannot
shrink; going through Arbitrary gets shrinking back at the cost of
hand written shrinkers, which have to preserve the invariants their
generator established.

IR now derives Eq, and the properties assert against a complete
expected tree rather than pattern matching. The old patterns bound
fresh names that shadowed the generated ones, so they only ever
checked the shape of the tree and never its content.

Test.Gen.Document generates a description of a document which renders
to source and derives its own expected tree, so the two stay in step
while shrinking. Rendering goes through a Syntax record so Djot can
reuse the spec and the expectations. Escaping is a generator level
concern: an Escaper says which characters are special and how (or
whether) they can be written literally.

Two properties fail, both on parser bugs rather than test bugs:

  code_block          fencedCodeBlock inverts its language test, so a
                      fence naming a language reports Nothing. Djot.hs
                      gets this right at src/Djot.hs:354.
  document_round_trip a nested list with more than one item makes the
                      whole list fail to parse and fall back to a
                      paragraph. In listBlock's child parser the second
                      alternative has no `try`, so it fails having
                      consumed the first item's indentation. The
                      existing nesting tests only ever used one nested
                      item.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:15:33 -04:00

289 lines
9.9 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,
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 (PlainS a : PlainS b : rest) = mkRun (PlainS (a <> b) : rest)
mkRun (x : rest) = x : mkRun rest
mkRun [] = []
-- * generation
instance Arbitrary DocSpec where
arbitrary = sized $ \size -> do
count <- chooseInt (1, 1 + min 3 (size `div` 8))
DocSpec <$> vectorOf count blockSpec
-- an empty document is legitimate, `document` is a `many`
shrink (DocSpec blocks) = DocSpec <$> shrinkList shrinkBlock blocks
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. The expectations are shared,
-- only this differs between Markdown and Djot.
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
}
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"
}
where
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 :: DocSpec -> Document
expected (DocSpec blocks) = Doc $ map expectedBlock blocks
expectedBlock :: BlockSpec -> Element
expectedBlock = \case
ParagraphS run -> Paragraph (P (expectedRun run)) mempty
HeadingS level run -> Heading (H {level, text = expectedRun run}) mempty
QuoteS run -> BlockQuote (Q [Transparent (expectedRun run)]) mempty
ListS marker items -> List (expectedList marker items) mempty
expectedList :: Marker -> [ItemSpec] -> List
expectedList marker items = L {list_type = expectedType marker, items = map expectedItem items}
expectedType :: Marker -> ListType
expectedType (Bullet _) = Unordered {style = Nothing}
expectedType (Numbered _) = Ordered {start_number = Nothing, style = Nothing}
expectedItem :: ItemSpec -> ListItem
expectedItem (ItemS run nested) = LI {content = Transparent (expectedRun run) : child}
where
child = case nested of
Nothing -> []
Just (marker, runs) -> [List (expectedList 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}