oglyad-0.1.2.0: src/Oglyad/Regex.hs
-- | a regex engine for the dialect of Rust's regex crate, which andax's
-- find(), find_all() and sub() use (andax/fns/kokoro.rs in FyraLabs/anda).
--
-- it works the way Rust's BoundedBacktracker does: compile to a Thompson
-- NFA shaped like regex-automata's compiler builds it, then backtrack with a
-- visited set of (state, position) pairs. the visited set keeps it linear
-- time, and it's also what decides which captures win when a repeated group
-- can match empty, so mirroring the NFA shape gets the same answers as Rust.
-- it was checked against the real crate on every regex in terrapkg/packages
-- and ~300k fuzzed ones, with identical matches and captures.
--
-- supported: literals and escapes, ., classes with ranges, negation and
-- [:posix:] names, \d \w \s and their negations, \b \B \A \z, ^ $, groups
-- ((...), (?:...), (?P<name>...), (?<name>...)), alternation, * + ? {n}
-- {n,} {n,m} and their lazy forms, and the i, m and s flags. anything else
-- is a compile error, which callers treat as "can't tell"
module Oglyad.Regex
( Regex,
compile,
captures,
capturesAll,
replaceAll,
)
where
import Control.Applicative ((<|>))
import Control.Monad.ST (runST)
import Data.Char (GeneralCategory (..), generalCategory, isAlphaNum, isAsciiLower, isAsciiUpper, isDigit, isHexDigit, isPunctuation, isSpace, isSymbol, toLower, toUpper)
import Data.IntMap.Strict (IntMap)
import Data.IntMap.Strict qualified as IM
import Data.Text (Text)
import Data.Text qualified as T
import Data.Vector qualified as V
import Data.Vector.Unboxed qualified as VU
import Data.Vector.Unboxed.Mutable qualified as MVU
import Text.Read (readMaybe)
data Node
= -- | one character
One (Char -> Bool)
| -- | ^ and $, True for multi-line
LineStart Bool
| LineEnd Bool
| -- | \b, False for \B
WordBoundary Bool
| Group (Maybe Int) Node
| Alt [Node]
| Seq [Node]
| -- | min, max, greedy
Repeat Int (Maybe Int) Bool Node
data Regex = Regex
{ reProg :: Prog,
reGroups :: Int,
reNames :: [(Text, Int)]
}
data Flags = Flags {fI, fM, fS :: Bool}
-- * parsing
-- | parser state: input left, groups numbered so far, names seen
data St = St {stIn :: String, stGroups :: Int, stNames :: [(Text, Int)]}
type Parse a = St -> Either Text (a, St)
compile :: Text -> Either Text Regex
compile src = do
(node, st) <- alternation (Flags False False False) (St (T.unpack src) 0 [])
case stIn st of
[] -> Right (Regex (toProg (stGroups st) node) (stGroups st) (stNames st))
c : _ -> Left ("unexpected " <> T.singleton c)
-- | a|b|c, up to a ')' or the end. (?flags) partway through applies to the
-- rest of the group, alternatives included, like Rust
alternation :: Flags -> Parse Node
alternation flags st0 = go flags st0 []
where
go fl st acc = do
((node, fl'), st') <- sequence' fl st []
case stIn st' of
'|' : rest -> go fl' st' {stIn = rest} (node : acc)
_ -> Right (alt (reverse (node : acc)), st')
alt [n] = n
alt ns = Alt ns
sequence' :: Flags -> St -> [Node] -> Either Text ((Node, Flags), St)
sequence' fl st acc = case stIn st of
[] -> done
'|' : _ -> done
')' : _ -> done
-- a bare flag group changes the flags for what follows
'(' : '?' : rest
| (fs, ')' : rest') <- span (`elem` ("imsxUR-" :: String)) rest,
not (null fs) -> do
fl' <- setFlags fl fs
sequence' fl' st {stIn = rest'} acc
_ -> do
(atom, st1) <- atomP fl st
(node, st2) <- quantifier atom st1
sequence' fl st2 (node : acc)
where
done = Right ((Seq (reverse acc), fl), st)
setFlags :: Flags -> String -> Either Text Flags
setFlags fl0 = go True fl0
where
go _ fl [] = Right fl
go on fl (c : cs) = case c of
'-' -> go False fl cs
'i' -> go on fl {fI = on} cs
'm' -> go on fl {fM = on} cs
's' -> go on fl {fS = on} cs
_ -> Left ("unsupported flag " <> T.singleton c)
quantifier :: Node -> Parse Node
quantifier atom st = case stIn st of
'*' : rest -> lazy 0 Nothing rest
'+' : rest -> lazy 1 Nothing rest
'?' : rest -> lazy 0 (Just 1) rest
'{' : rest
| (a, rest1) <- span isDigit rest,
not (null a) ->
case rest1 of
'}' : rest2 -> bounded a (Just a) rest2
',' : '}' : rest2 -> bounded a Nothing rest2
',' : rest2
| (b, '}' : rest3) <- span isDigit rest2, not (null b) -> bounded a (Just b) rest3
_ -> Left "bad repetition"
_ -> Right (atom, st)
where
bounded a b rest = case (readMaybe a, traverse readMaybe b) of
(Just lo, Just hi) | maybe True (>= lo) hi -> lazy lo hi rest
_ -> Left "bad repetition"
lazy lo hi = \case
'?' : rest -> Right (Repeat lo hi False atom, st {stIn = rest})
rest -> Right (Repeat lo hi True atom, st {stIn = rest})
atomP :: Flags -> Parse Node
atomP fl st = case stIn st of
'(' : '?' : ':' : rest -> group Nothing st {stIn = rest}
'(' : '?' : 'P' : '<' : rest -> named rest
'(' : '?' : '<' : rest -> named rest
'(' : '?' : _ -> Left "unsupported group"
'(' : rest ->
let n = stGroups st + 1
in group (Just n) st {stIn = rest, stGroups = n}
'[' : rest -> do
(p, rest') <- classP fl rest
Right (One p, st {stIn = rest'})
'.' : rest -> Right (One (\c -> fS fl || c /= '\n'), st {stIn = rest})
'^' : rest -> Right (LineStart (fM fl), st {stIn = rest})
'$' : rest -> Right (LineEnd (fM fl), st {stIn = rest})
'\\' : rest -> do
(node, rest') <- escape fl rest
Right (node, st {stIn = rest'})
c : rest
| c `elem` ("*+?{" :: String) -> Left "repetition with nothing to repeat"
| otherwise -> Right (One (charEq fl c), st {stIn = rest})
[] -> Left "unexpected end"
where
group idx s = do
(inner, s') <- alternation fl s
case stIn s' of
')' : rest -> Right (Group idx inner, s' {stIn = rest})
_ -> Left "unclosed group"
named rest = case span (\c -> isAlphaNum c || c == '_') rest of
(name, '>' : rest')
| not (null name) ->
let n = stGroups st + 1
in group (Just n) st {stIn = rest', stGroups = n, stNames = (T.pack name, n) : stNames st}
_ -> Left "bad group name"
charEq :: Flags -> Char -> Char -> Bool
charEq fl c
| fI fl = \x -> toLower x == toLower c || toUpper x == toUpper c
| otherwise = (== c)
-- | an escape outside a class
escape :: Flags -> String -> Either Text (Node, String)
escape fl = \case
'b' : rest -> Right (WordBoundary True, rest)
'B' : rest -> Right (WordBoundary False, rest)
'A' : rest -> Right (LineStart False, rest)
'z' : rest -> Right (LineEnd False, rest)
s -> do
(p, rest) <- classEscape fl s
Right (One p, rest)
-- | an escape that stands for one character or a class of them
classEscape :: Flags -> String -> Either Text (Char -> Bool, String)
classEscape fl = \case
'd' : rest -> Right (isDigit', rest)
'D' : rest -> Right (not . isDigit', rest)
'w' : rest -> Right (isWord, rest)
'W' : rest -> Right (not . isWord, rest)
's' : rest -> Right (isSpace, rest)
'S' : rest -> Right (not . isSpace, rest)
'n' : rest -> lit '\n' rest
't' : rest -> lit '\t' rest
'r' : rest -> lit '\r' rest
'f' : rest -> lit '\f' rest
'v' : rest -> lit '\v' rest
'x' : a : b : rest | isHexDigit a && isHexDigit b, Just n <- readMaybe ("0x" <> [a, b]) -> lit (toEnum n) rest
c : rest
| isPunctuation c || isSymbol c || c == ' ' -> lit c rest
_ -> Left "unsupported escape"
where
lit c rest = Right (charEq fl c, rest)
-- | Rust's \d is Unicode-aware, and isDigit is ASCII-only
isDigit' :: Char -> Bool
isDigit' c = generalCategory c == DecimalNumber
isWord :: Char -> Bool
isWord c = isAlphaNum c || c == '_'
-- | the inside of [...], given what's after the '['
classP :: Flags -> String -> Either Text (Char -> Bool, String)
classP fl s0 = do
let (negated, s1) = case s0 of
'^' : r -> (True, r)
r -> (False, r)
-- a ']' right at the start is a literal
(items, rest) <- case s1 of
']' : r -> go [charEq fl ']'] r
r -> go [] r
let p c = any ($ c) items
Right (if negated then not . p else p, rest)
where
go acc = \case
']' : rest -> Right (acc, rest)
'[' : ':' : rest
| (name, ':' : ']' : rest') <- span isAsciiLower rest -> do
p <- posix name
go (p : acc) rest'
'[' : _ -> Left "nested classes aren't supported"
'&' : '&' : _ -> Left "class operations aren't supported"
'-' : '-' : _ -> Left "class operations aren't supported"
'~' : '~' : _ -> Left "class operations aren't supported"
s -> do
(lo, rest) <- single s
case (lo, rest) of
(Left c, '-' : rest') | take 1 rest' /= "]" -> do
(hi, rest'') <- single rest'
case hi of
Left d | c <= d -> go (range c d : acc) rest''
_ -> Left "bad class range"
(Left c, _) -> go (charEq fl c : acc) rest
(Right p, _) -> go (p : acc) rest
-- one class item: Left for a plain character (which can start a range),
-- Right for an escape like \d
single = \case
'\\' : rest -> case rest of
c : rest' | isPunctuation c || isSymbol c || c == ' ' -> Right (Left c, rest')
'n' : rest' -> Right (Left '\n', rest')
't' : rest' -> Right (Left '\t', rest')
_ -> do
(p, rest') <- classEscape fl rest
Right (Right p, rest')
c : rest -> Right (Left c, rest)
[] -> Left "unclosed class"
range lo hi
| fI fl = \c -> any (\x -> lo <= x && x <= hi) [c, toLower c, toUpper c]
| otherwise = \c -> lo <= c && c <= hi
posix = \case
"alnum" -> Right isAlphaNumAscii
"alpha" -> Right (\c -> isAsciiLower c || isAsciiUpper c)
"digit" -> Right isDigit
"lower" -> Right isAsciiLower
"upper" -> Right isAsciiUpper
"space" -> Right (`elem` (" \t\n\r\f\v" :: String))
"xdigit" -> Right isHexDigit
"punct" -> Right (\c -> c < '\x80' && (isPunctuation c || isSymbol c))
_ -> Left "unknown posix class"
isAlphaNumAscii c = isDigit c || isAsciiLower c || isAsciiUpper c
-- * compiling to an NFA
data Look = AtLineStart Bool | AtLineEnd Bool | AtWordBoundary Bool
data State
= SChar (Char -> Bool) !Int
| -- | alternatives in preference order
SUnion [Int]
| -- | record the position in a capture slot
SCapture !Int !Int
| SLook Look !Int
| SMatch
data Prog = Prog
{ progStates :: V.Vector State,
progStart :: Int,
-- | two per group, group 0 being the whole match
progSlots :: Int
}
-- | NFA under construction: the next free id and the states so far
type Build = (Int, IntMap State)
new :: State -> Build -> (Int, Build)
new st (n, m) = (n, (n + 1, IM.insert n st m))
-- | reserve a state to fill in later, for loops
reserve :: Build -> (Int, Build)
reserve (n, m) = (n, (n + 1, m))
fill :: Int -> State -> Build -> Build
fill i st (n, m) = (n, IM.insert i st m)
toProg :: Int -> Node -> Prog
toProg groups node =
let (matchId, b1) = new SMatch (0, IM.empty)
(end0, b2) = new (SCapture 1 matchId) b1
(body, b3) = nfa node end0 b2
(start, (n, states)) = new (SCapture 0 body) b3
in Prog (V.generate n (states IM.!)) start (2 * (groups + 1))
-- | compile a node that continues to `next`, returning its start state.
-- the repetition shapes follow regex-automata's thompson compiler
-- (c_at_least, c_bounded, c_zero_or_one), since the visited set makes the
-- shape observable
nfa :: Node -> Int -> Build -> (Int, Build)
nfa node next b = case node of
One p -> new (SChar p next) b
LineStart multi -> new (SLook (AtLineStart multi) next) b
LineEnd multi -> new (SLook (AtLineEnd multi) next) b
WordBoundary want -> new (SLook (AtWordBoundary want) next) b
Group Nothing inner -> nfa inner next b
Group (Just g) inner ->
let (close, b1) = new (SCapture (2 * g + 1) next) b
(body, b2) = nfa inner close b1
in new (SCapture (2 * g) body) b2
Seq ns -> foldr (\n k nx bb -> let (s, bb') = k nx bb in nfa n s bb') (,) ns next b
Alt ns ->
let (starts, b1) = foldr (\n (acc, bb) -> let (s, bb') = nfa n next bb in (s : acc, bb')) ([], b) ns
in new (SUnion starts) b1
Repeat lo hi greedy inner -> case hi of
Just 1
| lo == 0 ->
let (x, b1) = nfa inner next b
in new (SUnion (order x next)) b1
Just m
| lo == m -> copies lo next b
| otherwise ->
-- the optional copies nest: once one is skipped, the rest are too
let (opt, b1) = foldr (\_ (nx, bb) -> let (x, bb') = nfa inner nx bb in new (SUnion (order x next)) bb') (next, b) [1 .. m - lo]
in copies' lo opt b1
Nothing
| lo == 0 && minLen inner > 0 ->
let (u, b1) = reserve b
(x, b2) = nfa inner u b1
in (u, fill u (SUnion (order x next)) b2)
| lo == 0 ->
-- x* where x can match empty is compiled as (x+)?, which keeps
-- the preference order right (rust-lang/regex#779)
let (plus, b1) = reserve b
(x, b2) = nfa inner plus b1
b3 = fill plus (SUnion (order x next)) b2
in new (SUnion (order x next)) b3
| otherwise ->
-- n-1 copies, then one more that loops
let (u, b1) = reserve b
(x, b2) = nfa inner u b1
b3 = fill u (SUnion (order x next)) b2
in copies' (lo - 1) x b3
where
order x nx = if greedy then [x, nx] else [nx, x]
copies n nx bb = copies' n nx bb
copies' n nx bb = foldr (\_ (s, bb') -> nfa inner s bb') (nx, bb) [1 .. n]
-- | the shortest match a node can make, to tell whether x* can loop on
-- nothing
minLen :: Node -> Int
minLen = \case
One _ -> 1
LineStart _ -> 0
LineEnd _ -> 0
WordBoundary _ -> 0
Group _ n -> minLen n
Seq ns -> sum (map minLen ns)
Alt ns -> if null ns then 0 else minimum (map minLen ns)
Repeat lo _ _ n -> lo * minLen n
-- * matching
-- | positions the visited set can track before we give up. Rust switches to
-- another engine past its limit. we only ever see version strings, so a
-- refusal here is fine
visitedLimit :: Int
visitedLimit = 20000000
data Frame = Step !Int !Int | Restore !Int !Int
-- | the first match starting at or after a position, as capture slots.
-- the visited set lives for the whole search, like Rust's: a (state,
-- position) that failed from one start fails from every later one too
searchFrom :: Regex -> VU.Vector Char -> Int -> Either Text (Maybe (VU.Vector Int))
searchFrom re input from
| V.length states * width > visitedLimit = Left "text too long for the regex engine"
| otherwise = Right $ runST $ do
visited <- MVU.replicate (V.length states * width) False
slots <- MVU.replicate (progSlots prog) (-1)
let tryAt at
| at > len = pure Nothing
| otherwise =
run [Step (progStart prog) at] >>= \case
Nothing -> tryAt (at + 1)
found -> pure found
run = \case
[] -> pure Nothing
Restore slot old : rest -> MVU.write slots slot old >> run rest
Step sid at : rest -> step sid at rest
step sid at rest = do
let key = sid * width + at
seen <- MVU.read visited key
if seen
then run rest
else do
MVU.write visited key True
case states V.! sid of
SChar p nx
| at < len && p (input VU.! at) -> step nx (at + 1) rest
| otherwise -> run rest
SUnion [] -> run rest
SUnion (x : xs) -> step x at (map (`Step` at) xs <> rest)
SCapture slot nx -> do
old <- MVU.read slots slot
MVU.write slots slot at
step nx at (Restore slot old : rest)
SLook l nx
| look l at -> step nx at rest
| otherwise -> run rest
SMatch -> Just <$> VU.freeze slots
tryAt from
where
prog = reProg re
states = progStates prog
len = VU.length input
width = len + 1
look l at = case l of
AtLineStart multi -> at == 0 || (multi && input VU.! (at - 1) == '\n')
AtLineEnd multi -> at == len || (multi && input VU.! at == '\n')
AtWordBoundary want -> (wordAt (at - 1) /= wordAt at) == want
wordAt i = i >= 0 && i < len && isWord (input VU.! i)
-- | (start, end, slots) of a match
type Match = (Int, Int, VU.Vector Int)
toMatch :: VU.Vector Int -> Match
toMatch slots = (slots VU.! 0, slots VU.! 1, slots)
-- | every non-overlapping match, like Rust's captures_iter. an empty match
-- right where the previous one ended is skipped
matches :: Regex -> VU.Vector Char -> Either Text [Match]
matches re input = go 0 Nothing
where
go from lastEnd
| from > VU.length input = Right []
| otherwise =
searchFrom re input from >>= \case
Nothing -> Right []
Just slots -> case toMatch slots of
(s, e, _)
| s == e && Just e == lastEnd -> go (s + 1) lastEnd
m@(s, e, _) -> (m :) <$> go (if s == e then e + 1 else e) (Just e)
-- | group texts for one match, group 0 first. Nothing for groups that
-- didn't take part
groupsOf :: Regex -> VU.Vector Char -> Match -> [Maybe Text]
groupsOf re input (_, _, slots) =
[ if a >= 0 && b >= 0 then Just (T.pack (VU.toList (VU.slice a (b - a) input))) else Nothing
| g <- [0 .. reGroups re],
let a = slots VU.! (2 * g)
b = slots VU.! (2 * g + 1)
]
chars :: Text -> VU.Vector Char
chars = VU.fromList . T.unpack
-- | the first match, like Rust's Regex::captures. Left when the text is
-- too long for the engine
captures :: Regex -> Text -> Either Text (Maybe [Maybe Text])
captures re t = fmap (groupsOf re input . toMatch) <$> searchFrom re input 0
where
input = chars t
capturesAll :: Regex -> Text -> Either Text [[Maybe Text]]
capturesAll re t = map (groupsOf re input) <$> matches re input
where
input = chars t
-- | Rust's replace_all: $1, ${1}, $name and ${name} expand to groups (empty
-- when missing), $$ is a dollar sign. $name takes the longest run of
-- letters, digits and underscores, so "$1a" is the group named "1a"
replaceAll :: Regex -> Text -> Text -> Either Text Text
replaceAll re rep t = T.concat . go 0 <$> matches re input
where
input = chars t
slice a b = T.pack (VU.toList (VU.slice a (b - a) input))
go pos [] = [slice pos (VU.length input)]
go pos (m@(s, e, _) : ms) = slice pos s : expand (groupsOf re input m) : go e ms
expand groups = T.pack (expandS groups (T.unpack rep))
expandS groups = \case
[] -> []
'$' : '$' : rest -> '$' : expandS groups rest
'$' : '{' : rest
| (name, '}' : rest') <- span (/= '}') rest, not (null name) -> lookupGroup groups name <> expandS groups rest'
'$' : rest
| (name, rest') <- span (\c -> isAlphaNum c || c == '_') rest, not (null name) -> lookupGroup groups name <> expandS groups rest'
c : rest -> c : expandS groups rest
lookupGroup groups name =
let idx = readMaybe name <|> lookup (T.pack name) (reNames re)
in maybe "" T.unpack (idx >>= \i -> if i < length groups then groups !! i else Nothing)