aihc-cpp-1.0.0.0: src/Aihc/Cpp/Evaluator.hs
{-# LANGUAGE OverloadedStrings #-}
module Aihc.Cpp.Evaluator
( expandMacros,
expandMacrosMultiline,
substituteParams,
evalCondition,
evalNumeric,
Token (..),
tokenize,
parseExpr,
parseOr,
parseAnd,
parseEq,
parseRel,
parseAdd,
parseMul,
parseUnary,
parseAtom,
replaceDefined,
replaceRemainingWithZero,
)
where
import Aihc.Cpp.Parser (isIdentChar, isIdentStart, isOpChar)
import Aihc.Cpp.Types (EngineState (..), MacroDef (..))
import Data.Char (isDigit, isSpace)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as M
import Data.Set (Set)
import qualified Data.Set as S
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Lazy as TL
import qualified Data.Text.Lazy.Builder as TB
import qualified Data.Text.Read as TR
-- | Expand macros in a single piece of text using the blue-paint algorithm.
-- A single pass with a suppression set replaces the previous iterate-up-to-32
-- fixpoint approach.
expandMacros :: EngineState -> Text -> Text
expandMacros st txt =
builderToText (expandBlue st S.empty False False False (TB.fromText txt))
-- | Expand macros with multi-line support. When a function-like macro call
-- spans multiple lines, continuation lines are consumed from @moreLines@.
-- Returns the expanded text and the number of extra lines consumed.
expandMacrosMultiline :: EngineState -> Text -> [Text] -> (Text, Int)
expandMacrosMultiline st txt moreLines =
let extraNeeded = countExtraLinesConsumed st txt moreLines
in if extraNeeded == 0
then (expandMacros st txt, 0)
else
let combinedLines = txt : take extraNeeded moreLines
combined = T.intercalate "\n" combinedLines
expanded = expandMacros st combined
in (expanded, extraNeeded)
-- | Count how many extra lines a function macro call consumes.
-- Scans the first line for an identifier that matches a function macro,
-- then checks if parseCallArgs needs to span into continuation lines.
countExtraLinesConsumed :: EngineState -> Text -> [Text] -> Int
countExtraLinesConsumed st txt moreLines = scanForFunctionMacro False False False txt
where
macros = stMacros st
scanForFunctionMacro :: Bool -> Bool -> Bool -> Text -> Int
scanForFunctionMacro _ _ _ t | T.null t = 0
scanForFunctionMacro inString inChar escaped t =
case T.uncons t of
Nothing -> 0
Just (c, rest)
| inString ->
let escaped' = c == '\\' && not escaped
inString' = not (c == '"' && not escaped)
in scanForFunctionMacro inString' False escaped' rest
| inChar ->
let escaped' = c == '\\' && not escaped
inChar' = not (c == '\'' && not escaped)
in scanForFunctionMacro False inChar' escaped' rest
| startsHsBlockComment t ->
let (_, remaining) = consumeHsBlockComment t
in scanForFunctionMacro False False False remaining
| c == '"' -> scanForFunctionMacro True False False rest
| c == '\'' -> scanForFunctionMacro False True False rest
| isIdentStart c ->
let (ident, rest') = T.span isIdentChar t
in case M.lookup ident macros of
Just (FunctionMacro _ _) ->
case tryMultilineCallArgs rest' of
Just n -> n
Nothing -> scanForFunctionMacro False False False rest'
_ -> scanForFunctionMacro False False False rest'
| otherwise -> scanForFunctionMacro False False False rest
-- Try to parse function call args, potentially spanning multiple lines.
-- Returns Just n if the call spans n extra lines, Nothing if no call.
tryMultilineCallArgs :: Text -> Maybe Int
tryMultilineCallArgs rest = seekOpenParen (T.dropWhile isSpace rest) 0
seekOpenParen :: Text -> Int -> Maybe Int
seekOpenParen remaining extraLines =
case T.uncons remaining of
Just ('(', afterOpen) ->
findClosingParen 0 afterOpen extraLines
Just _ ->
Nothing
Nothing ->
case drop extraLines moreLines of
[] -> Nothing
(nextLine : _) ->
seekOpenParen (T.dropWhile isSpace nextLine) (extraLines + 1)
findClosingParen :: Int -> Text -> Int -> Maybe Int
findClosingParen = goClosing False False False
where
goClosing :: Bool -> Bool -> Bool -> Int -> Text -> Int -> Maybe Int
goClosing inString inChar escaped depth remaining extraLines =
case T.uncons remaining of
Nothing ->
-- Need more lines
case drop extraLines moreLines of
[] -> Nothing -- No more lines, unclosed call
(nextLine : _) ->
goClosing inString inChar escaped depth (T.cons '\n' nextLine) (extraLines + 1)
Just (ch, rest)
| inString ->
let escaped' = ch == '\\' && not escaped
inString' = not (ch == '"' && not escaped)
in goClosing inString' False escaped' depth rest extraLines
| inChar ->
let escaped' = ch == '\\' && not escaped
inChar' = not (ch == '\'' && not escaped)
in goClosing False inChar' escaped' depth rest extraLines
| startsHsBlockComment remaining ->
let (_, afterComment) = consumeHsBlockComment remaining
in goClosing False False False depth afterComment extraLines
| ch == '"' -> goClosing True False False depth rest extraLines
| ch == '\'' -> goClosing False True False depth rest extraLines
| ch == '(' -> goClosing False False False (depth + 1) rest extraLines
| ch == ')' && depth > 0 -> goClosing False False False (depth - 1) rest extraLines
| ch == ')' -> Just extraLines
| otherwise -> goClosing False False False depth rest extraLines
-- | Blue-paint macro expansion engine. Uses a suppression set (@painted@)
-- to prevent infinite recursion instead of iterating to a fixpoint.
-- Output is accumulated via a lazy 'TB.Builder' for amortized O(n).
expandBlue :: EngineState -> Set Text -> Bool -> Bool -> Bool -> TB.Builder -> TB.Builder
expandBlue st painted inString inChar escaped input =
let txt = builderToText input
in goText st painted inString inChar escaped txt mempty
-- | Walk the input text, expanding macros with blue-paint suppression.
goText :: EngineState -> Set Text -> Bool -> Bool -> Bool -> Text -> TB.Builder -> TB.Builder
goText _ _ _ _ _ txt acc | T.null txt = acc
goText st painted inString inChar escaped txt acc =
case T.uncons txt of
Nothing -> acc
Just (c, rest)
| inString ->
let escaped' = c == '\\' && not escaped
inString' = not (c == '"' && not escaped)
in goText st painted inString' False escaped' rest (acc <> TB.singleton c)
| inChar ->
let escaped' = c == '\\' && not escaped
inChar' = not (c == '\'' && not escaped)
in goText st painted False inChar' escaped' rest (acc <> TB.singleton c)
| startsHsBlockComment txt ->
let (commentText, remaining) = consumeHsBlockComment txt
in goText st painted False False False remaining (acc <> TB.fromText commentText)
| c == '"' ->
goText st painted True False False rest (acc <> TB.singleton c)
| c == '\'' ->
goText st painted False True False rest (acc <> TB.singleton c)
| isIdentStart c ->
expandIdentBlue st painted txt acc
| c == '-',
Just ('-', _) <- T.uncons rest ->
-- Haskell line comment: copy remainder verbatim without macro expansion
acc <> TB.fromText txt
| otherwise ->
goText st painted False False False rest (acc <> TB.singleton c)
-- | Handle an identifier during blue-paint expansion.
expandIdentBlue :: EngineState -> Set Text -> Text -> TB.Builder -> TB.Builder
expandIdentBlue st painted txt acc =
let (ident, rest) = T.span isIdentChar txt
in if S.member ident painted
then -- Blue-painted: copy verbatim, don't expand
goText st painted False False False rest (acc <> TB.fromText ident)
else case ident of
"__LINE__" ->
goText st painted False False False rest (acc <> TB.fromString (show (stCurrentLine st)))
"__FILE__" ->
goText st painted False False False rest (acc <> TB.fromString (show (stCurrentFile st)))
_ ->
case M.lookup ident (stMacros st) of
Just (ObjectMacro replacement) ->
let painted' = S.insert ident painted
replacement' = normalizeObjectReplacement replacement
expanded = builderToText (goText st painted' False False False replacement' mempty)
in goText st painted False False False rest (acc <> TB.fromText expanded)
Just (FunctionMacro params body) ->
case parseCallArgs rest of
Nothing ->
goText st painted False False False rest (acc <> TB.fromText ident)
Just (args, restAfter)
| length args == length params ->
let body' = substituteParamsBuilder (M.fromList (zip params args)) body
painted' = S.insert ident painted
expanded = builderToText (goText st painted' False False False body' mempty)
in goText st painted False False False restAfter (acc <> TB.fromText expanded)
| otherwise ->
goText st painted False False False rest (acc <> TB.fromText ident)
Nothing ->
goText st painted False False False rest (acc <> TB.fromText ident)
-- | Normalize comments inside object-like macro replacement text while
-- preserving string and char literals. cpphs replaces @/* ... */@ with spaces
-- matching the width of the comment body, but treats empty @/**/@ as a token
-- pasting hack with zero width.
normalizeObjectReplacement :: Text -> Text
normalizeObjectReplacement = T.stripEnd . go False False False mempty
where
go :: Bool -> Bool -> Bool -> TB.Builder -> Text -> Text
go _ _ _ acc txt | T.null txt = builderToText acc
go inString inChar escaped acc txt =
case T.uncons txt of
Nothing -> builderToText acc
Just (c, rest)
| inString ->
let escaped' = c == '\\' && not escaped
inString' = not (c == '"' && not escaped)
in go inString' False escaped' (acc <> TB.singleton c) rest
| inChar ->
let escaped' = c == '\\' && not escaped
inChar' = not (c == '\'' && not escaped)
in go False inChar' escaped' (acc <> TB.singleton c) rest
| c == '"' -> go True False False (acc <> TB.singleton c) rest
| c == '\'' -> go False True False (acc <> TB.singleton c) rest
| "/*" `T.isPrefixOf` txt ->
let (commentText, remaining) = consumeCBlockComment txt
replacement = commentReplacement commentText
in go False False False (acc <> TB.fromText replacement) remaining
| otherwise ->
go False False False (acc <> TB.singleton c) rest
consumeCBlockComment :: Text -> (Text, Text)
consumeCBlockComment txt =
let afterOpen = T.drop 2 txt
(inside, suffix) = T.breakOn "*/" afterOpen
in if T.null suffix
then (txt, "")
else ("/*" <> inside <> "*/", T.drop 2 suffix)
commentReplacement :: Text -> Text
commentReplacement commentText
| commentText == "/**/" = ""
| otherwise = T.replicate (T.length (commentBody commentText)) " "
commentBody :: Text -> Text
commentBody commentText =
if "/*" `T.isPrefixOf` commentText && "*/" `T.isSuffixOf` commentText
then T.dropEnd 2 (T.drop 2 commentText)
else T.drop 2 commentText
-- | Parse function-like macro call arguments.
parseCallArgs :: Text -> Maybe ([Text], Text)
parseCallArgs input = do
('(', rest) <- T.uncons (T.dropWhile isSpace input)
parseArgs False False False 0 [] mempty rest
parseArgs :: Bool -> Bool -> Bool -> Int -> [Text] -> TB.Builder -> Text -> Maybe ([Text], Text)
parseArgs inString inChar escaped depth argsRev current remaining =
case T.uncons remaining of
Nothing -> Nothing
Just (ch, rest)
| inString ->
let escaped' = ch == '\\' && not escaped
inString' = not (ch == '"' && not escaped)
in parseArgs inString' False escaped' depth argsRev (current <> TB.singleton ch) rest
| inChar ->
let escaped' = ch == '\\' && not escaped
inChar' = not (ch == '\'' && not escaped)
in parseArgs False inChar' escaped' depth argsRev (current <> TB.singleton ch) rest
| startsHsBlockComment remaining ->
let (commentText, afterComment) = consumeHsBlockComment remaining
in parseArgs False False False depth argsRev (current <> TB.fromText commentText) afterComment
| ch == '"' ->
parseArgs True False False depth argsRev (current <> TB.singleton ch) rest
| ch == '\'' ->
parseArgs False True False depth argsRev (current <> TB.singleton ch) rest
| ch == '(' ->
parseArgs False False False (depth + 1) argsRev (current <> TB.singleton ch) rest
| ch == ')' && depth > 0 ->
parseArgs False False False (depth - 1) argsRev (current <> TB.singleton ch) rest
| ch == ')' && depth == 0 ->
let arg = trimSpacesText (builderToText current)
argsRev' =
if T.null arg && null argsRev
then [""]
else arg : argsRev
in Just (reverse argsRev', rest)
| ch == ',' && depth == 0 ->
let arg = trimSpacesText (builderToText current)
in parseArgs False False False depth (arg : argsRev) mempty rest
| ch == '-' && depth == 0,
Just ('-', afterDash) <- T.uncons rest ->
-- Haskell line comment inside arg list: close the arg, find ')' in comment
let commentText = "--" <> afterDash
in case findLastCloseParen commentText of
Nothing -> Nothing
Just (commentPrefix, afterClose) ->
let currentText = builderToText current
arg = trimSpacesText currentText
trailingWS = T.takeWhileEnd isSpace currentText
argsRev' = if T.null arg && null argsRev then [""] else arg : argsRev
in Just (reverse argsRev', trailingWS <> commentPrefix <> afterClose)
| otherwise ->
parseArgs False False False depth argsRev (current <> TB.singleton ch) rest
-- | Find the last ')' in text and split before it.
findLastCloseParen :: Text -> Maybe (Text, Text)
findLastCloseParen txt =
case T.findIndex (== ')') (T.reverse txt) of
Nothing -> Nothing
Just revIdx ->
let idx = T.length txt - revIdx - 1
in Just (T.take idx txt, T.drop (idx + 1) txt)
startsHsBlockComment :: Text -> Bool
startsHsBlockComment txt =
case T.uncons txt of
Just ('{', rest) ->
case T.uncons rest of
Just ('-', rest') ->
case T.uncons rest' of
Just ('#', _) -> False
_ -> True
_ -> False
_ -> False
consumeHsBlockComment :: Text -> (Text, Text)
consumeHsBlockComment = go 0 mempty
where
go :: Int -> TB.Builder -> Text -> (Text, Text)
go depth acc txt =
case T.uncons txt of
Nothing -> (builderToText acc, "")
Just (c, rest) ->
case T.uncons rest of
Just ('-', rest')
| c == '{' ->
go (depth + 1) (acc <> TB.fromText "{-") rest'
Just ('}', rest')
| c == '-' && depth <= 1 ->
(builderToText (acc <> TB.fromText "-}"), rest')
Just ('}', rest')
| c == '-' ->
go (depth - 1) (acc <> TB.fromText "-}") rest'
_ ->
go depth (acc <> TB.singleton c) rest
data Piece
= PieceWhitespace !Text
| PiecePaste
| PieceRaw !Text
| PieceParam !Text
substituteParams :: Map Text Text -> Text -> Text
substituteParams = substituteParamsBuilder
-- | Builder-based parameter substitution. Replaces identifiers found
-- in the substitution map, respecting string and char literals.
substituteParamsBuilder :: Map Text Text -> Text -> Text
substituteParamsBuilder subs = renderPieces . collapseTokenPastes . collapseStringizing . tokenizeReplacementList
where
tokenizeReplacementList :: Text -> [Piece]
tokenizeReplacementList txt =
case T.uncons txt of
Nothing -> []
Just (c, rest)
| isSpace c ->
let (spaces, remaining) = T.span isSpace txt
in PieceWhitespace spaces : tokenizeReplacementList remaining
| c == '"' ->
let (literal, remaining) = scanQuoted '"' txt
in PieceRaw literal : tokenizeReplacementList remaining
| c == '\'' ->
let (literal, remaining) = scanQuoted '\'' txt
in PieceRaw literal : tokenizeReplacementList remaining
| "/*" `T.isPrefixOf` txt ->
let (commentText, remaining) = consumeCBlockComment txt
piece = if commentText == "/**/" then PiecePaste else PieceWhitespace (commentReplacement commentText)
in piece : tokenizeReplacementList remaining
| "##" `T.isPrefixOf` txt ->
PiecePaste : tokenizeReplacementList (T.drop 2 txt)
| isIdentStart c ->
let (ident, remaining) = T.span isIdentChar txt
piece = if M.member ident subs then PieceParam ident else PieceRaw ident
in piece : tokenizeReplacementList remaining
| otherwise ->
PieceRaw (T.singleton c) : tokenizeReplacementList rest
scanQuoted :: Char -> Text -> (Text, Text)
scanQuoted quote = go False mempty
where
go escaped acc remaining =
case T.uncons remaining of
Nothing -> (builderToText acc, "")
Just (c, rest)
| c == quote && not escaped ->
(builderToText (acc <> TB.singleton c), rest)
| c == '\\' ->
go (not escaped) (acc <> TB.singleton c) rest
| otherwise ->
go False (acc <> TB.singleton c) rest
collapseStringizing :: [Piece] -> [Piece]
collapseStringizing [] = []
collapseStringizing (PieceRaw "#" : PieceParam name : rest) =
PieceRaw (stringizeArgument (lookupParam name)) : collapseStringizing rest
collapseStringizing (PieceRaw "#" : rest) =
PieceRaw "#" : collapseStringizing rest
collapseStringizing (piece : rest) = piece : collapseStringizing rest
collapseTokenPastes :: [Piece] -> [Piece]
collapseTokenPastes = go []
where
go acc [] = acc
go acc (piece : rest) =
case piece of
PiecePaste ->
let (accNoSpace, _) = trimTrailingWhitespace acc
(leadingSpace, restAfterSpace) = span isWhitespacePiece rest
in case (unsnoc accNoSpace, restAfterSpace) of
(Just (accInit, leftPiece), rightPiece : remaining) ->
go (accInit <> [PieceRaw (renderPiece leftPiece <> renderPiece rightPiece)]) remaining
_ -> go (acc <> [PieceRaw "##"] <> leadingSpace) restAfterSpace
_ -> go (acc <> [piece]) rest
trimTrailingWhitespace :: [Piece] -> ([Piece], [Piece])
trimTrailingWhitespace pieces =
let (trailingRev, restRev) = span isWhitespacePiece (reverse pieces)
in (reverse restRev, reverse trailingRev)
unsnoc :: [a] -> Maybe ([a], a)
unsnoc [] = Nothing
unsnoc [x] = Just ([], x)
unsnoc (x : xs) = do
(init', last') <- unsnoc xs
pure (x : init', last')
isWhitespacePiece :: Piece -> Bool
isWhitespacePiece (PieceWhitespace _) = True
isWhitespacePiece _ = False
lookupParam :: Text -> Text
lookupParam name = M.findWithDefault name name subs
renderPieces :: [Piece] -> Text
renderPieces = T.concat . map renderPiece
renderPiece :: Piece -> Text
renderPiece piece =
case piece of
PieceWhitespace txt -> txt
PiecePaste -> "##"
PieceRaw txt -> txt
PieceParam name -> lookupParam name
stringizeArgument :: Text -> Text
stringizeArgument arg =
let normalized = normalizeWhitespace arg
escaped = T.concatMap escapeStringChar normalized
in T.cons '"' (T.snoc escaped '"')
normalizeWhitespace :: Text -> Text
normalizeWhitespace = T.unwords . T.words
escapeStringChar :: Char -> Text
escapeStringChar '"' = "\\\""
escapeStringChar '\\' = "\\\\"
escapeStringChar c = T.singleton c
evalCondition :: EngineState -> Text -> Bool
evalCondition st expr = eval expr /= 0
where
macros = stMacros st
eval = evalNumeric . replaceRemainingWithZero . expandMacros st . replaceDefined macros
evalNumeric :: Text -> Integer
evalNumeric input =
let tokens = tokenize input
in case parseExpr tokens of
(val, _) -> val
data Token = TOp Text | TNum Integer | TIdent Text | TOpenParen | TCloseParen deriving (Show)
tokenize :: Text -> [Token]
tokenize input =
case T.uncons input of
Nothing -> []
Just (c, rest)
| isSpace c ->
tokenize (T.dropWhile isSpace rest)
| isDigit c ->
let (num, remaining) = T.span isDigit input
in case TR.decimal num of
Right (value, _) -> TNum value : tokenize remaining
Left _ -> tokenize remaining
| isIdentStart c ->
let (ident, remaining) = T.span isIdentChar input
in TIdent ident : tokenize remaining
| c == '(' ->
TOpenParen : tokenize rest
| c == ')' ->
TCloseParen : tokenize rest
| otherwise ->
let (op, remaining) = T.span isOpChar input
in if T.null op
then tokenize rest
else TOp op : tokenize remaining
parseExpr :: [Token] -> (Integer, [Token])
parseExpr = parseOr
binary :: ([Token] -> (Integer, [Token])) -> [Text] -> [Token] -> (Integer, [Token])
binary next ops ts =
let (v1, ts1) = next ts
in go v1 ts1
where
go v1 (TOp op : ts2)
| op `elem` ops =
let (v2, ts3) = next ts2
in go (apply op v1 v2) ts3
go v1 ts2 = (v1, ts2)
apply "||" a b = if a /= 0 || b /= 0 then 1 else 0
apply "&&" a b = if a /= 0 && b /= 0 then 1 else 0
apply "==" a b = if a == b then 1 else 0
apply "!=" a b = if a /= b then 1 else 0
apply "<" a b = if a < b then 1 else 0
apply ">" a b = if a > b then 1 else 0
apply "<=" a b = if a <= b then 1 else 0
apply ">=" a b = if a >= b then 1 else 0
apply "+" a b = a + b
apply "-" a b = a - b
apply "*" a b = a * b
apply "/" a b = if b == 0 then 0 else a `div` b
apply "%" a b = if b == 0 then 0 else a `mod` b
apply _ a _ = a
parseOr, parseAnd, parseEq, parseRel, parseAdd, parseMul :: [Token] -> (Integer, [Token])
parseOr = binary parseAnd ["||"]
parseAnd = binary parseEq ["&&"]
parseEq = binary parseRel ["==", "!="]
parseRel = binary parseAdd ["<", ">", "<=", ">="]
parseAdd = binary parseMul ["+", "-"]
parseMul = binary parseUnary ["*", "/", "%"]
parseUnary :: [Token] -> (Integer, [Token])
parseUnary (TOp "!" : ts) = let (v, ts') = parseUnary ts in (if v == 0 then 1 else 0, ts')
parseUnary (TOp "-" : ts) = let (v, ts') = parseUnary ts in (-v, ts')
parseUnary ts = parseAtom ts
parseAtom :: [Token] -> (Integer, [Token])
parseAtom (TNum n : ts) = (n, ts)
parseAtom (TIdent _ : ts) = (0, ts)
parseAtom (TOpenParen : ts) =
let (v, ts1) = parseExpr ts
in case ts1 of
TCloseParen : ts2 -> (v, ts2)
_ -> (v, ts1)
parseAtom ts = (0, ts)
replaceDefined :: Map Text MacroDef -> Text -> Text
replaceDefined macros = go
where
go txt =
case T.uncons txt of
Nothing -> ""
Just (c, rest)
| "defined" `T.isPrefixOf` txt && not (nextCharIsIdent (T.drop 7 txt)) ->
expandDefined (T.dropWhile isSpace (T.drop 7 txt))
| otherwise ->
T.cons c (go rest)
expandDefined rest =
case T.uncons rest of
Just ('(', restAfterOpen) ->
let rest' = T.dropWhile isSpace restAfterOpen
(name, restAfterName0) = T.span isIdentChar rest'
restAfterName = T.dropWhile isSpace restAfterName0
in case T.uncons restAfterName of
Just (')', restAfterClose) ->
boolLiteral (M.member name macros) <> go restAfterClose
_ ->
boolLiteral False <> go restAfterName
_ ->
let (name, restAfterName) = T.span isIdentChar rest
in if T.null name
then boolLiteral False <> go rest
else boolLiteral (M.member name macros) <> go restAfterName
boolLiteral True = " 1 "
boolLiteral False = " 0 "
nextCharIsIdent remaining =
case T.uncons remaining of
Just (c, _) -> isIdentChar c
Nothing -> False
replaceRemainingWithZero :: Text -> Text
replaceRemainingWithZero = go
where
go txt =
case T.uncons txt of
Nothing -> ""
Just (c, rest)
| isIdentStart c ->
let (_, remaining) = T.span isIdentChar txt
in " 0 " <> go remaining
| otherwise ->
T.cons c (go rest)
builderToText :: TB.Builder -> Text
builderToText = TL.toStrict . TB.toLazyText
trimSpacesText :: Text -> Text
trimSpacesText = T.dropWhileEnd isSpace . T.dropWhile isSpace