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>
289 lines
9.9 KiB
Haskell
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}
|