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>
229 lines
8.8 KiB
Haskell
229 lines
8.8 KiB
Haskell
{-# LANGUAGE OverloadedStrings #-}
|
|
|
|
-- | Generators shared by the parser test suites.
|
|
--
|
|
-- Everything a property generates lives behind a newtype with an `Arbitrary`
|
|
-- instance rather than being pulled in with `pick`. `pick` embeds its value
|
|
-- with `forAll (return a)`, which cannot shrink, so a failing property would
|
|
-- report whatever random 10 character string happened to trip it. Going
|
|
-- through `Arbitrary` gets shrinking back, at the cost of having to write the
|
|
-- shrinkers by hand.
|
|
--
|
|
-- Those shrinkers have to preserve the invariants their generator established
|
|
-- (non-empty, alphabet, ranges). A shrunk counterexample that no longer
|
|
-- satisfies what the property assumes is worse than no shrinking at all, it
|
|
-- reports a failure for an input the test was never making a claim about.
|
|
module Test.Gen
|
|
( -- * sized primitives
|
|
linear,
|
|
textOf,
|
|
asciiChar,
|
|
alphaChar,
|
|
alphaNumChar,
|
|
wordsOf,
|
|
|
|
-- * shrinking
|
|
shrinkTextWith,
|
|
|
|
-- * wrappers
|
|
AsciiText (..),
|
|
AlphaText (..),
|
|
AlphaNumText (..),
|
|
VerbatimText (..),
|
|
UrlText (..),
|
|
HeaderLevel (..),
|
|
|
|
-- * escaping
|
|
Escaper (..),
|
|
backslashEscaper,
|
|
unescapable,
|
|
markdownEscaper,
|
|
djotEscaper,
|
|
escapeWith,
|
|
EscapedText (..),
|
|
escapedText,
|
|
MarkdownText (..),
|
|
DjotText (..),
|
|
)
|
|
where
|
|
|
|
import Data.Text (Text)
|
|
import qualified Data.Text as T
|
|
import Test.QuickCheck (Arbitrary (arbitrary, shrink), Gen, chooseInt, elements, frequency, sized, suchThat, vectorOf)
|
|
|
|
-- | Hedgehog's `Range.linear` grows its upper bound with the size of the test
|
|
-- case, this is the same thing in terms of QuickCheck's size parameter.
|
|
linear :: Int -> Int -> Gen Int
|
|
linear lo hi = sized $ \size -> chooseInt (lo, lo + ((hi - lo) * min size 99) `div` 99)
|
|
|
|
textOf :: Gen Char -> Int -> Int -> Gen Text
|
|
textOf char_gen lo hi = do
|
|
len <- linear lo hi
|
|
T.pack <$> vectorOf len char_gen
|
|
|
|
asciiChar :: Gen Char
|
|
asciiChar = elements ['\0' .. '\127']
|
|
|
|
alphaChar :: Gen Char
|
|
alphaChar = elements (['a' .. 'z'] <> ['A' .. 'Z'])
|
|
|
|
alphaNumChar :: Gen Char
|
|
alphaNumChar = elements (['a' .. 'z'] <> ['A' .. 'Z'] <> ['0' .. '9'])
|
|
|
|
-- | One to three alphanumeric words separated by single spaces. Never starts or
|
|
-- ends with a space, so it can sit directly after a block marker without the
|
|
-- marker's own trailing space being ambiguous.
|
|
wordsOf :: Gen Text
|
|
wordsOf = do
|
|
count <- chooseInt (1, 3)
|
|
T.unwords <$> vectorOf count (textOf alphaNumChar 1 8)
|
|
|
|
-- | Shrink by taking shorter prefixes, dropping any that no longer satisfy the
|
|
-- predicate the generator guaranteed. Prefixes keep the first character, which
|
|
-- is what stops a shrunk value from acquiring a leading space or a digit where
|
|
-- the original had a letter.
|
|
shrinkTextWith :: (Text -> Bool) -> Text -> [Text]
|
|
shrinkTextWith valid t = filter valid [T.take n t | n <- [1 .. T.length t - 1]]
|
|
|
|
newtype AsciiText = AsciiText Text
|
|
deriving (Show)
|
|
|
|
instance Arbitrary AsciiText where
|
|
arbitrary = AsciiText <$> textOf asciiChar 0 100
|
|
shrink (AsciiText t) = AsciiText <$> shrinkTextWith (const True) t
|
|
|
|
newtype AlphaText = AlphaText Text
|
|
deriving (Show)
|
|
|
|
instance Arbitrary AlphaText where
|
|
arbitrary = AlphaText <$> textOf alphaChar 1 10
|
|
shrink (AlphaText t) = AlphaText <$> shrinkTextWith (not . T.null) t
|
|
|
|
newtype AlphaNumText = AlphaNumText Text
|
|
deriving (Show)
|
|
|
|
instance Arbitrary AlphaNumText where
|
|
arbitrary = AlphaNumText <$> textOf alphaNumChar 1 10
|
|
shrink (AlphaNumText t) = AlphaNumText <$> shrinkTextWith (not . T.null) t
|
|
|
|
-- | Content for a verbatim span. Djot says verbatim content is literal, so this
|
|
-- ranges over printable ascii rather than words; backticks are excluded because
|
|
-- they would close the span, and the ends are kept non blank because a space
|
|
-- next to a delimiter is subject to a stripping rule of its own.
|
|
newtype VerbatimText = VerbatimText Text
|
|
deriving (Show)
|
|
|
|
instance Arbitrary VerbatimText where
|
|
arbitrary = VerbatimText <$> (textOf verbatimChar 1 20 `suchThat` wellFormed)
|
|
where
|
|
verbatimChar = elements $ filter (/= '`') [' ' .. '~']
|
|
wellFormed t = not (T.null t) && T.head t /= ' ' && T.last t /= ' '
|
|
shrink (VerbatimText t) =
|
|
VerbatimText <$> shrinkTextWith (\s -> not (T.null s) && T.last s /= ' ') t
|
|
|
|
-- | Something that can sit inside @(...)@ as a link destination: no spaces, no
|
|
-- closing paren, no newline.
|
|
newtype UrlText = UrlText Text
|
|
deriving (Show)
|
|
|
|
instance Arbitrary UrlText where
|
|
arbitrary = UrlText <$> textOf urlChar 1 20
|
|
where
|
|
urlChar = elements $ ['a' .. 'z'] <> ['A' .. 'Z'] <> ['0' .. '9'] <> "./:-_~"
|
|
shrink (UrlText t) = UrlText <$> shrinkTextWith (not . T.null) t
|
|
|
|
newtype HeaderLevel = HeaderLevel Int
|
|
deriving (Show)
|
|
|
|
instance Arbitrary HeaderLevel where
|
|
arbitrary = HeaderLevel <$> chooseInt (1, 6)
|
|
shrink (HeaderLevel level) = HeaderLevel <$> [1 .. level - 1]
|
|
|
|
-- | How a syntax lets you write a character that would otherwise be markup.
|
|
--
|
|
-- `escapeChar` is Nothing for a syntax with no escape mechanism at all, in
|
|
-- which case the only way to get a special character into plain text is not to
|
|
-- generate one. Markdown is currently in that boat.
|
|
data Escaper = Escaper
|
|
{ specialChars :: [Char],
|
|
escapeChar :: Maybe (Char -> Text)
|
|
}
|
|
|
|
backslashEscaper :: [Char] -> Escaper
|
|
backslashEscaper cs = Escaper {specialChars = cs, escapeChar = Just $ \c -> T.pack ['\\', c]}
|
|
|
|
unescapable :: [Char] -> Escaper
|
|
unescapable cs = Escaper {specialChars = cs, escapeChar = Nothing}
|
|
|
|
-- | The characters that start markup in the Markdown parser's inline layer.
|
|
-- \`*[~ are the ones `plain_text` breaks a text node on, _ starts an underline
|
|
-- and ! an image, both of which are only recognised at the start of an inline
|
|
-- run. Markdown.hs has no backslash escape handling, so this is `unescapable`.
|
|
markdownEscaper :: Escaper
|
|
markdownEscaper = unescapable "`*[~_!<"
|
|
|
|
-- | Djot's inline specials. The syntax reference says a backslash escapes any
|
|
-- ASCII punctuation, and every character here is ASCII punctuation, so all of
|
|
-- them are representable in plain text rather than having to be avoided.
|
|
--
|
|
-- The quote characters are deliberately absent. They are not markup, they are
|
|
-- input to the smart punctuation pass, and what an escaped quote should turn
|
|
-- into is a separate question from whether escaping works at all.
|
|
djotEscaper :: Escaper
|
|
djotEscaper = backslashEscaper "\\`*_^~[]{}<>$:!-.=+"
|
|
|
|
-- | A piece of source text paired with the literal it is supposed to parse to.
|
|
-- For an escaping syntax these differ, for `unescapable` they are equal because
|
|
-- the only representable literals are the ones needing no escape.
|
|
data EscapedText = EscapedText
|
|
{ rendered :: Text,
|
|
literal :: Text
|
|
}
|
|
deriving (Show)
|
|
|
|
-- | Write a literal out in a form the syntax will read back as that literal.
|
|
escapeWith :: Escaper -> Text -> Text
|
|
escapeWith escaper = T.concatMap render
|
|
where
|
|
render c = case escaper.escapeChar of
|
|
Just escape | c `elem` escaper.specialChars -> escape c
|
|
_ -> T.singleton c
|
|
|
|
-- | Generate a literal the syntax can represent, paired with its source form.
|
|
--
|
|
-- Which literals are representable depends on the escaper: one with an escape
|
|
-- mechanism can carry any special character, one without can only carry the
|
|
-- characters that aren't special in the first place.
|
|
escapedText :: Escaper -> Gen Char -> Int -> Int -> Gen EscapedText
|
|
escapedText escaper base lo hi = escaped escaper <$> textOf char lo hi
|
|
where
|
|
ordinary = base `suchThat` (`notElem` escaper.specialChars)
|
|
char = case escaper.escapeChar of
|
|
-- nothing needing an escape can be represented, so don't generate it
|
|
Nothing -> ordinary
|
|
Just _ -> frequency [(3, ordinary), (1, elements escaper.specialChars)]
|
|
|
|
escaped :: Escaper -> Text -> EscapedText
|
|
escaped escaper t = EscapedText {literal = t, rendered = escapeWith escaper t}
|
|
|
|
-- | Text destined for the Markdown parser's inline layer, in source form and in
|
|
-- the form it should come back out as. The two are equal for as long as
|
|
-- `markdownEscaper` is `unescapable`; pointing it at `backslashEscaper` once
|
|
-- Markdown.hs handles escapes turns every property using this into a test of
|
|
-- that handling, without the properties themselves changing.
|
|
newtype MarkdownText = MarkdownText EscapedText
|
|
deriving (Show)
|
|
|
|
instance Arbitrary MarkdownText where
|
|
arbitrary = MarkdownText <$> escapedText markdownEscaper alphaNumChar 1 20
|
|
shrink (MarkdownText t) = MarkdownText . escaped markdownEscaper <$> shrinkTextWith (not . T.null) t.literal
|
|
|
|
-- | The same for Djot, where the escaper does have an escape mechanism, so the
|
|
-- generated literals contain the special characters and the source form is the
|
|
-- backslash escaped version of them.
|
|
newtype DjotText = DjotText EscapedText
|
|
deriving (Show)
|
|
|
|
instance Arbitrary DjotText where
|
|
arbitrary = DjotText <$> escapedText djotEscaper alphaNumChar 1 20
|
|
shrink (DjotText t) = DjotText . escaped djotEscaper <$> shrinkTextWith (not . T.null) t.literal
|