packages feed

aihc-cpp-2.0.0.0: src/Aihc/Cpp/Scanner.hs

{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE OverloadedStrings #-}

module Aihc.Cpp.Scanner
  ( LineSpan (..),
    LineScan (..),
    scanLine,
    scanLineDepthOnly,
    expandLineBySpanMultiline,
  )
where

import Aihc.Cpp.Cursor
  ( Cursor (..),
    findNewline,
    null,
    skipNewline,
    sliceBytes,
  )
import Aihc.Cpp.Evaluator (expandMacros, expandMacrosMultiline)
import Aihc.Cpp.Types (EngineState)
import Data.Bits (bit, testBit, (.|.))
import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as C
import qualified Data.ByteString.Unsafe as BSU
import Data.Word (Word64, Word8)
import Prelude hiding (null)

data LineSpan = LineSpan
  { lineSpanInBlockComment :: !Bool,
    lineSpanText :: !ByteString
  }

data LineScan = LineScan
  { lineScanSpans :: ![LineSpan],
    lineScanFinalHsDepth :: !Int,
    lineScanFinalCDepth :: !Int
  }

-- | Expand macros in a list of line spans (single-line, no lookahead).
expandLineBySpan :: EngineState -> [LineSpan] -> ByteString
expandLineBySpan st =
  C.concat . map expandSpan
  where
    expandSpan lineChunk
      | lineSpanInBlockComment lineChunk = lineSpanText lineChunk
      | otherwise = expandMacros st (lineSpanText lineChunk)

-- | Expand macros in a list of line spans with multi-line lookahead.
-- When a function macro call spans multiple lines, continuation lines
-- are consumed from the @futureCursor@ (positioned after the current line).
-- Returns (expanded text, number of extra lines consumed).
--
-- Multi-line expansion is only attempted for lines that consist entirely
-- of code spans (no inline comments). Mixed code/comment lines use
-- single-line expansion to preserve comment span positions.
expandLineBySpanMultiline :: EngineState -> [LineSpan] -> Cursor -> (ByteString, Int)
expandLineBySpanMultiline st spans futureCursor =
  let commentSpans = filter lineSpanInBlockComment spans
      hasLineComment = any (\s -> "--" `C.isPrefixOf` lineSpanText s) commentSpans
      hasCBlockComment = any (C.all (== ' ') . lineSpanText) commentSpans
      hasHsComment = case commentSpans of
        [] -> False
        _ -> not hasCBlockComment
   in if hasLineComment || hasHsComment
        then -- Haskell comments stay in the token stream, so expand the full line.
          let fullText = concatSpans spans
           in (expandMacros st fullText, 0)
        else
          if hasCBlockComment
            then -- C comments are stripped to spaces, so preserve per-span handling.
              (expandLineBySpan st spans, 0)
            else -- Pure code line: try multi-line expansion
              let codeText = concatSpans spans
                  futureCodeLines = cursorToLines futureCursor
               in expandMacrosMultiline st codeText futureCodeLines

-- | Join the text of a line's spans. A line with no comment on it is a
-- single span, and returning that slice unchanged keeps the common case
-- zero-copy; 'C.concat' would copy it.
concatSpans :: [LineSpan] -> ByteString
concatSpans [one] = lineSpanText one
concatSpans spans = C.concat (map lineSpanText spans)

-- | Extract lines from a cursor as a lazy list of byte slices.
-- Each line is the content up to the next newline (or EOF).
cursorToLines :: Cursor -> [ByteString]
cursorToLines !cur
  | null cur = []
  | otherwise =
      let eol = findNewline cur
          lineText = sliceBytes (curPos cur) (curPos eol) cur
       in lineText : maybe [] cursorToLines (skipNewline eol)

-- | Lightweight scan that only tracks block comment depth changes.
-- Does not build 'LineSpan' segments or track string/char literals.
-- Used for inactive conditional branches where only comment depth
-- tracking is needed (no macro expansion or span splitting).
--
-- Takes the text of one logical line.
scanLineDepthOnly :: Int -> Int -> ByteString -> (Int, Int)
scanLineDepthOnly hsDepth0 cDepth0 line = goDepth hsDepth0 cDepth0 0
  where
    len = BS.length line

    goDepth :: Int -> Int -> Int -> (Int, Int)
    goDepth !hsDepth !cDepth !i
      -- Fewer than two bytes left: no two-character sequence can start here.
      | i + 1 >= len = (hsDepth, cDepth)
      | cDepth > 0 =
          if b1 == 0x2A && b2 == 0x2F -- '*/'
            then goDepth hsDepth 0 (i + 2)
            else goDepth hsDepth cDepth (i + 1)
      | hsDepth > 0 && b1 == 0x2D && b2 == 0x7D -- '-}'
        =
          goDepth (hsDepth - 1) cDepth (i + 2)
      | b1 == 0x7B && b2 == 0x2D -- '{-'
        =
          if hsDepth == 0 && i + 2 < len && BS.index line (i + 2) == 0x23 -- '#'
            then -- {-# is a pragma, not a comment (only at depth 0; inside a
            -- comment it is an ordinary nested opener, balancing the -} of
            -- its closing #-})
              goDepth hsDepth cDepth (i + 1)
            else goDepth (hsDepth + 1) cDepth (i + 2)
      | hsDepth == 0 && b1 == 0x2F && b2 == 0x2A -- '/*'
        =
          goDepth hsDepth 1 (i + 2)
      | hsDepth == 0 && b1 == 0x2D && b2 == 0x2D -- '--' line comment
        =
          (hsDepth, cDepth)
      | otherwise = goDepth hsDepth cDepth (i + 1)
      where
        b1 = BS.index line i
        b2 = BS.index line (i + 1)

-- | Scan a line, tracking comment depths and splitting into spans that are
-- either inside or outside block comments.
--
-- Takes the text of one logical line, and walks it by byte offset: a
-- cursor-per-byte walk allocated a 'Cursor' for every byte of the input.
--
-- The scanner splits the line into 'LineSpan' segments. Each segment is
-- tagged with whether it is inside a block comment. Code spans (outside
-- comments) are zero-copy slices of the raw input. C89 comment
-- content is replaced with spaces to preserve column alignment.
scanLine :: Int -> Int -> ByteString -> LineScan
scanLine 0 0 line
  -- Overwhelmingly the common case: a line outside any block comment that
  -- contains no comment at all is one code span, and 'isPlainCodeLine'
  -- settles that with a loop over unboxed arguments. The general scanner
  -- below threads a span accumulator, which costs an allocation per byte.
  | not (BS.null line) && isPlainCodeLine line = LineScan [LineSpan False line] 0 0
scanLine hsDepth0 cDepth0 line =
  let (spans, finalHsDepth, finalCDepth) =
        go hsDepth0 cDepth0 False False False [] 0 (hsDepth0 > 0 || cDepth0 > 0) 0
   in LineScan
        { lineScanSpans = reverse spans,
          lineScanFinalHsDepth = finalHsDepth,
          lineScanFinalCDepth = finalCDepth
        }
  where
    len = BS.length line

    -- \| Emit a span from @start@ to @end@ if non-empty, prepending to @acc@.
    emit :: [LineSpan] -> Int -> Int -> Bool -> [LineSpan]
    emit acc start end inComment
      | start >= end = acc
      | otherwise = LineSpan inComment (BS.take (end - start) (BS.drop start line)) : acc
    {-# INLINE emit #-}

    go ::
      Int ->
      Int ->
      Bool ->
      Bool ->
      Bool ->
      [LineSpan] ->
      Int ->
      Bool ->
      Int ->
      ([LineSpan], Int, Int)
    go !hsDepth !cDepth !inString !inChar !escaped !acc !spanStart !spanInComment !i
      -- End of input: flush the accumulated span.
      | i >= len =
          (emit acc spanStart i spanInComment, hsDepth, cDepth)
      -- Only one byte left: no two-character sequence is possible.
      | i + 1 >= len =
          if cDepth > 0
            then -- In a C comment: flush what came before, emit a space.
              (LineSpan True " " : emit acc spanStart i spanInComment, hsDepth, cDepth)
            else -- Include this last byte in the accumulated span.
              (emit acc spanStart (i + 1) (hsDepth > 0), hsDepth, cDepth)
      -- === C block comment mode ===
      | cDepth > 0 =
          if b1 == 0x2A && b2 == 0x2F -- '*/'
            then
              go hsDepth 0 False False False (LineSpan True "  " : emit acc spanStart i spanInComment) (i + 2) False (i + 2)
            else
              go hsDepth cDepth False False False (LineSpan True " " : emit acc spanStart i spanInComment) (i + 1) True (i + 1)
      -- === Line comment: -- (outside strings and hs comments) ===
      | not inString && not inChar && hsDepth == 0 && b1 == 0x2D && b2 == 0x2D -- '--'
        =
          (LineSpan True (BS.drop i line) : emit acc spanStart i spanInComment, hsDepth, cDepth)
      -- === Inside string literal ===
      | inString =
          let escaped' = not escaped && b1 == 0x5C -- '\\'
              inString' = escaped || b1 /= 0x22 -- '"'
           in go hsDepth cDepth inString' False escaped' acc spanStart spanInComment (i + 1)
      -- === Inside char literal ===
      | inChar =
          let escaped' = not escaped && b1 == 0x5C -- '\\'
              inChar' = escaped || b1 /= 0x27 -- '\''
           in go hsDepth cDepth False inChar' escaped' acc spanStart spanInComment (i + 1)
      -- === Start of string literal ===
      | hsDepth == 0 && b1 == 0x22 -- '"'
        =
          go hsDepth cDepth True False False acc spanStart spanInComment (i + 1)
      -- === Start of char literal ===
      | hsDepth == 0 && b1 == 0x27 -- '\''
        =
          go hsDepth cDepth False True False acc spanStart spanInComment (i + 1)
      -- === End of Haskell block comment: -} ===
      | hsDepth > 0 && b1 == 0x2D && b2 == 0x7D -- '-}'
        =
          let hsDepth' = hsDepth - 1
              -- Flush everything up to and including -} as a comment span.
              acc' = emit acc spanStart (i + 2) True
           in go hsDepth' cDepth False False False acc' (i + 2) (hsDepth' > 0) (i + 2)
      -- === Start of Haskell block comment: {- (but not a top-level {-# pragma) ===
      | b1 == 0x7B && b2 == 0x2D -- '{-'
        =
          if hsDepth == 0 && i + 2 < len && BS.index line (i + 2) == 0x23 -- '#'
            then -- A pragma, not a block comment (only outside comments;
            -- nested, {-# opens one). Advance past '{' only.
              go hsDepth cDepth False False False acc spanStart spanInComment (i + 1)
            else -- Flush any text before {-, emit {- as a comment.
              go (hsDepth + 1) cDepth False False False (LineSpan True "{-" : emit acc spanStart i spanInComment) (i + 2) True (i + 2)
      -- === Start of C block comment: /* ===
      | hsDepth == 0 && b1 == 0x2F && b2 == 0x2A -- '/*'
        =
          go hsDepth 1 False False False (LineSpan True "  " : emit acc spanStart i spanInComment) (i + 2) True (i + 2)
      -- === Normal byte: bulk-skip bytes that can start nothing ===
      | otherwise =
          go hsDepth cDepth False False False acc spanStart spanInComment (skipDull (i + 1))
      where
        b1 = BS.index line i
        b2 = BS.index line (i + 1)

    -- \| Advance past bytes that cannot start any CPP-significant
    -- two-character sequence, so runs of plain text (identifiers,
    -- whitespace, operators, non-ASCII) are stepped over without
    -- per-byte dispatch.
    skipDull :: Int -> Int
    -- The @i < len@ test guards the read on the same line: this is the
    -- innermost loop of the scan and the bounds check doubled its cost.
    skipDull !i
      | i < len && not (isInteresting (BSU.unsafeIndex line i)) = skipDull (i + 1)
      | otherwise = i

-- | Would the full scan of this line produce exactly one code span and
-- leave both comment depths at zero? That is, does the line open no
-- comment of either kind and contain no @--@ outside a literal?
--
-- This runs the same string- and char-literal state machine as 'scanLine',
-- so the two always agree about whether a @--@ or a @{-@ is a comment. It
-- carries no accumulator, so it compiles to a loop over unboxed arguments
-- that allocates nothing.
isPlainCodeLine :: ByteString -> Bool
isPlainCodeLine line = go 0 False False False
  where
    len = BS.length line

    go :: Int -> Bool -> Bool -> Bool -> Bool
    go !i !inString !inChar !escaped
      -- Fewer than two bytes left: no two-character sequence can start.
      | i + 1 >= len = True
      | inString = go (i + 1) (escaped || b1 /= 0x22) False (not escaped && b1 == 0x5C)
      | inChar = go (i + 1) False (escaped || b1 /= 0x27) (not escaped && b1 == 0x5C)
      | b1 == 0x2D && b2 == 0x2D = False -- '--'
      | b1 == 0x22 = go (i + 1) True False False -- '"'
      | b1 == 0x27 = go (i + 1) False True False -- '\''
      | b1 == 0x7B && b2 == 0x2D -- '{-'
      -- A {-# pragma is not a comment; the scan resumes after the '{'.
        =
          i + 2 < len && BS.index line (i + 2) == 0x23 && go (i + 1) False False False
      | b1 == 0x2F && b2 == 0x2A = False -- '/*'
      | otherwise = go (skipPlainDull (i + 1)) False False False
      where
        b1 = BS.index line i
        b2 = BS.index line (i + 1)

    skipPlainDull :: Int -> Int
    -- The @i < len@ test guards the read on the same line: this is the
    -- innermost loop of the scan and the bounds check doubled its cost.
    skipPlainDull !i
      | i < len && not (isInteresting (BSU.unsafeIndex line i)) = skipPlainDull (i + 1)
      | otherwise = i

-- | Can this byte start a CPP-significant two-character sequence?
--
-- A bit test against a pair of masks rather than a chain of comparisons:
-- this runs on every byte of the input, and the eight-way chain it
-- replaces cost more than the rest of the scan.
--
-- The bytes are @"@ (0x22), @\'@ (0x27), @*@ (0x2A), @-@ (0x2D), @/@
-- (0x2F), @\\@ (0x5C), @{@ (0x7B) and @}@ (0x7D). All are ASCII, so any
-- byte >= 0x80 — a continuation byte of whatever the source encoding is —
-- is uninteresting by construction.
isInteresting :: Word8 -> Bool
isInteresting b
  | b < 64 = testBit interestingLow (fromIntegral b)
  | b < 128 = testBit interestingHigh (fromIntegral b - 64)
  | otherwise = False
{-# INLINE isInteresting #-}

interestingLow :: Word64
interestingLow = bit 0x22 .|. bit 0x27 .|. bit 0x2A .|. bit 0x2D .|. bit 0x2F

interestingHigh :: Word64
interestingHigh = bit (0x5C - 64) .|. bit (0x7B - 64) .|. bit (0x7D - 64)