smirk-0.1.0.0: src/Smirk/Core/Parser.hs
{-# LANGUAGE OverloadedStrings #-}
{- |
Module : Smirk.Core.Parser
Description : Recursive descent PCRE parser based on Megaparsec
Copyright : (c) Nadia Yvette Chambers, 2026
License : Apache-2.0
Implements a standard recursive descent parser for Perl-Compatible Regular
Expressions (PCRE) using Megaparsec.
=== Operator Precedence Hierarchy
1. __Alternation ('pAlternation'):__ Lowest precedence; separated by @|@.
2. __Concatenation ('pSequence'):__ Sequential concatenation of terms.
3. __Repetition ('pRepeatItem'):__ Suffix quantifiers: @*@, @+@, @?@, @{m,n}@,
with optional greediness modifiers (@?@ for lazy, @+@ for possessive).
4. __Atoms ('pAtom'):__ Groups, character classes, anchors, backreferences,
escaped characters, and character literals.
-}
module Smirk.Core.Parser
( -- * High-Level Parsing API
parsePCRE
, parsePCREWithFlags
, ParserError
-- * Grammar Combinators
, Parser
, pRegex
, pAlternation
, pSequence
, pRepeatItem
, pRepetition
, pAtom
, pGroup
, pNamedGroup
, pClassSet
, pRangeItem
, pBackRef
, pEscaped
, pLiteral
) where
import Control.Monad (void)
import Data.Char (isDigit)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Void (Void)
import Text.Megaparsec
import Text.Megaparsec.Char
import qualified Text.Megaparsec.Char.Lexer as L
import Smirk.Core.AST
-- | Megaparsec parser type over strict Text with Void error components.
type Parser = Parsec Void Text
-- | Pretty-printable Megaparsec error bundle type.
type ParserError = ParseErrorBundle Text Void
-- | Parse a PCRE regular expression string using default compilation flags.
-- Returns 'Right' 'AST' on success, or 'Left' errorMessage on failure.
parsePCRE :: Text -> Either Text AST
parsePCRE = parsePCREWithFlags defaultFlags
-- | Parse a PCRE regular expression string with explicit initial flags.
parsePCREWithFlags :: Flags -> Text -> Either Text AST
parsePCREWithFlags initFlags input =
case runParser (pRegex initFlags) "pcre" input of
Left err -> Left (T.pack $ errorBundlePretty err)
Right ast -> Right ast
-- | Top-level regular expression parser consuming to end-of-input.
pRegex :: Flags -> Parser AST
pRegex _initFlags = do
res <- pAlternation
eof
pure res
-- | Parse an alternation branch (@branch1 | branch2 | ...@).
pAlternation :: Parser AST
pAlternation = do
branches <- sepBy1 pSequence (char '|')
pure $ case branches of
[singleBranch] -> singleBranch
multiple -> Alt multiple
-- | Parse a sequence of concatenated items (@item1 item2 ...@).
pSequence :: Parser AST
pSequence = do
items <- many pRepeatItem
pure $ case items of
[] -> Empty
[singleItem] -> singleItem
multiple -> Seq multiple
-- | Parse an atom with an optional repetition suffix.
pRepeatItem :: Parser AST
pRepeatItem = do
atom <- pAtom
mRep <- optional pRepetition
pure $ case mRep of
Nothing -> atom
Just (minN, maxN, kind) -> Repeat minN maxN kind atom
-- | Parse repetition quantifiers: @*@, @+@, @?@, or range @{min,max}@,
-- accompanied by an optional greediness modifier (@?@ for lazy, @+@ for possessive).
pRepetition :: Parser (Int, Maybe Int, RepKind)
pRepetition = do
(minN, maxN) <- pRepRange
kind <- pRepModifier
pure (minN, maxN, kind)
where
-- Basic repetition range
pRepRange =
(char '*' >> pure (0, Nothing))
<|> (char '+' >> pure (1, Nothing))
<|> (char '?' >> pure (0, Just 1))
<|> pBraceRep
-- Bounded repetition syntax: {n}, {n,}, or {n,m}
pBraceRep = between (char '{') (char '}') $ do
n <- L.decimal
mComma <- optional (char ',')
case mComma of
Nothing -> pure (n, Just n)
Just _ -> do
mMax <- optional L.decimal
pure (n, mMax)
-- Greediness modifier: default is greedy, '?' is lazy, '+' is possessive
pRepModifier =
(char '?' >> pure RepLazy)
<|> (char '+' >> pure RepPossessive)
<|> pure RepGreedy
-- | Parse an atomic expression: group, class set, dot, anchor, backreference, or literal.
pAtom :: Parser AST
pAtom =
pGroup
<|> pClassSet
<|> pDot
<|> pAnchor
<|> pBackRef
<|> pEscaped
<|> pLiteral
-- | Parse @.@ matching any character (subject to dotAll flag).
pDot :: Parser AST
pDot = char '.' >> pure (Class ClassAny)
-- | Parse zero-width position anchors: @^@, @$@, @\\b@, @\\B@.
pAnchor :: Parser AST
pAnchor =
(char '^' >> pure (AnchorAssert AnchorStart))
<|> (char '$' >> pure (AnchorAssert AnchorEnd))
<|> (string "\\b" >> pure (AnchorAssert AnchorWordBoundary))
<|> (string "\\B" >> pure (AnchorAssert AnchorNotWordBoundary))
-- | Parse parenthesized group constructs, handling both capturing and non-capturing/lookaround variants.
pGroup :: Parser AST
pGroup = between (char '(') (char ')') $ do
mSpecial <- optional (char '?')
case mSpecial of
Nothing -> Group Nothing Nothing <$> pAlternation
Just _ ->
(string ":" >> (NonCapturing <$> pAlternation))
<|> (string "=" >> (Lookaround Lookahead True <$> pAlternation))
<|> (string "!" >> (Lookaround Lookahead False <$> pAlternation))
<|> (string "<=" >> (Lookaround Lookbehind True <$> pAlternation))
<|> (string "<!" >> (Lookaround Lookbehind False <$> pAlternation))
<|> pNamedGroup
-- | Parse a named capture group: @(?<name>pattern)@.
pNamedGroup :: Parser AST
pNamedGroup = do
void (char '<')
name <- takeWhile1P (Just "group name") (/= '>')
void (char '>')
Group (Just name) Nothing <$> pAlternation
-- | Parse a bracketed character set: @[abc]@, @[a-z0-9]@, or negated @[^a-z]@.
pClassSet :: Parser AST
pClassSet = between (char '[') (char ']') $ do
negated <- (char '^' >> pure True) <|> pure False
ranges <- many pRangeItem
pure $ Class (ClassSet ranges negated)
-- | Parse a character range within a set: either @a-z@ or a single character @a@.
pRangeItem :: Parser CharRange
pRangeItem = try pBetweenRange <|> pSingleChar
where
pBetweenRange = do
startC <- noneOf [']', '-']
void (char '-')
endC <- noneOf [']']
pure $ RangeBetween startC endC
pSingleChar = RangeSingle <$> noneOf [']']
-- | Parse a numbered backreference: @\\1@, @\\2@, etc.
pBackRef :: Parser AST
pBackRef = try $ do
void (char '\\')
d <- satisfy isDigit
pure $ BackRef (read [d])
-- | Parse an escape sequence: character classes (\d, \w, \s) or control characters (\n, \t, \r).
pEscaped :: Parser AST
pEscaped = do
void (char '\\')
c <- anySingle
pure $ case c of
'd' -> Class ClassDigit
'D' -> Class ClassNotDigit
'w' -> Class ClassWord
'W' -> Class ClassNotWord
's' -> Class ClassSpace
'S' -> Class ClassNotSpace
'n' -> Lit '\n'
'r' -> Lit '\r'
't' -> Lit '\t'
other -> Lit other
-- | Parse a plain literal character, disallowing special PCRE metacharacters.
pLiteral :: Parser AST
pLiteral = Lit <$> noneOf ("^$\\.*+?()[]{}|" :: String)