packages feed

skylighting-core-0.15: test/test-skylighting.hs

{-# OPTIONS_GHC -fno-warn-orphans #-}
{-# LANGUAGE CPP                 #-}
{-# LANGUAGE OverloadedStrings   #-}
{-# LANGUAGE ScopedTypeVariables #-}
module Main where
import Data.Maybe
import Data.Aeson (decode, encode)
import Data.Algorithm.Diff
import qualified Data.ByteString.Lazy as BL
import qualified Data.Map as Map
#if !MIN_VERSION_base(4,11,0)
import Data.Semigroup ((<>), Semigroup)
#endif
import Data.Text (Text)
import qualified Data.Text as Text
import qualified Data.Text.IO as Text
import qualified Data.Text.Encoding as TE
import System.Directory
import System.Environment (getArgs)
import System.FilePath
import System.Exit (exitFailure)
import System.IO (hSetEncoding, utf8, openFile, IOMode(..))
import Test.QuickCheck
import Test.Tasty
import Test.Tasty.Golden.Advanced (goldenTest)
import Test.Tasty.HUnit
import Test.Tasty.QuickCheck (testProperty)
import Text.Show.Pretty
import GHC.IO.Encoding (setLocaleEncoding)
import Skylighting.Core

readTextFile :: FilePath -> IO Text
readTextFile fp = do
  h <- openFile fp ReadMode
  hSetEncoding h utf8
  Text.hGetContents h

tokToText :: Token -> Text
tokToText (_, s) = s

xmlPath :: FilePath
xmlPath = "xml/"

main :: IO ()
main = do
  setLocaleEncoding utf8
  sMap <- do
      result <- loadSyntaxesFromDir xmlPath
      case result of
          Left e -> do
              putStrLn $ "Error loading syntax definitions from " <> xmlPath <> ": " <> e
              exitFailure
          Right m -> return m

  let syntaxes = Map.elems sMap
      defConfig = TokenizerConfig { traceOutput = False
                                  , syntaxMap = sMap
                                  }
  let getMatchers = map rMatcher .  concatMap cRules . sContexts
  let getRegexFromMatcher (RegExpr re) = Just $ reString re
      getRegexFromMatcher _ = Nothing
  let getRegexesFromSyntax = mapMaybe getRegexFromMatcher . getMatchers
  inputs <- filter (\fp -> take 1 fp /= ".")
         <$> getDirectoryContents ("test" </> "cases")
  allcases <- mapM (fmap (Text.take 240)
                    . readTextFile . (("test" </> "cases") </>)) inputs
  args <- getArgs
  let regen = "--accept" `elem` args
  defaultTheme <- BL.readFile ("test" </> "default.theme")
  defaultMain $ testGroup "skylighting tests" $
    [ testGroup "tokenizer tests" $
        map (tokenizerTest defConfig sMap regen) inputs
    , testGroup "FromJSON instance tests"
       [ testCase "decode simple color" $
            Just (RGB 0x15 0xff 0xa0) @=? decode "\"#15ffa0\""
       , testCase "decode TokenStyle" $
            Just (TokenStyle{tokenColor = Just (RGB 0x1f 0x1c 0x1b),
                             tokenBackground = Nothing,
                             tokenBold = True,
                             tokenItalic = False,
                             tokenUnderline = False }) @=?
            decode "{ \"text-color\": \"#1f1c1b\", \"bold\": true }"
       , testCase "decode KDE theme to Style" $
            Just kate @=? decode defaultTheme
       , testCase "round trip style -> theme -> style" $
            Just kate @=? decode (encode kate)
       ]
    , testGroup "Skylighting" $
      [ testCase "syntaxesByFilename" $
            ["Perl"] @=?
              map sName (syntaxesByFilename sMap "foo/bar.pl")
      ]
    , testGroup "Doesn't hang or drop text on a mixed syntax sample" $
        map (noDropTest defConfig allcases) syntaxes
    , testGroup "Doesn't hang or drop text on fuzz" $
        map (\syn -> testProperty (Text.unpack (sName syn)) (p_no_drop defConfig syn))
        syntaxes
    , testGroup "All regexes compile" $
        map
           (\syn -> testGroup ("syntax " <> sFilename syn)
            (map
              (\regex ->
                testCase ("regex " <>
                           (Text.unpack $ TE.decodeUtf8 regex) <> " in "
                           <> sFilename syn)
             $ case compileRegex True False regex of
                 Right _ -> assertBool "regex does not compile" True
                 Left e -> assertFailure ("regex does not compile: " <> show e))
                         $ getRegexesFromSyntax syn))
        syntaxes
    , testGroup "Regex module" $ map regexTest regexTests
    , testGroup "Regex module compile errors" $
        map regexErrorTest regexErrorTests
    , testGroup "Regression tests" $
      let perl = maybe (error "could not find Perl syntax") id
                             (lookupSyntax "Perl" sMap)
          html = maybe (error "could not find HTML syntax") id
                             (lookupSyntax "html" sMap)
          cpp  = maybe (error "could not find CPP syntax") id
                             (lookupSyntax "cpp" sMap)
          bash  = maybe (error "could not find bash syntax") id
                             (lookupSyntax "bash" sMap)
          c    = maybe (error "could not find C syntax") id
                             (lookupSyntax "c" sMap)
          dosbat = maybe (error "could not find MS-DOS Batch syntax") id
                             (lookupSyntax "MS-DOS Batch" sMap)
          cmake = maybe (error "could not find CMake syntax") id
                             (lookupSyntax "CMake" sMap)
          lua = maybe (error "could not find Lua syntax") id
                             (lookupSyntax "Lua" sMap)
          awk = maybe (error "could not find AWK syntax") id
                             (lookupSyntax "AWK" sMap)
          glsl = maybe (error "could not find GLSL syntax") id
                             (lookupSyntax "GLSL" sMap)
          makefile = maybe (error "could not find Makefile syntax") id
                             (lookupSyntax "Makefile" sMap)
          markdown = maybe (error "could not find Markdown syntax") id
                             (lookupSyntax "Markdown" sMap) in
      [ testCase "perl NUL case" $ Right
             [[(OtherTok,"s\NULb\NUL")
              ,(StringTok,"c")
              ,(OtherTok,"\NUL")]]
             @=? tokenize defConfig perl "s\0b\0c\0"
      , testCase "perl backslash case 1" $ Right
          [[(OtherTok,"m\\'")]]
            @=? tokenize defConfig perl
                     "m\\'"
      , testCase "perl backslash case 2" $ Right
          [[(OtherTok,"m\\a\\")]]
            @=? tokenize defConfig perl
                     "m\\a\\"
      , testCase "perl quoting case" $ Right
          [[(KeywordTok,"my")
           ,(NormalTok," ")
           ,(DataTypeTok,"$foo")
           ,(NormalTok," = ")
           ,(OtherTok,"q/")
           ,(SpecialStringTok,"bar")
           ,(OtherTok,"/")
           ,(NormalTok,";")]
          ,[(KeywordTok,"my")
           ,(NormalTok," ")
           ,(DataTypeTok,"$baz")
           ,(NormalTok," = ")
           ,(OtherTok,"'")
           ,(SpecialStringTok,"quux")
           ,(OtherTok,"'")
           ,(NormalTok,";")]]
             @=? tokenize defConfig perl
                     "my $foo = q/bar/;\nmy $baz = 'quux';\n"
      , testCase "cpp floats" $ Right
           [ [ (FloatTok,"0.1") , (BuiltInTok,"f")]
           , [ (FloatTok,"1.0") , (BuiltInTok,"f")]
           , [ (OperatorTok,"-") , (FloatTok,"0.1") , (BuiltInTok,"f")]
           , [ (OperatorTok,"-") , (FloatTok,"1.0") , (BuiltInTok,"F")]
           , [ (OperatorTok,"-") , (FloatTok,"1.0") , (BuiltInTok,"L")]
           , [ (FloatTok,"1e3")]
           , [ (OperatorTok,"-") , (FloatTok,"15e+3")]
           , [ (FloatTok,"0.") , (BuiltInTok,"f")]
           , [ (FloatTok,"1.") , (BuiltInTok,"F")]
           , [ (FloatTok,"1.E3")]
           ] @=? tokenize defConfig cpp
                     "0.1f\n1.0f\n-0.1f\n-1.0F\n-1.0L\n1e3\n-15e+3\n0.f\n1.F\n1.E3"
      , testCase "cpp identifier (#76)" $ Right
           [ [ (NormalTok,"ng_or") ]
           ] @=? tokenize defConfig cpp "ng_or"

      , testCase "c '\\0' (#82)" $ Right
           [ [ (CharTok,"'"),(SpecialCharTok,"\\0"),(CharTok,"'") ]
           ] @=? tokenize defConfig c "'\\0'"

      , testCase "c very long integer (#81)" $ Right
           [ [ (DecValTok, "1111111111111111111111") ]
           ] @=? tokenize defConfig c "1111111111111111111111"

      , testCase "Chinese characters in HTML (#110)" $ Right
          [ [ ( NormalTok , "\35797\65306" ) , ( DataTypeTok , "<" ) ,
              ( KeywordTok , "a" ) , ( DataTypeTok , ">" ) ]
          ] @=? tokenize defConfig html "试:<a>"

      , testCase "Bash closing brace (#119)" $ Right
          [ [ ( FunctionTok , "f()" )
            , ( NormalTok , " " )
            , ( KeywordTok , "{" ) ]
          , [ ( NormalTok , "    " )
            , ( BuiltInTok , "echo" )
            , ( NormalTok , " " )
            , ( OperatorTok , ">" )
            , ( NormalTok , " f" ) ]
          , [ ( KeywordTok , "}" ) ] ]
             @=? tokenize defConfig bash
                     "f() {\n    echo > f\n}\n"

      , testCase "LineContinue with char attribute (dosbat ^)" $ Right
          [ [ ( BuiltInTok , "echo" )
            , ( NormalTok , " foo " )
            , ( SpecialCharTok , "^" ) ]
          , [ ( NormalTok , "bar" ) ] ]
             @=? tokenize defConfig dosbat "echo foo ^\nbar"

      , testCase "keyword rule insensitive attribute (cmake)" $ Right
          [ [ ( ControlFlowTok , "if" )
            , ( NormalTok , "(" )
            , ( OtherTok , "YES" )
            , ( NormalTok , ")" ) ]
          , [ ( ControlFlowTok , "if" )
            , ( NormalTok , "(" )
            , ( OtherTok , "yes" )
            , ( NormalTok , ")" ) ] ]
             @=? tokenize defConfig cmake "if(YES)\nif(yes)"

      , testCase "keyword rule additionalDeliminator attribute (lua)" $ Right
          [ [ ( VariableTok , "a" )
            , ( NormalTok , " " )
            , ( OperatorTok , "=" )
            , ( NormalTok , " " )
            , ( KeywordTok , "nil" )
            , ( OperatorTok , "." )
            , ( VariableTok , "x" ) ] ]
             @=? tokenize defConfig lua "a = nil.x"

      , testCase "Int respects general weakDeliminator (awk)" $ Right
          [ [ ( NormalTok , "x" )
            , ( OperatorTok , "@" )
            , ( NormalTok , "5" ) ] ]
             @=? tokenize defConfig awk "x@5"

      , testCase "C floating-point literal (#174)" $ Right
          [ [ ( DataTypeTok , "double")
            , ( NormalTok , " x " )
            , ( OperatorTok , "=" )
            , ( NormalTok , " " )
            , ( FloatTok , "0.5")
            , ( OperatorTok , ";" ) ] ]
             @=? tokenize defConfig c
                     "double x = 0.5;\n"

      -- HlCOct matches C-style octals (0 followed by octal digits);
      -- HlCHex matches 0x followed by hex digits:
      , testCase "HlCOct and HlCHex rules (glsl)" $ Right
          [ [ ( NormalTok , "x " )
            , ( OperatorTok , "=" )
            , ( NormalTok , " " )
            , ( BaseNTok , "0755" )
            , ( OperatorTok , ";" ) ]
          , [ ( NormalTok , "y " )
            , ( OperatorTok , "=" )
            , ( NormalTok , " " )
            , ( BaseNTok , "0x1F" )
            , ( OperatorTok , ";" ) ] ]
             @=? tokenize defConfig glsl "x = 0755;\ny = 0x1F;"

      -- As in KDE, the Float rule requires a '.', so 5e2 is matched
      -- by Int (leaving e2 unmatched), and an incomplete exponent is
      -- excluded from the match:
      , testCase "Float rule requires dot; exponent all-or-nothing (glsl)" $
          Right
          [ [ ( NormalTok , "x " )
            , ( OperatorTok , "=" )
            , ( NormalTok , " " )
            , ( DecValTok , "5" )
            , ( NormalTok , "e2" )
            , ( OperatorTok , ";" ) ]
          , [ ( NormalTok , "y " )
            , ( OperatorTok , "=" )
            , ( NormalTok , " " )
            , ( FloatTok , "1.5" )
            , ( NormalTok , "e" )
            , ( OperatorTok , "+;" ) ] ]
             @=? tokenize defConfig glsl "x = 5e2;\ny = 1.5e+;"

      -- As in KDE, Int and Float rules do not consume a leading sign:
      , testCase "Int does not include leading minus (glsl)" $ Right
          [ [ ( NormalTok , "x " )
            , ( OperatorTok , "=" )
            , ( NormalTok , " " )
            , ( OperatorTok , "-" )
            , ( DecValTok , "15" )
            , ( OperatorTok , ";" ) ] ]
             @=? tokenize defConfig glsl "x = -15;"

      -- As in KDE, column and firstNonspaceColumn restart on every
      -- physical line; a line continuation only suppresses the
      -- previous line's lineEndContext.  So the column="0" Target
      -- rule applies to "bar" on the continuation line:
      , testCase "column restarts after LineContinue (makefile)" $ Right
          [ [ ( DecValTok , "foo " )
            , ( CharTok , "\\" ) ]
          , [ ( DecValTok , "bar:" )
            , ( DataTypeTok , " baz" ) ] ]
             @=? tokenize defConfig makefile "foo \\\nbar: baz"

      -- As in KDE, an empty line applies the lineEmptyContext
      -- switches of successive top contexts until #stay (and
      -- lineEmptyContext defaults to lineEndContext).  Here the empty
      -- line pops blockquote and then enters Normal Text's
      -- lineEmptyContext (find-code-block), so the indented line
      -- becomes a code block:
      , testCase "empty line applies successive lineEmptyContexts (markdown)" $
          Right
          [ [ ( AttributeTok , "> quote" ) ]
          , []
          , [ ( InformationTok , "    code" ) ] ]
             @=? tokenize defConfig markdown "> quote\n\n    code"

      -- A zero-progress cycle of context switches driven by a
      -- lookahead rule must trigger the endless-loop guard (as in
      -- KDE), aborting the line instead of hanging; in particular a
      -- lookahead match must not reset the loop counter:
      , testCase "zero-progress lookahead loop aborts line" $
          Right [ [ ( NormalTok, "xy" ) ] ]
             @=? tokenize defConfig{ syntaxMap =
                    addSyntaxDefinition loopSyntax (syntaxMap defConfig) }
                  loopSyntax "xy"
      ]
    ]

-- | A syntax definition with a zero-progress loop: a lookahead rule
-- pushes a context that immediately pops back via fallthrough.
loopSyntax :: Syntax
loopSyntax = either error id $ parseSyntaxDefinitionFromText "loop.xml" $
     "<language name=\"Loop\" version=\"1\" kateversion=\"5.0\""
  <> " section=\"Other\" extensions=\"\">"
  <> "<highlighting><contexts>"
  <> "<context name=\"start\" attribute=\"Normal Text\""
  <> " lineEndContext=\"#stay\">"
  <> "<AnyChar lookAhead=\"1\" context=\"other\" String=\"x\"/>"
  <> "</context>"
  <> "<context name=\"other\" attribute=\"Normal Text\""
  <> " lineEndContext=\"#stay\" fallthroughContext=\"#pop\">"
  <> "</context>"
  <> "</contexts><itemDatas>"
  <> "<itemData name=\"Normal Text\" defStyleNum=\"dsNormal\"/>"
  <> "</itemDatas></highlighting></language>"

compareValues :: FilePath -> Text -> Text -> IO (Maybe String)
compareValues referenceFile expected actual =
   if expected == actual
      then return $ Nothing
      else return $ Just $ makeDiff referenceFile
                           (Text.lines expected) (Text.lines actual)

makeDiff :: FilePath -> [Text] -> [Text] -> String
makeDiff referenceFile expected actual = unlines $
  [ "--- " ++ referenceFile
  , "+++ actual" ] ++
  map (Text.unpack . vividize) (filter notBoth (getDiff expected actual))
    where notBoth (Both _ _ ) = False
          notBoth _           = True

instance Arbitrary Text where
  arbitrary = Text.pack <$> arbitrary
  shrink xs = Text.pack <$> shrink (Text.unpack xs)

p_no_drop :: TokenizerConfig -> Syntax -> Text -> Bool
p_no_drop cfg syntax t =
  case tokenize cfg syntax t of
       Right ts -> Text.lines t == map (mconcat . map tokToText) ts
       Left _   -> False

noDropTest :: TokenizerConfig -> [Text] -> Syntax -> TestTree
noDropTest cfg inps syntax =
  localOption (mkTimeout 75000000)
  $ testCase (Text.unpack (sName syntax))
  $ mapM_ go inps
    where go inp =
              case tokenize cfg syntax inp of
                    Right ts -> assertBool ("Text has been dropped:\n" ++ diffs)
                                 (inplines == toklines)
                         where inplines = Text.lines inp
                               toklines = map (mconcat . map tokToText) ts
                               diffs = makeDiff "expected" inplines toklines
                    Left  e  ->
                      assertFailure ("Unexpected error: " ++ e ++ "\ninput = " ++ show inp)

tokenizerTest :: TokenizerConfig -> SyntaxMap -> Bool -> FilePath -> TestTree
tokenizerTest cfg sMap regen inpFile = localOption (mkTimeout 75000000) $
  goldenTest testname getExpected getActual
      (compareValues referenceFile) updateGolden
  where testname = lang ++ " tokenizing of " ++ inpFile
        getExpected = readTextFile referenceFile
        getActual = do
          code <- readTextFile (casesdir </> inpFile)
          syntax <- case lookupSyntax (Text.pack lang) sMap of
                         Just s  -> return s
                         Nothing -> fail $
                            "Could not find syntax definition for " ++ lang
          case tokenize cfg syntax $! code of
                 Left e   -> fail e
                 Right ls -> return $ Text.pack $ ppShow ls ++ "\n"
        updateGolden = if regen
                          then Text.writeFile referenceFile
                          else \_ -> return ()
        expecteddir = "test" </> "expected"
        casesdir = "test" </> "cases"
        referenceFile = expecteddir </> inpFile <.> "native"
        lang = drop 1 $ takeExtension inpFile

regexTest :: (String, String, Maybe (String, [(Int,String)])) -> TestTree
regexTest (re, inp, expected) =
  testCase ("/" ++ re ++ "/ " ++ inp) $
    expected @=? testRegex True re inp

regexTests :: [(String, String, Maybe (String, [(Int,String)]))]
regexTests =
  [ (".", "aab", Just ("a", []))
  , ("ab", "aab", Nothing)
  , ("ab", "abb", Just ("ab", []))
  , ("a{2}b", "aaab", Nothing)
  , ("a{2,}b", "aaab", Just ("aaab", []))
  , ("a{2,3}b", "aab", Just ("aab", []))
  , ("a{2,3}b", "aaab", Just ("aaab", []))
  , ("a(b)", "abb", Just ("ab", [(1,"b")]))
  , ("a(b.)*", "abbbcb", Just ("abbbc", [(1,"bc")]))
  , ("a(?:b.)*", "abbbcb", Just ("abbbc", []))
  , ("a(?=b)", "abb", Just ("a", []))
  , ("a(?=b)", "acb", Nothing)
  , ("a(?!b)", "abb", Nothing)
  , ("a(?!b)", "acb", Just ("a", []))
  , ("a?b+", "bbb", Just ("bbb", []))
  , ("a?b+", "abbb", Just ("abbb", []))
  , ("a?b+", "ac", Nothing)
  , ("a*", "bbb", Just ("", []))
  , ("abc|ab$", "ab", Just ("ab", []))
  , ("abc|ab$", "abcd", Just ("abc", []))
  , ("abc|ab$", "abd", Nothing)
  , ("[\\x50-\\x51]*", "PQR", Just ("PQ", []))
  , ("[\\x{2019}]*", "\x2019PQR", Just ("\x2019", []))
  , ("(?:ab)*|a.*", "abababa", Just ("ababab", []))
    -- leftmost-first: first alternative matches, so second is never tried
  , ("a[b-e]*", "abcdefg", Just ("abcde", []))
  , ("a[b-e\\n-]*", "abcde\nb-bcfg", Just ("abcde\nb-bc", []))
  , ("^\\s+\\S+\\s+$", "   abc  ", Just ("   abc  ", []))
  , ("\\$", "$$", Just ("$", []))
  , ("[\\z12bb]", "\x12bb", Just ("\x12bb", []))
  , ("[\\p{Lu}\\p{Ll}]*", "Σφa1B", Just ("Σφa", []))
  , ("\\bhello\\b|hell", "hello there", Just ("hello", []))
  , ("\\bhello\\b|hell", "hellothere", Just ("hell", []))
  , ("u\\b", "ue", Nothing)
  , ("[[:space:]]{2,4}.", "  abc", Just ("  a", []))
  , ("[[:space:]]{2,4}.", " abc", Nothing)
  , ("[[:space:]]{2,4}.", "     abc", Just ("     ", []))
  , ("((..)\\+\\2)", "aa+aabb+bbbc+cb",
          Just ("aa+aa", [(1,"aa+aa"), (2,"aa")]))
  , ("(\\d+)/(\\d+) == \\{1}", "22/2 == 22",
         Just ("22/2 == 22", [(1,"22"), (2,"2")]))
  , ("([a-z]+){2}", "htabc", Just ("htabc", [(1,"c")]))
  , ("((.+)(.+)(.+))*", (replicate 400 'a'),
          Just (replicate 400 'a',
                 [(1, replicate 400 'a')
                 ,(2,replicate 398 'a')
                 ,(3,"a")
                 ,(4,"a")]))
  , ("a++a", "aaaaa", Nothing)
  , ("\\w+e", "aaaeeee", Just ("aaaeeee", []))
  , ("\\w+?e", "aaaeeee", Just ("aaae", []))
  , ("a+b??", "aaab", Just ("aaa", []))
  , ("\\([a-z]+(?R)*\\)", "(aa(b(c)(d)))", Just ("(aa(b(c)(d)))", []))
  , ("a{}", "aaa", Nothing)
  , ("a{}", "a{}", Just ("a{}", []))
  , ("a{3", "a{3", Just ("a{3", []))
  , ("(?|(abc)|(def))", "abc", Just ("abc", [(1,"abc")]))
  , ("(?|(abc)|(def))", "def", Just ("def", [(1,"def")]))
  , ("(?:(abc)|(def))", "def", Just ("def", [(2,"def")]))
  , ("((?i:infinity|e|pi)|NaN)", "InfInity",
      Just ("InfInity", [(1,"InfInity")]))
--  , ("(?im)foo", "Foo", Just ("Foo", []))
  , ("d(?=(bc)|(ef))", "def", Just ("d", [(2,"ef")]))
  , ("([bcd])([efg])(?2)(?1)", "befd", Just ("befd", [(1,"b"),(2,"e")]))
  , ("([abc](?1)*)", "abcd", Just ("abc", [(1,"abc")]))
  , ("(x(?1)*)", "xxxxy", Just ("xxxx", [(1,"xxxx")]))
  , ("a|\\((?0)\\)", "(((a)))", Just ("(((a)))", []))
  , ("([abc](x(?1))*)", "axbxcc", Just ("axbxc", [(1,"axbxc"),(2,"xbxc")]))
    -- group 2's last iteration is "xbxc": the recursion (?1) inside it
    -- matches "bxc", and inner iterations' captures are overwritten
  , ("[\\p{Nd}]", "33", Just ("3", []))
  , ("\\p{N}", "33", Just ("3", []))
    -- {m,n} with m > n is a compile error (see regexErrorTests; it
    -- used to send the compiler into an infinite loop, and later was
    -- treated as a literal)
    -- lazy quantifiers in lookbehinds used to hang the matcher:
  , ("ab(?<=a+?b)c", "abc", Just ("abc", []))
  , ("ab(?<=a+?)c", "abc", Nothing)
    -- recursive subroutine calls that consume no input used to hang;
    -- now re-entering a subroutine at the same offset just fails:
  , ("x|(?R)", "x", Just ("x", []))
  , ("a|(?R)(?R)", "aa", Just ("a", []))
    -- leftmost-first: the first alternative succeeds on "a"
    -- backward matching (lookbehind, \b) after multibyte characters
    -- used to land inside a UTF-8 sequence:
  , ("\x00e9(?<=\x00e9)x", "\x00e9x", Just ("\x00e9x", []))
  , ("\x00e9\\bx", "\x00e9x", Nothing)
  , ("\x2019(?<=\x2019)x", "\x2019x", Just ("\x2019x", []))
  , ("\x00e9(?<!\x00e9)x", "\x00e9x", Nothing)
    -- (?i:...) is scoped to the group; it used to leak to the rest
    -- of the pattern:
  , ("(?i:a)b", "Ab", Just ("Ab", []))
  , ("(?i:a)b", "AB", Nothing)
  , ("x(?i:a(?-i:b)c)y", "xAbCy", Just ("xAbCy", []))
  , ("x(?i:a(?-i:b)c)y", "xABCy", Nothing)
    -- [[:graph:]] and [[:word:]] used to be unparseable (and graph
    -- meant "print"):
  , ("[[:graph:]]+", "ab cd", Just ("ab", []))
  , ("[[:word:]]+", "a_b-c", Just ("a_b", []))
  , ("[^[:graph:]]", " a", Just (" ", []))
  , ("[[:alpha:][:digit:]]+", "ab1 x", Just ("ab1", []))
    -- subroutine calls to groups nested inside other groups used to
    -- be silently ignored (matching the empty string):
  , ("((a)b)(?2)", "aba", Just ("aba", [(1,"ab"),(2,"a")]))
  , ("((a)b)(?2)", "abx", Nothing)
    -- character classes, escaped literals, and backreferences used
    -- to ignore case-insensitivity:
  , ("(?i:[abc]+)d", "aBcd", Just ("aBcd", []))
  , ("(?i:[a-z]+)!", "aBcD!", Just ("aBcD!", []))
  , ("(?i:[^a]+)", "xA", Just ("x", []))
  , ("(?i:\\x61+)", "aA", Just ("aA", []))
  , ("(?i:(ab)\\1)", "abAB", Just ("abAB", [(1,"ab")]))
  , ("(ab)\\1", "abAB", Nothing)
    -- {m,n} expansion is now linear in n; behavior is unchanged:
  , ("a{0,3}b", "aaab", Just ("aaab", []))
  , ("a{0,3}b", "aaaab", Nothing)
  , ("a{2,4}c", "aaaac", Just ("aaaac", []))
  , ("a{2,4}c", "aaaaac", Nothing)
  , ("[ab]{0,800}", replicate 800 'a', Just (replicate 800 'a', []))
    -- repeat counts over 65535 are compile errors (see
    -- regexErrorTests)
    -- an unmatched ] outside a character class is a literal, as in
    -- PCRE (used, e.g., by mermaid.xml and apparmor.xml):
  , ("a]b", "a]b", Just ("a]b", []))
  , ("\\d{1,3}]", "42]x", Just ("42]", []))
  , ("[ab]]", "b]", Just ("b]", []))
    -- a class is terminated by the first unescaped ] even if that
    -- yields a stray ] later (as in PCRE):
  , ("[^|{}[]", "a", Just ("a", []))
  , ("[^|{}[]", "[", Nothing)
    -- \0 takes up to two further octal digits, as in PCRE
    -- (\041 = '!', \042 = '"'); it is octal, not a backreference:
  , ("[\\041-\\043]", "\"", Just ("\"", []))
  , ("\\041", "!!", Just ("!", []))
  , ("a\\0b", "a\NULb", Just ("a\NULb", []))
  , ("[\\0]", "\NULx", Just ("\NUL", []))
    -- a third digit is not consumed (\0101 is \b followed by 1):
  , ("\\0101", "\b1", Just ("\b1", []))
    -- \G asserts the position where the match attempt started;
    -- since our matches are anchored, a leading \G is always true
    -- and a \G after consuming input always fails:
  , ("\\G\\d{4}-\\d{2}", "2024-01x", Just ("2024-01", []))
  , ("a\\Gb", "ab", Nothing)
    -- \g1 and \g{1} are PCRE syntax for backreferences:
  , ("(ab)\\g1", "ababx", Just ("abab", [(1,"ab")]))
  , ("(a)(b)\\g2\\g1", "abba", Just ("abba", [(1,"a"),(2,"b")]))
  , ("(ab)c\\g{1}", "abcabx", Just ("abcab", [(1,"ab")]))
  , ("([_*]{1,2})x\\g1", "**x**", Just ("**x**", [(1,"**")]))
  , ("([_*]{1,2})x\\g1", "**x*", Nothing)
    -- inline modifiers without a colon, like (?i), apply from that
    -- point to the end of the enclosing group (as in PCRE):
  , ("(?i)ab", "AB", Just ("AB", []))
  , ("A(?i)B", "aB", Nothing)
  , ("A(?i)B", "Ab", Just ("Ab", []))
  , ("x(?:(?i)a)Y", "xAY", Just ("xAY", []))
  , ("x(?:(?i)a)Y", "xAy", Nothing)
  , ("(a(?i)b|c)", "C", Just ("C", [(1,"C")]))
  , ("(?i)A(?-i)B", "aB", Just ("aB", []))
  , ("(?i)A(?-i)B", "Ab", Nothing)
    -- \h and \H match horizontal whitespace (and its complement):
  , ("a\\hb", "a b", Just ("a b", []))
  , ("a\\hb", "a\tb", Just ("a\tb", []))
  , ("a\\hb", "a\xa0\&b", Just ("a\xa0\&b", []))
  , ("\\h", "\x180e", Just ("\x180e", []))
  , ("a\\hb", "a\nb", Nothing)
  , ("a\\Hb", "axb", Just ("axb", []))
  , ("a\\Hb", "a b", Nothing)
  , ("[\\h]", "\xa0", Just ("\xa0", []))
  , ("[^\\h]+", "ab cd", Just ("ab", []))
    -- \A asserts the start of the subject:
  , ("\\Aab", "abc", Just ("ab", []))
  , ("a\\Ab", "ab", Nothing)
    -- an empty first alternative matches the empty string:
  , ("(?:|abc)x", "x", Just ("x", []))
  , ("(?:|abc)x", "abcx", Just ("abcx", []))
  , ("(?:\\d\\d(?:|[DT]\\d\\d))y", "12y", Just ("12y", []))
  , ("(?:\\d\\d(?:|[DT]\\d\\d))y", "12T34y", Just ("12T34y", []))
  , ("(?<=|)z\\d", "z4", Just ("z4", []))
    -- inside a character class, \b means backspace:
  , ("[\\b]", "\b", Just ("\b", []))
  , ("[\\b+-]x", "\bx", Just ("\bx", []))
  , ("[\\b+-]x", "+x", Just ("+x", []))
  , ("[\\b+-]x", "bx", Nothing)
    -- atomic groups (?>...):
  , ("(?>ab|a)c", "abc", Just ("abc", []))
  , ("(?>ab|a)c", "ac", Just ("ac", []))
  , ("(?>a+)ab", "aaab", Nothing)
  , ("x(?>)y", "xy", Just ("xy", []))
  , ("(?>a|ab)c", "abc", Nothing)
  , ("(?>(a|ab)c)", "abc", Just ("abc", [(1,"ab")]))
    -- alternation is leftmost-first, not longest-match (as in PCRE):
  , ("a|ab", "ab", Just ("a", []))
  , ("(a|ab)c?", "abc", Just ("a", [(1,"a")]))
  , ("(?=(a|ab))", "ab", Just ("", [(1,"a")]))
    -- lazy quantifiers match as little as possible:
  , ("a+?b", "aaab", Just ("aaab", []))
  , ("a*?b", "aaab", Just ("aaab", []))
  , ("(a+?)ab", "aaab", Just ("aaab", [(1,"aa")]))
    -- subroutine calls to groups with multi-digit numbers:
  , ("(a)(b)(c)(d)(e)(f)(g)(h)(i)(j)(k)(l)x(?12)", "abcdefghijklxl",
      Just ("abcdefghijklxl",
            [(1,"a"),(2,"b"),(3,"c"),(4,"d"),(5,"e"),(6,"f"),(7,"g"),
             (8,"h"),(9,"i"),(10,"j"),(11,"k"),(12,"l")]))
  , ("(a)(b)(c)(d)(e)(f)(g)(h)(i)(j)(k)(l)x(?12)", "abcdefghijklxa", Nothing)
    -- numbering after (?|...) resumes after the highest group number
    -- used in any alternative:
  , ("(?|(a)(b)|(c))(d)\\2", "abdb",
      Just ("abdb", [(1,"a"),(2,"b"),(3,"d")]))
  , ("(?|(a)(b)|(c))(d)\\2", "cdd", Nothing)
    -- a ] in first position in a character class is a literal, and
    -- may be the start of a range:
  , ("[]-a]+", "^_`", Just ("^_`", []))
  , ("[]-a]+", "b", Nothing)
  , ("[]-]+", "]-]", Just ("]-]", []))
  , ("[]-]+", "^", Nothing)
  , ("[^]-a]+", "bz!", Just ("bz!", []))
  , ("[^]-a]+", "^", Nothing)
  , ("[]a-]+", "a]-", Just ("a]-", []))
    -- \pL is short for \p{L}; \P is the complement of \p:
  , ("\\pL+", "ab\x3a3\&9", Just ("ab\x3a3", []))
  , ("\\pN", "9", Just ("9", []))
  , ("\\pN", "a", Nothing)
  , ("\\PL+", "9!", Just ("9!", []))
  , ("\\P{L}+", "9!a", Just ("9!", []))
  , ("\\p{^L}+", "9!a", Just ("9!", []))
  , ("[\\pL]+", "ab9", Just ("ab", []))
  , ("[\\PL]+", "9!a", Just ("9!", []))
  , ("[\\P{N}]+", "a!9", Just ("a!", []))
  , ("[^\\PL]+", "ab9", Just ("ab", []))
    -- {,n} is a quantifier (as in PCRE 10.43+), but {,} and {b} are
    -- literal:
  , ("a{,2}", "aaa", Just ("aa", []))
  , ("a{,}", "a{,}", Just ("a{,}", []))
  , ("a{b}", "a{b}", Just ("a{b}", []))
    -- (?s) and (?m) are accepted (and are no-ops on our single-line
    -- subjects):
  , ("(?s)a.b", "axb", Just ("axb", []))
  , ("(?m)^ab", "ab", Just ("ab", []))
  , ("(?ims)ab", "AB", Just ("AB", []))
    -- (?U) makes quantifiers minimal by default and ? makes them
    -- greedy (PCRE's UNGREEDY option, QRegularExpression's
    -- InvertedGreedinessOption, minimal="1" in KDE syntax files):
  , ("(?U)a+", "aaa", Just ("a", []))
  , ("(?U)a+?", "aaa", Just ("aaa", []))
  , ("(?U)a*b", "aabb", Just ("aab", []))
  , ("(?U)a?", "a", Just ("", []))
  , ("(?U)a{2,4}", "aaaaa", Just ("aa", []))
  , ("(?U)a{2,4}?", "aaaaa", Just ("aaaa", []))
  , ("(?U)a{2}", "aaa", Just ("aa", []))
  , ("(?U)a*+b", "aab", Just ("aab", [])) -- possessive is unaffected
  , ("(?U)<(.*)>", "<x><y>", Just ("<x>", [(1, "x")]))
  , ("(?U:a+)a", "aaa", Just ("aa", []))
  , ("((?U)a+)(a+)", "aaaa", Just ("aaaa", [(1, "a"), (2, "aaa")]))
  , ("(?iU)ab+", "ABBB", Just ("AB", []))
  , ("(?U)(?-U)a+", "aaa", Just ("aaa", []))
  ]

-- these should fail to compile, as they do in PCRE ("quantifier does
-- not follow a repeatable item", "numbers out of order in {}
-- quantifier", "number too big in {} quantifier", or an unsupported
-- inline flag):
regexErrorTests :: [String]
regexErrorTests =
  [ "{2}"
  , "({2})"
  , "a|{2}"
  , "a{2}{3}"
  , "a+{2}"
  , "{,2}"
  , "a{3,1}"
  , "x{70000}"
  , "a{2,70000}"
  , "^{2}"
  , "^*a"
  , "a$*"
  , "\\b+a"
  , "(?x)a b"
  , "(?n)(a)b"
  , "(?u)a"
  ]

regexErrorTest :: String -> TestTree
regexErrorTest re =
  testCase ("/" ++ re ++ "/") $
    case compileRegex True False (TE.encodeUtf8 (Text.pack re)) of
      Left _  -> return ()
      Right _ -> assertFailure "regex compiled, but an error was expected"


vividize :: Diff Text -> Text
vividize (Both s _) = "  " <> s
vividize (First s)  = "- " <> s
vividize (Second s) = "+ " <> s