packages feed

aihc-cpp 1.0.0.2 → 2.0.0.0

raw patch · 19 files changed

+2571/−791 lines, 19 filesdep +hppdep +tasty-benchdep +transformersdep ~aihc-cppdep ~bytestringdep ~containersPVP ok

version bump matches the API change (PVP)

Dependencies added: hpp, tasty-bench, transformers

Dependency ranges changed: aihc-cpp, bytestring, containers

API changes (from Hackage documentation)

- Aihc.Cpp: Config :: FilePath -> !Map Text Text -> Config
+ Aihc.Cpp: Config :: FilePath -> !Map ByteString ByteString -> Config
- Aihc.Cpp: Result :: !Text -> ![Diagnostic] -> Result
+ Aihc.Cpp: Result :: !ByteString -> ![Diagnostic] -> Result
- Aihc.Cpp: [configMacros] :: Config -> !Map Text Text
+ Aihc.Cpp: [configMacros] :: Config -> !Map ByteString ByteString
- Aihc.Cpp: [resultOutput] :: Result -> !Text
+ Aihc.Cpp: [resultOutput] :: Result -> !ByteString

Files

CHANGELOG.md view
@@ -4,7 +4,85 @@  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). -## [Unreleased]+## [2.0.0.0] - 2026-09-10++### Performance++- Preprocessing the whole Stackage `lts-24.58` corpus is about 3.5x faster —+  one sweep over 5,802 CPP-using modules went from 3.07 s to 0.87 s on the+  same machine, with byte-identical output and diagnostics for every module.+  Net of the file reads every tool pays for, that is 4.5x faster than `cpphs`,+  against 1.12x before. The changes:+  - Macro expansion now scans byte offsets and copies nothing until a macro+    actually expands, so a line that names no macro is returned as the very+    `ByteString` that came in. Previously every line of every module was+    rebuilt one character at a time through a `Builder`.+  - A 64-bit first-byte filter over the macro names rejects an identifier+    that can name no macro without walking the macro map, which is the+    outcome for nearly every identifier in a Haskell module.+  - Token pasting in a function-like macro body no longer appends to the end+    of a list once per token, which was quadratic in the body length and the+    single largest source of allocation.+  - The GCC string-continuation check tests the two trailing bytes of a line+    before scanning it for quote state, removing a full scan from every line;+    and a module that defines no function-like macro skips the multi-line+    call lookahead entirely.+  - Splitting the input into lines uses `memchr` rather than a byte-at-a-time+    walk that allocated a cursor per byte of the input.+  - The line scanner works on byte offsets rather than a cursor per byte, and+    a line that opens no comment — nearly every line — is recognised by a+    loop over unboxed arguments and returned as a single span, skipping the+    accumulator-threading scanner entirely.+  - Loop-carried `where` bindings that only some branches use were thunks+    allocated once per byte and once per identifier scanned; they are now+    forced or pushed into the branch that needs them.++### Changed++- **Breaking:** the preprocessor is now agnostic to the source encoding.+  `resultOutput` is a `ByteString` rather than `Text`, and `configMacros`+  is keyed by `ByteString`. Bytes the preprocessor did not generate itself+  are copied from input to output verbatim, so a module in any encoding —+  or in no consistent encoding — passes through unchanged. Nothing but+  `Diagnostic` message text is ever decoded.++  To migrate, decode at the boundary if you want `Text`:+  `Data.Text.Encoding.decodeUtf8With Data.Text.Encoding.Error.lenientDecode (resultOutput r)`.++### Fixed++- `preprocess` no longer throws an impure exception on source that is not+  valid UTF-8 (for example a Latin-1 encoded module containing byte `0xa9`,+  as shipped in Ebnf2ps). Previously `Data.Text.Encoding.decodeUtf8` raised+  from inside a pure function, escaping the `Diagnostic` mechanism the API+  otherwise uses; such bytes now simply pass through. GHC accepts an+  undecodable byte in a comment and rejects one where a token must be+  lexed, so this leaves the encoding decision to the compiler front-end+  instead of failing modules that genuinely compile.+- Whitespace and identifier classification is now ASCII-only. Using+  `Data.Char.isSpace` on a byte treated `0xA0` — an ordinary UTF-8+  continuation byte — as whitespace, which could split a multi-byte+  character in half.+- Macro arguments are now expanded before substitution, so a function-like+  macro invocation produced by an expansion is rescanned and expanded, matching+  GHC's C preprocessor and `cpphs`. The C standard's non-recursive-expansion+  rule is honoured, so a macro is never expanded inside its own expansion.+- A pragma nested inside a Haskell block comment no longer terminates that+  comment. `{-#` is treated as a pragma delimiter only outside a comment;+  inside one it counts as an ordinary nested `{-`, balancing the `-}` of the+  closing `#-}`. Previously each such pragma decremented the comment depth,+  making CPP directives in the rest of the commented-out region live —+  producing spurious `unmatched #endif` warnings and, with a commented-out+  `#if 0`, silently dropping the comment's contents+  ([#1](https://github.com/ai-haskell-compiler/aihc-cpp/issues/1)).++## [1.0.0.3] - 2026-07-26++### Changed++- Moved development to the standalone+  [`ai-haskell-compiler/aihc-cpp`](https://github.com/ai-haskell-compiler/aihc-cpp)+  repository, including the full test and compatibility CI configuration.  ## [1.0.0.2] - 2026-05-27 
aihc-cpp.cabal view
@@ -1,11 +1,12 @@ cabal-version: 3.8 name: aihc-cpp-version: 1.0.0.2+version: 2.0.0.0 build-type: Simple license: Unlicense license-file: LICENSE extra-doc-files: CHANGELOG.md extra-source-files:+  bench/include/*.h   test/Test/Fixtures/progress/*.hs   test/Test/Fixtures/progress/*.tsv   test/Test/Fixtures/progress/includes/*.inc@@ -19,13 +20,12 @@   package provides deterministic preprocessing with include continuations and   oracle-backed tests against cpphs. -homepage: https://github.com/ai-haskell-compiler/aihc/tree/main/components/aihc-cpp-bug-reports: https://github.com/ai-haskell-compiler/aihc/issues+homepage: https://github.com/ai-haskell-compiler/aihc-cpp+bug-reports: https://github.com/ai-haskell-compiler/aihc-cpp/issues  source-repository head   type: git-  location: https://github.com/ai-haskell-compiler/aihc.git-  subdir: components/aihc-cpp+  location: https://github.com/ai-haskell-compiler/aihc-cpp.git  library   exposed-modules:@@ -41,8 +41,8 @@   hs-source-dirs: src   build-depends:     base >=4.16 && <5,-    bytestring >=0.10.8 && <0.13,-    containers >=0.5 && <0.8,+    bytestring >=0.11 && <0.13,+    containers >=0.5 && <0.9,     deepseq >=1.4 && <1.6,     filepath >=1.3.0.1 && <1.6,     text >=1.2 && <2.2,@@ -58,10 +58,10 @@     Test.Progress    build-depends:-    aihc-cpp >=1.0 && <1.1,+    aihc-cpp >=2.0 && <2.1,     base >=4.16 && <5,-    bytestring >=0.10.8 && <0.13,-    containers >=0.5 && <0.8,+    bytestring >=0.11 && <0.13,+    containers >=0.5 && <0.9,     cpphs >=1.20 && <1.21,     directory >=1.2.3 && <1.5,     filepath >=1.3.0.1 && <1.6,@@ -71,4 +71,37 @@     text >=1.2 && <2.2,    ghc-options: -Wall+  default-language: Haskell2010++benchmark micro+  type: exitcode-stdio-1.0+  hs-source-dirs: bench+  main-is: Micro.hs+  other-modules: Bench.Corpus+  build-depends:+    aihc-cpp >=2.0 && <2.1,+    base >=4.16 && <5,+    bytestring >=0.10.8 && <0.13,+    containers >=0.5 && <0.9,+    cpphs >=1.20 && <1.21,+    deepseq >=1.4 && <1.6,+    directory >=1.2.3 && <1.5,+    filepath >=1.3.0.1 && <1.6,+    hpp >=0.6 && <0.7,+    tasty-bench >=0.4 && <0.5,+    text >=1.2 && <2.2,+    transformers >=0.5 && <0.7,++  -- -M caps the heap so an oversized corpus fails with a clear heap-overflow+  -- message instead of driving the machine into swap or the OOM killer.+  -- -M caps the heap so a runaway corpus fails with a clear heap-overflow+  -- message rather than driving the machine into swap. -rtsopts lets a full+  -- snapshot sweep raise it: too low a cap does not merely abort, it makes a+  -- tool thrash against the limit and look far slower than it is.+  ghc-options:+    -Wall+    -O2+    -rtsopts+    "-with-rtsopts=-A32m -M1024m -T"+   default-language: Haskell2010
+ bench/Bench/Corpus.hs view
@@ -0,0 +1,301 @@+{-# LANGUAGE OverloadedStrings #-}++-- | Deterministic corpus generation for the aihc-cpp benchmarks.+--+-- The corpus is generated rather than committed so that it can be resized+-- without churning the repository, and it is fully deterministic so that two+-- runs on two machines benchmark byte-identical inputs.+--+-- The cases are chosen to isolate the different costs a preprocessor pays, so+-- that a regression can be attributed to one of them rather than showing up as+-- a single number that moved.+--+-- This corpus is artificial, and it is worth being explicit about where it+-- departs from reality. Measured over 1,631 CPP-using modules from 211 Hackage+-- packages: the median module is 6.2KB with 3.5% directive lines; conditionals+-- dominate the directive mix (@#if@ and @#endif@ together outnumber @#define@+-- by more than ten to one); @__GLASGOW_HASKELL__@, @MIN_VERSION_base@ and+-- @mingw32_HOST_OS@ account for most macro references, nearly all of them+-- inside @#if@ conditions rather than expanded into the output; and a module+-- that includes anything usually includes one or two headers.+--+-- So 'passthroughCase' and 'conditionalsCase' are close to real code, while+-- 'macrosCase' (a function-like macro expanded on every line) and+-- 'includesCase' (24 included files) are far heavier than anything real.+-- They are useful for isolating a cost, and misleading if read as a workload.+-- Point the benchmark at real source instead — see the AIHC_CPP_BENCH_CORPUS+-- setting in @bench\/Micro.hs@ — before drawing conclusions about throughput.+module Bench.Corpus+  ( CorpusCase (..),+    corpusCases,+    generateCorpus,+    defaultCorpusRoot,+  )+where++import System.Directory (createDirectoryIfMissing)+import System.FilePath ((</>))++-- | Where the generated corpus goes by default.+--+-- Deliberately outside @src@, @test@, @app@ and @bench@, the directories the+-- formatter and linter walk. A generated corpus is megabytes of machine-written+-- Haskell full of redundant brackets, and hlint follows @#include@ directives,+-- so letting a linter reach it produces six-figure hint counts and exhausts+-- memory. Putting it under @dist-newstyle@ makes that structurally impossible+-- rather than depending on an exclusion pattern staying correct, and means+-- @cabal clean@ disposes of it.+defaultCorpusRoot :: FilePath+defaultCorpusRoot = "dist-newstyle" </> "bench-corpus"++-- | One benchmark input: a top-level file plus any files it includes.+data CorpusCase = CorpusCase+  { -- | Short identifier, also the basename of the generated file.+    caseName :: !String,+    -- | Human-readable note about what this case stresses.+    caseDescription :: !String,+    -- | Path of the entry file, relative to the corpus root.+    caseEntry :: !FilePath,+    -- | All files to write, relative to the corpus root.+    caseFiles :: [(FilePath, String)]+  }++-- | Rough target size, in lines, for each generated case.+--+-- Large enough that a single run takes milliseconds rather than microseconds,+-- so the measurement is not dominated by timer resolution, and small enough+-- that a full sweep of three preprocessors stays interactive.+caseLines :: Int+caseLines = 4000++-- | The benchmark corpus.+--+-- The mix is deliberately weighted towards 'passthrough', because that is what+-- real CPP-using Haskell looks like: a few directives at the top and thousands+-- of lines the preprocessor merely has to copy. The remaining cases isolate+-- individual costs so a regression can be attributed.+corpusCases :: [CorpusCase]+corpusCases =+  [ passthroughCase,+    conditionalsCase,+    macrosCase,+    literalsCase,+    includesCase+  ]++-- | Write the corpus under the given root directory.+generateCorpus :: FilePath -> IO ()+generateCorpus root = do+  createDirectoryIfMissing True root+  createDirectoryIfMissing True (root </> "includes")+  mapM_ writeCase corpusCases+  where+    writeCase c = mapM_ writeOne (caseFiles c)+    writeOne (path, contents) = writeFile (root </> path) contents++-- ---------------------------------------------------------------------------+-- Deterministic pseudo-randomness+-- ---------------------------------------------------------------------------++-- | A tiny linear congruential generator (the Numerical Recipes constants).+--+-- Avoids a dependency on @random@ and, more importantly, pins the corpus to an+-- exact byte sequence that does not drift between library versions.+lcg :: Int -> Int+lcg s = (s * 1664525 + 1013904223) `mod` 2147483648++-- | An infinite deterministic stream of values drawn from a list.+pick :: Int -> [a] -> [a]+pick seed xs = go seed+  where+    n = length xs+    go s = let s' = lcg s in (xs !! (s' `mod` n)) : go s'++-- ---------------------------------------------------------------------------+-- Cases+-- ---------------------------------------------------------------------------++-- | Ordinary Haskell with a realistic sprinkling of directives.+--+-- Roughly 5% directive lines, with the rest simply copied through. This is the+-- case that best predicts the cost a preprocessor adds to a real compile, and+-- the one to weight most heavily when reading the results.+passthroughCase :: CorpusCase+passthroughCase =+  CorpusCase+    { caseName = "passthrough",+      caseDescription = "realistic module: ~5% directives, the rest copied through",+      caseEntry = "passthrough.hs",+      caseFiles = [("passthrough.hs", body)]+    }+  where+    body = unlines (header <> concat (take (caseLines `div` 20) chunks))+    header =+      [ "{-# LANGUAGE CPP #-}",+        "module Passthrough where",+        "#define VERSION_base 1",+        "#define HAS_FEATURE(x) (x)"+      ]+    chunks = zipWith chunk [0 :: Int ..] (pick 1 [0 .. 9 :: Int])+    chunk i r =+      [ "",+        "-- | Documentation for value " <> show i <> ".",+        "value" <> show i <> " :: Int -> Int",+        "value" <> show i <> " x = x + " <> show (r * i `mod` 97),+        "",+        "helper" <> show i <> " :: [Int] -> Int",+        "helper" <> show i <> " xs = sum (map value" <> show i <> " xs)",+        ""+      ]+        <> ( if i `mod` 5 == 0+               then+                 [ "#ifdef VERSION_base",+                   "guarded" <> show i <> " :: Int",+                   "guarded" <> show i <> " = " <> show i,+                   "#else",+                   "guarded" <> show i <> " :: Int",+                   "guarded" <> show i <> " = 0",+                   "#endif"+                 ]+               else+                 [ "plain" <> show i <> " :: Int",+                   "plain" <> show i <> " = " <> show i+                 ]+           )+        <> replicate 8 ("-- filler comment line for value " <> show i)++-- | Dense, deeply nested conditionals with arithmetic and @defined@.+conditionalsCase :: CorpusCase+conditionalsCase =+  CorpusCase+    { caseName = "conditionals",+      caseDescription = "nested #if/#elif/#else with arithmetic and defined()",+      caseEntry = "conditionals.hs",+      caseFiles = [("conditionals.hs", body)]+    }+  where+    body = unlines (header <> concatMap block [0 .. caseLines `div` 16])+    header =+      [ "{-# LANGUAGE CPP #-}",+        "module Conditionals where",+        "#define MAJOR 9",+        "#define MINOR 12",+        "#define GLASGOW_HASKELL 912",+        "#define WORD_SIZE_IN_BITS 64"+      ]+    block i =+      [ "#if MAJOR > 8 && MINOR >= 4",+        "#  if defined(GLASGOW_HASKELL) && GLASGOW_HASKELL >= 900",+        "#    if WORD_SIZE_IN_BITS == 64",+        "cond" <> show i <> " :: Int",+        "cond" <> show i <> " = " <> show i,+        "#    else",+        "cond" <> show i <> " :: Int",+        "cond" <> show i <> " = 0",+        "#    endif",+        "#  elif defined(MISSING)",+        "cond" <> show i <> " = -1",+        "#  else",+        "cond" <> show i <> " = -2",+        "#  endif",+        "#else",+        "cond" <> show i <> " = -3",+        "#endif"+      ]++-- | Heavy object- and function-like macro expansion.+macrosCase :: CorpusCase+macrosCase =+  CorpusCase+    { caseName = "macros",+      caseDescription = "object- and function-like macro expansion on every line",+      caseEntry = "macros.hs",+      caseFiles = [("macros.hs", body)]+    }+  where+    body = unlines (header <> concatMap block [0 .. caseLines `div` 6])+    header =+      [ "{-# LANGUAGE CPP #-}",+        "module Macros where",+        "#define MIN_VERSION_base(a,b,c) 1",+        "#define WRAP(x) (fromIntegral (x))",+        "#define PAIR(a,b) ((a), (b))",+        "#define NAME base",+        "#define WIDE(a,b,c,d) ((a) + (b) + (c) + (d))"+      ]+    block i =+      [ "macro" <> show i <> " :: Int",+        "macro" <> show i <> " = WRAP(" <> show i <> ")",+        "pair" <> show i <> " = PAIR(" <> show i <> ", " <> show (i + 1) <> ")",+        "wide" <> show i <> " = WIDE(" <> show i <> ", 2, 3, 4)",+        "#if MIN_VERSION_base(4,16,0)",+        "gated" <> show i <> " = WRAP(" <> show i <> ")",+        "#endif"+      ]++-- | String, character and comment heavy input.+--+-- Haskell-aware preprocessors track Haskell block comments and string literals+-- so they can avoid expanding macros inside them. That scanning is real work,+-- and the three implementations do differing amounts of it, so it gets its own+-- case rather than quietly taxing the average of the others.+literalsCase :: CorpusCase+literalsCase =+  CorpusCase+    { caseName = "literals",+      caseDescription = "string/char literals and Haskell comments (macro-suppression scanning)",+      caseEntry = "literals.hs",+      caseFiles = [("literals.hs", body)]+    }+  where+    body = unlines (header <> concatMap block [0 .. caseLines `div` 8])+    header =+      [ "{-# LANGUAGE CPP #-}",+        "module Literals where",+        "#define NAME notExpandedInStrings"+      ]+    block i =+      [ "text" <> show i <> " :: String",+        "text" <> show i <> " = \"NAME must not be expanded here \" ++ show " <> show i,+        "chars" <> show i <> " = ['N', 'A', 'M', 'E']",+        "{- NAME inside a Haskell block comment",+        "   spanning several lines, still NAME -}",+        "prime" <> show i <> "' = " <> show i <> " -- NAME in a line comment",+        "escaped" <> show i <> " = \"a \\\"NAME\\\" quoted\"",+        ""+      ]++-- | An include chain, exercising the continuation-based include protocol.+includesCase :: CorpusCase+includesCase =+  CorpusCase+    { caseName = "includes",+      caseDescription = "chain of #include files resolved through the continuation API",+      caseEntry = "includes.hs",+      caseFiles = ("includes.hs", entry) : map leaf [0 .. leafCount - 1]+    }+  where+    leafCount = 24 :: Int+    entry =+      unlines+        ( [ "{-# LANGUAGE CPP #-}",+            "module Includes where"+          ]+            <> ["#include \"includes/part" <> show n <> ".inc\"" | n <- [0 .. leafCount - 1]]+        )+    leaf n =+      ( "includes" </> ("part" <> show n <> ".inc"),+        unlines+          ( [ "#ifndef PART" <> show n,+              "#define PART" <> show n <> " 1",+              "#define PART" <> show n <> "_VALUE(x) ((x) + " <> show n <> ")"+            ]+              <> concat+                [ [ "part" <> show n <> "_" <> show k <> " :: Int",+                    "part" <> show n <> "_" <> show k <> " = PART" <> show n <> "_VALUE(" <> show k <> ")"+                  ]+                | k <- [0 .. (caseLines `div` leafCount) - 1 :: Int]+                ]+              <> ["#endif"]+          )+      )
+ bench/Micro.hs view
@@ -0,0 +1,692 @@+{-# LANGUAGE BangPatterns #-}+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE OverloadedStrings #-}++-- | Raw throughput of aihc-cpp against the other two pure-Haskell C+-- preprocessors on Hackage, hpp and cpphs.+--+-- All three run in-process as libraries, on the same preloaded bytes, with+-- their output forced. Nothing but preprocessing is timed: no process startup,+-- no reading the entry file, no lazy IO left unevaluated.+--+-- This deliberately does not check that the three agree. Preprocessing Haskell+-- is under-specified — the tools differ on comment handling, on rescanning, on+-- what survives inside a literal — and demanding equivalence would mean either+-- excluding the interesting inputs or holding aihc-cpp to another+-- implementation's accidents. The test suite is where behaviour is pinned down;+-- this is where speed is.+--+-- Two corpora are available:+--+-- * The default is generated (see "Bench.Corpus"): deterministic and cheap,+--   good for spotting regressions, but artificial. Its shape is a guess at what+--   real code looks like, and a guess written by the same people who wrote the+--   preprocessor being measured.+-- * Setting @AIHC_CPP_BENCH_CORPUS@ to a directory of Haskell source+--   benchmarks every CPP-using module found under it. That is the number to+--   trust when the question is throughput on real work.+module Main (main) where++import Aihc.Cpp+  ( Config (..),+    Diagnostic (..),+    IncludeRequest (..),+    Result (..),+    Severity (..),+    Step (..),+    defaultConfig,+    preprocess,+  )+import Bench.Corpus (CorpusCase (..), corpusCases, defaultCorpusRoot, generateCorpus)+import Control.DeepSeq (NFData, force)+import Control.Exception (AsyncException, SomeException, evaluate, fromException, throwIO, try)+import Control.Monad (foldM)+import Control.Monad.Trans.Except (runExceptT)+import qualified Data.ByteString as BS+import qualified Data.ByteString.Char8 as BS8+import Data.Char (isAlpha, isSpace, toLower)+import Data.Either (fromRight, rights)+import Data.List (isPrefixOf, isSuffixOf, sort, sortOn)+import qualified Data.Map.Strict as M+import Data.Maybe (fromMaybe)+import qualified Data.Text as T+import GHC.Clock (getMonotonicTime)+import GHC.Generics (Generic)+import qualified Hpp+import qualified Hpp.Config as HppConfig+import Language.Preprocessor.Cpphs+  ( BoolOptions (..),+    CpphsOptions (..),+    defaultCpphsOptions,+    runCpphs,+  )+import System.Directory (doesDirectoryExist, doesFileExist, listDirectory)+import System.Environment (lookupEnv)+import System.FilePath (splitDirectories, takeDirectory, takeExtension, (</>))+import System.IO (BufferMode (LineBuffering), hSetBuffering, stdout)+import System.IO.Unsafe (unsafePerformIO)+import Test.Tasty.Bench (Benchmark, bench, bgroup, defaultMain, nfIO)++-- | Which corpus to benchmark.+data Corpus+  = -- | The generated corpus, in a directory this program owns.+    Generated FilePath+  | -- | Real modules discovered under a directory the user supplied.+    RealWorld FilePath++-- | Read the corpus choice from the environment.+--+-- A user-supplied directory is only ever read from. An earlier version called+-- 'generateCorpus' on whatever path this variable named, which overwrote files+-- in that directory and then benchmarked the generated corpus while appearing+-- to honour the setting.+selectCorpus :: IO Corpus+selectCorpus = maybe (Generated defaultCorpusRoot) RealWorld <$> lookupEnv "AIHC_CPP_BENCH_CORPUS"++-- | How many bytes of source to preload, in total.+--+-- The corpus is held in memory three times over, once in each preprocessor's+-- input type, and a Haskell 'String' costs upwards of sixteen bytes per+-- character — so a few megabytes of source becomes a few hundred megabytes of+-- residency. Only the bytes are held now (see 'tools'), so the budget buys+-- much more coverage than it used to, but outputs and collector headroom still+-- scale with it: 8MB samples several hundred modules, 16MB samples about+-- eleven hundred and peaks near 590MB against the 512MB heap cap this binary+-- carries. Raise it for wider coverage and lower it if the cap is ever hit.+byteBudget :: IO Int+byteBudget = maybe defaultBudget read <$> lookupEnv "AIHC_CPP_BENCH_MAX_BYTES"+  where+    defaultBudget = 8 * 1024 * 1024++-- | Every @.hs@ file under a directory, as packed paths.+--+-- Iterative rather than recursive, with the accumulator forced as it goes, and+-- paths held as 'BS.ByteString' rather than 'FilePath'. Both matter at snapshot+-- scale: a full Stackage checkout is fifty thousand modules under a tree of+-- several hundred thousand entries, and a Haskell 'String' path costs around+-- two kilobytes, so keeping them unpacked exhausted the heap before the walk+-- finished.+listHsFiles :: FilePath -> IO [BS.ByteString]+listHsFiles root = sort <$> go [BS8.pack root] []+  where+    go [] acc = pure acc+    go (dir : queue) acc = do+      let dirPath = BS8.unpack dir+      entries <- fromRight [] <$> tryIO (listDirectory dirPath)+      (dirs, files) <- foldM (classify dirPath) ([], []) entries+      go (dirs <> queue) $! foldl (flip (:)) acc files+    classify dirPath (dirs, files) entry+      | "." `isPrefixOf` entry = pure (dirs, files)+      | otherwise = do+          let path = dirPath </> entry+          isDir <- doesDirectoryExist path+          pure $+            if isDir+              then (BS8.pack path : dirs, files)+              else (dirs, if ".hs" `isSuffixOf` entry then BS8.pack path : files else files)+    tryIO :: IO a -> IO (Either SomeException a)+    tryIO = try++-- | Choose the modules to benchmark: CPP-using, within the byte budget.+--+-- Candidates are examined at an even stride through the sorted file list rather+-- than from the front, so the sample spans the whole tree instead of stopping+-- inside whichever package sorts first, and only the candidates are read.+-- Deterministic, so two runs measure the same modules.+selectModules :: Int -> [BS.ByteString] -> IO [FilePath]+selectModules budget paths = take' budget (every stride paths)+  where+    -- Bound how many files are opened just to find out whether they use CPP,+    -- while still examining enough of them to fill the budget: roughly one+    -- module in ten uses CPP, and the median one is a few kilobytes.+    candidates = max 4000 (budget `div` 256)+    stride = max 1 (length paths `div` candidates)+    every n xs = case xs of+      [] -> []+      (x : rest) -> x : every n (drop (n - 1) rest)+    take' _ [] = pure []+    take' remaining (packed : rest)+      | remaining <= 0 = pure []+      | otherwise = do+          let path = BS8.unpack packed+          bytes <- fromRight BS.empty <$> (try (BS.readFile path) :: IO (Either SomeException BS.ByteString))+          if usesCpp bytes+            then (path :) <$> take' (remaining - BS.length bytes) rest+            else take' remaining rest++-- | Does this source actually contain preprocessor directives?+--+-- Modules without them would measure nothing but the cost of copying bytes,+-- which all three tools do at much the same speed and which no one is trying to+-- optimise.+usesCpp :: BS.ByteString -> Bool+usesCpp = any isDirective . BS8.lines+  where+    isDirective line = case BS8.uncons (dropBlanks line) of+      Just ('#', rest) -> any (`BS.isPrefixOf` dropBlanks rest) directives+      _ -> False+    dropBlanks = BS8.dropWhile (`elem` (" \t" :: String))+    directives =+      ["if", "ifdef", "ifndef", "elif", "else", "endif", "define", "undef", "include"]++-- | An input, identified by path, with the include path it should be given.+--+-- Nothing is preloaded. Each tool reads the file inside the timed region and+-- discards it, so peak memory is one module rather than the whole corpus and+-- there is no ceiling on how much of a snapshot can be measured. The read is+-- charged to every tool equally and quantified by the @(read only)@ row.+--+-- The include path is resolved once here rather than per tool, so all three are+-- given exactly the same one.+data Prepared = Prepared+  { prepPath :: !FilePath,+    prepSearch :: ![FilePath]+  }++-- | What one tool did with one module.+data Outcome = Outcome+  { -- | Bytes of output produced.+    outBytes :: !Int,+    -- | The tool produced output but reported an error in the source.+    outError :: !(Maybe String)+  }+  deriving (Generic, NFData)++-- | The three preprocessors behind one interface, plus the cost of feeding one.+--+-- The two parenthesised entries are not competitors. @(read only)@ is the file+-- read every tool pays for; @(read + String)@ adds the 'String' conversion that+-- cpphs's API demands. Subtract the matching baseline from a tool to compare+-- preprocessing rather than plumbing.+--+-- Each forces its output completely and returns the length, so that no tool+-- benefits from leaving a lazy structure unevaluated and all three are charged+-- for producing the whole result, and each converts the input into the shape+-- its own API demands inside the timed region.+tools :: [(String, Prepared -> IO Outcome)]+tools =+  [ ( "aihc-cpp",+      \p -> do+        source <- BS.readFile (prepPath p)+        result <- runAihc (prepSearch p) (prepPath p) source+        -- aihc-cpp reports a bad directive or an unresolvable include as a+        -- diagnostic and still returns output; hpp and cpphs throw. Reporting+        -- these separately from crashes keeps that difference visible instead+        -- of turning it into a robustness claim in either direction.+        let firstError = case [d | d <- resultDiagnostics result, diagSeverity d == Error] of+              (d : _) -> Just (T.unpack (diagMessage d))+              [] -> Nothing+        flip Outcome firstError . BS.length <$> evaluate (force (resultOutput result))+    ),+    ( "hpp",+      \p -> do+        source <- BS.readFile (prepPath p)+        out <- hpp (prepSearch p) p (BS8.lines source)+        flip Outcome Nothing . sum . map BS.length <$> evaluate (force out)+    ),+    ( "cpphs",+      \p -> do+        source <- BS.readFile (prepPath p)+        out <- runCpphs (cpphsOptions (prepSearch p)) (prepPath p) (BS8.unpack source)+        flip Outcome Nothing . length <$> evaluate (force out)+    ),+    ( "(read only)",+      \p -> flip Outcome Nothing . BS.length <$> BS.readFile (prepPath p)+    ),+    ( "(read + String)",+      \p -> do+        source <- BS.readFile (prepPath p)+        flip Outcome Nothing . length <$> evaluate (force (BS8.unpack source))+    )+  ]++main :: IO ()+main = do+  hSetBuffering stdout LineBuffering+  corpus <- selectCorpus+  case corpus of+    Generated root -> do+      generateCorpus root+      declared <- packageIncludeDirs root+      prepared <- mapM (prepare declared root . (root </>) . caseEntry) corpusCases+      mapM_ reportOne (zip corpusCases prepared)+      defaultMain+        [ bgroup (caseName c) [bench name (nfIO (run p)) | (name, run) <- tools]+        | (c, p) <- zip corpusCases prepared+        ]+    RealWorld root -> do+      warnMissingStubs+      declared <- packageIncludeDirs root+      putStrLn ("include-dirs: declared by " <> show (M.size (M.filter (not . null) declared)) <> " packages")+      found <- listHsFiles root+      full <- lookupEnv "AIHC_CPP_BENCH_SWEEP"+      selected <- lookupEnv "AIHC_CPP_BENCH_TOOLS"+      let chosen = case selected of+            Nothing -> tools+            Just names -> [t | t@(name, _) <- tools, name `elem` splitOn ',' names]+      case full of+        Just _ -> fullSweep chosen =<< mapM (prepare declared root) =<< allCppModules found+        Nothing -> do+          budget <- byteBudget+          prepared <- mapM (prepare declared root) =<< selectModules budget found+          reportCorpus (length found) prepared+          defaultMain [bgroup "real-world" (map (sweep prepared) chosen)]++splitOn :: Char -> String -> [String]+splitOn sep str = case break (== sep) str of+  (chunk, []) -> [chunk]+  (chunk, _ : rest) -> chunk : splitOn sep rest++-- | Preprocess every CPP-using module in the corpus, once, per tool.+--+-- The sampled benchmark above exists to catch regressions and needs repeated+-- runs for its statistics, which puts a practical ceiling on corpus size. This+-- answers a different question — how does each tool fare across a whole+-- snapshot — and so takes one pass over everything and reports wall clock and+-- throughput rather than a distribution.+fullSweep :: [(String, Prepared -> IO Outcome)] -> [Prepared] -> IO ()+fullSweep chosen prepared = do+  corpusBytes <- sum <$> mapM (fmap BS.length . BS.readFile . prepPath) prepared+  putStrLn+    ( "full sweep: "+        <> show (length prepared)+        <> " CPP-using modules, "+        <> show (corpusBytes `div` (1024 * 1024))+        <> " MiB"+    )+  putStrLn ""+  putStrLn+    ( pad 18 "tool"+        <> pad 9 "ok"+        <> pad 9 "errored"+        <> pad 9 "crashed"+        <> pad 11 "seconds"+        <> pad 11 "MiB out"+        <> "MiB/s"+    )+  putStrLn (replicate 74 '-')+  mapM_ (one corpusBytes) chosen+  where+    one corpusBytes (name, run) = do+      start <- getMonotonicTime+      -- Folded strictly, keeping only counters and the first few failures.+      -- Retaining every outcome kept each failure's exception alive, and with+      -- it whatever the exception's message had captured.+      tally <- foldM (step run) (Tally 0 0 0 0 [] M.empty) prepared+      elapsed <- subtract start <$> getMonotonicTime+      let mib = fromIntegral corpusBytes / 1048576 :: Double+      putStrLn+        ( pad 18 name+            <> pad 9 (show (tallyOk tally))+            <> pad 9 (show (tallyErrored tally))+            <> pad 9 (show (tallyCrashed tally))+            <> pad 11 (showFixed 2 elapsed)+            <> pad 11 (showFixed 1 (fromIntegral (tallyBytes tally) / 1048576 :: Double))+            <> showFixed 1 (mib / elapsed)+        )+      mapM_ (\msg -> putStrLn ("    " <> msg)) (reverse (tallyFailures tally))+      mapM_ reportError (take 12 (sortOn (negate . snd) (M.toList (tallyErrors tally))))+    reportError (msg, n) = putStrLn ("    " <> pad 6 (show n) <> msg)+    step run tally p = do+      outcome <- tryTool (run p)+      pure $! case outcome of+        Right o -> case outError o of+          Just msg ->+            tally+              { tallyErrored = tallyErrored tally + 1,+                tallyBytes = tallyBytes tally + outBytes o,+                tallyErrors = M.insertWith (+) (generalise msg) 1 (tallyErrors tally)+              }+          Nothing -> tally {tallyOk = tallyOk tally + 1, tallyBytes = tallyBytes tally + outBytes o}+        Left err ->+          tally+            { tallyCrashed = tallyCrashed tally + 1,+              tallyFailures = keepFew (prepPath p <> ": " <> show err) (tallyFailures tally)+            }+    keepFew msg msgs+      | length msgs >= 3 = msgs+      | otherwise = length msg `seq` (msg : msgs)+    pad n str = str <> replicate (max 1 (n - length str)) ' '+    showFixed places x =+      let scaled = round (x * 10 ^ places) :: Integer+          (whole, frac) = scaled `divMod` (10 ^ places)+       in show whole <> "." <> pad0 places (show frac)+    pad0 n str = replicate (n - length str) '0' <> str++-- | Collapse the varying part of a diagnostic so like messages group together.+generalise :: String -> String+generalise msg = case break (== ':') msg of+  (prefix, ':' : _) | prefix `elem` grouped -> prefix <> ": <name>"+  _ -> msg+  where+    grouped = ["missing include", "unterminated conditional", "unknown directive"]++-- | Running counts for one tool's pass over the corpus.+data Tally = Tally+  { tallyOk :: !Int,+    tallyErrored :: !Int,+    tallyCrashed :: !Int,+    tallyBytes :: !Int,+    tallyFailures :: [String],+    -- | How often each distinct error diagnostic was reported.+    tallyErrors :: !(M.Map String Int)+  }++-- | Every CPP-using module, with no sampling and no budget.+allCppModules :: [BS.ByteString] -> IO [FilePath]+allCppModules = fmap reverse . foldM check []+  where+    check acc packed = do+      let path = BS8.unpack packed+      bytes <- fromRight BS.empty <$> (try (BS.readFile path) :: IO (Either SomeException BS.ByteString))+      pure $! if usesCpp bytes then path : acc else acc++-- | Preprocess every module in the corpus, as one benchmark.+--+-- Real modules have a median size of a few kilobytes: too small to time+-- individually, and there are far too many to list separately. Timing the whole+-- set at once is also the workload that matters — what a build pays across a+-- project, not what one module costs.+sweep :: [Prepared] -> (String, Prepared -> IO Outcome) -> Benchmark+sweep prepared (name, run) =+  bench name (nfIO (foldM step 0 prepared))+  where+    step !acc p = (acc +) <$> safely (run p)++-- | Run a preprocessor, treating a failure of its own as zero output.+--+-- Real modules routinely reference headers that are not present and macros that+-- are never defined, and the three tools disagree about which of those is+-- fatal. A crash must not abort the sweep, but it does mean less work was done,+-- which is why the failure counts are reported alongside the timings.+safely :: IO Outcome -> IO Int+safely act = either (const 0) outBytes <$> tryTool act++-- | Catch what a preprocessor does wrong, not what the runtime does.+--+-- A plain @try \@SomeException@ also catches heap and stack overflow, which+-- turned a benchmark run that was simply given too little memory into a report+-- of hundreds of \"tool failures\" — and the tool that tripped the limit looked+-- slow rather than starved. Runtime exhaustion is a problem with how the+-- benchmark was run, so it is re-thrown and aborts the run loudly.+tryTool :: IO a -> IO (Either SomeException a)+tryTool act = do+  outcome <- try act+  case outcome of+    Left err | Just async <- fromException err -> throwIO (async :: AsyncException)+    _ -> pure outcome++prepare :: M.Map FilePath [FilePath] -> FilePath -> FilePath -> IO Prepared+prepare declared root path = pure (Prepared path (searchPathFor declared root path))++-- | Describe the discovered corpus, and how much of it each tool can handle.+reportCorpus :: Int -> [Prepared] -> IO ()+reportCorpus found prepared = do+  corpusBytes <- sum <$> mapM (fmap BS.length . BS.readFile . prepPath) prepared+  putStrLn+    ( "real-world corpus: "+        <> show (length prepared)+        <> " of "+        <> show found+        <> " sampled CPP-using modules, "+        <> show (corpusBytes `div` 1024)+        <> " KiB (raise AIHC_CPP_BENCH_MAX_BYTES to widen)"+    )+  mapM_ report tools+  where+    report (name, run) = do+      outcomes <- mapM (\p -> (,) (prepPath p) <$> tryTool (run p)) prepared+      let failed = [(path, show err) | (path, Left err) <- outcomes]+          produced = sum (map outBytes (rights (map snd outcomes)))+      putStrLn+        ( "  "+            <> name+            <> ": "+            <> show (length prepared - length failed)+            <> " ok, "+            <> show (length failed)+            <> " failed, "+            <> show (produced `div` 1024)+            <> " KiB out"+        )+      -- Name the first few failures. A tool that crashes on real input is+      -- doing less work than the others, and if it is aihc-cpp it is a bug+      -- report rather than a benchmark result.+      mapM_ (\(path, err) -> putStrLn ("      failed: " <> path <> ": " <> err)) (take 3 failed)++-- | Print how much output each tool produces on a generated case.+--+-- Not a correctness check. It is here so a wildly faster result is not mistaken+-- for a win when it is really a tool that gave up early or emitted far less.+reportOne :: (CorpusCase, Prepared) -> IO ()+reportOne (c, p) = do+  inBytes <- BS.length <$> BS.readFile (prepPath p)+  sizes <- mapM (\(name, run) -> (,) name <$> safely (run p)) tools+  putStrLn+    ( caseName c+        <> ": in "+        <> show inBytes+        <> "B, out"+        <> concat [" " <> name <> " " <> show n | (name, n) <- sizes]+    )++-- | Run hpp over the preloaded lines, returning its output chunks.+hpp :: [FilePath] -> Prepared -> [BS.ByteString] -> IO [BS.ByteString]+hpp dirs p inputLines = do+  result <-+    runExceptT+      ( Hpp.runHpp+          (hppState (hppConfig dirs (prepPath p)))+          (Hpp.preprocess inputLines)+      )+  case result of+    Left err -> error ("hpp failed on " <> prepPath p <> ": " <> show err)+    Right (out, _) -> pure (Hpp.hppOutput out)++-- | hpp's initial state, carrying the same predefined macros as the others.+hppState :: HppConfig.Config -> Hpp.HppState+hppState config = foldl' define (Hpp.initHppState config mempty) predefinedMacros+  where+    define st (name, value) =+      fromMaybe (error ("hpp rejected -D" <> BS8.unpack name)) (Hpp.addDefinition name value st)++hppConfig :: [FilePath] -> FilePath -> HppConfig.Config+hppConfig dirs path =+  fromMaybe+    (error "hpp configuration incomplete")+    ( HppConfig.realizeConfig+        HppConfig.defaultConfigF+          { HppConfig.curFileNameF = Just path,+            HppConfig.includePathsF = Just (takeDirectory path : dirs)+          }+    )++-- | The same cpphs configuration the correctness oracle in @test/@ uses, so the+-- implementation being timed is the one already being compared against.+cpphsOptions :: [FilePath] -> CpphsOptions+cpphsOptions dirs =+  defaultCpphsOptions+    { boolopts = (boolopts defaultCpphsOptions) {stripC89 = True, warnings = False},+      includes = dirs,+      defines = [(BS8.unpack name, BS8.unpack value) | (name, value) <- predefinedMacros]+    }++-- | Macros the compiler defines, which no header supplies.+--+-- @__GLASGOW_HASKELL__@ is the single most referenced macro in real Haskell —+-- around a thousand modules in a snapshot test it — and GHC passes it with+-- @-D@ rather than putting it in a header, so no amount of include-path+-- fixing makes it appear. Left undefined it is zero in an @#if@, which is not+-- an error but silently sends every version test down its oldest branch, so+-- the corpus preprocesses code that no real build would.+--+-- The version tracks the compiler the pinned snapshot names (@with-compiler:+-- ghc-9.10.3@ for lts-24.58), in GHC's major*100+minor encoding. Bump it with+-- the snapshot.+predefinedMacros :: [(BS.ByteString, BS.ByteString)]+predefinedMacros =+  [ ("__GLASGOW_HASKELL__", "910"),+    ("__GLASGOW_HASKELL_PATCHLEVEL1__", "3"),+    ("__GLASGOW_HASKELL_PATCHLEVEL2__", "0"),+    -- GHC also defines the host and build platform, in these exact forms+    -- (confirmed against @ghc -E@). Without them a module that dispatches on+    -- platform falls through to its @#error@ branch, and the 731 modules that+    -- test @mingw32_HOST_OS@ take the non-Windows path for the wrong reason.+    --+    -- A fixed platform is claimed rather than the host's, so that a number+    -- measured on one machine is comparable with one measured on another: the+    -- platform decides which branches exist to be preprocessed at all.+    ("x86_64_HOST_ARCH", "1"),+    ("x86_64_BUILD_ARCH", "1"),+    ("linux_HOST_OS", "1"),+    ("linux_BUILD_OS", "1")+  ]++-- | Directories holding stand-ins for headers a real build would supply.+--+-- Defaults to @bench\/include@, relative to the package root where @cabal+-- bench@ runs. @AIHC_CPP_BENCH_INCLUDE@ overrides it with a colon-separated+-- list, so a corpus that needs headers of its own can add a directory.+stubIncludes :: [FilePath]+stubIncludes = unsafeStubIncludes++{-# NOINLINE unsafeStubIncludes #-}+unsafeStubIncludes :: [FilePath]+unsafeStubIncludes =+  unsafePerformIO+    (maybe ["bench" </> "include"] (splitOn ':') <$> lookupEnv "AIHC_CPP_BENCH_INCLUDE")++-- | Report which stub directories were found, and which were not.+--+-- Worth saying out loud: a missing directory does not fail, it just means more+-- modules do not resolve their includes, which shows up as a worse failure+-- count with no indication of why.+warnMissingStubs :: IO ()+warnMissingStubs = mapM_ check stubIncludes+  where+    check dir = do+      present <- doesDirectoryExist dir+      putStrLn $+        if present+          then "stub headers: " <> dir+          else "warning: no stub headers at " <> dir <> " (set AIHC_CPP_BENCH_INCLUDE)"++-- | Where to look for an @#include@ target, beyond the including file's own+-- directory.+--+-- A real build passes include directories that a bare source tree does not+-- have: the RTS headers GHC ships (stubbed under @bench\/include@) and whatever+-- the package declares in @include-dirs@, which is where nearly all of the+-- corpus keeps its own headers. Without them around a hundred modules per+-- snapshot fail to resolve @MachDeps.h@ alone, and since the three tools+-- disagree about whether an unresolvable include is fatal, the failure counts+-- end up describing include resolution rather than the preprocessors.+searchPathFor :: M.Map FilePath [FilePath] -> FilePath -> FilePath -> [FilePath]+searchPathFor declared root path =+  stubIncludes <> declaredDirs <> [packageDir </> "include", packageDir, root]+  where+    packageDir = packageDirOf root path+    declaredDirs = M.findWithDefault [] packageDir declared++-- | The package a corpus file belongs to.+--+-- Corpus layout is @\<root\>\/\<package-version\>\/...@, so the package+-- directory is the first component below the root.+packageDirOf :: FilePath -> FilePath -> FilePath+packageDirOf root path = case stripPrefixDir root path of+  Just (component : _) -> root </> component+  _ -> takeDirectory path++-- | Read @include-dirs@ out of every package's @.cabal@ file, once.+--+-- Packages keep their headers wherever they like — @src@, @cbits@, @srcinc@ —+-- and tell Cabal about it with @include-dirs@. Guessing @\<package\>\/include@+-- covers almost none of them: of the include sites that failed to resolve+-- before this, nine in ten named a header that was present in its own package+-- but in a directory only the @.cabal@ file knows about.+--+-- This is a deliberately loose parser. It takes every @include-dirs@ field in+-- the file regardless of which stanza or conditional it sits under, because the+-- benchmark wants a superset: a directory that some other component would have+-- used costs nothing here, whereas a missing one costs a module.+packageIncludeDirs :: FilePath -> IO (M.Map FilePath [FilePath])+packageIncludeDirs root = do+  packages <- fromRight [] <$> (try (listDirectory root) :: IO (Either SomeException [FilePath]))+  M.fromList . concat <$> mapM forPackage packages+  where+    forPackage name = do+      let dir = root </> name+      entries <- fromRight [] <$> (try (listDirectory dir) :: IO (Either SomeException [FilePath]))+      case filter ((== ".cabal") . takeExtension) entries of+        [] -> pure []+        (cabalFile : _) -> do+          contents <- fromRight BS.empty <$> (try (BS.readFile (dir </> cabalFile)) :: IO (Either SomeException BS.ByteString))+          pure [(dir, map (dir </>) (parseIncludeDirs contents))]++-- | Pull the values of every @include-dirs@ field out of a @.cabal@ file.+--+-- A field's value may sit on its own line, on following lines indented further,+-- and be separated by commas or whitespace. A following line that looks like+-- another field ends the list.+parseIncludeDirs :: BS.ByteString -> [FilePath]+parseIncludeDirs = go . BS8.lines+  where+    go [] = []+    go (line : rest) = case BS8.break (== ':') (BS8.map toLower line) of+      (name, remainder)+        | BS8.strip name == "include-dirs",+          not (BS.null remainder) ->+            let indent = BS8.length (BS8.takeWhile isSpace line)+                (continued, after) = span (isContinuation indent) rest+             in values (BS.drop 1 (BS8.drop (BS8.length name) line))+                  <> concatMap values continued+                  <> go after+      _ -> go rest+    isContinuation indent line =+      not (BS.null (BS8.strip line))+        && BS8.length (BS8.takeWhile isSpace line) > indent+        && not (isField line)+    isField line = case BS8.break (== ':') line of+      (name, remainder) -> not (BS.null remainder) && BS8.all fieldChar (BS8.strip name)+    fieldChar c = isAlpha c || c == '-'+    values =+      map BS8.unpack+        . concatMap (filter (not . BS.null) . BS8.splitWith isSpace)+        . BS8.split ','++stripPrefixDir :: FilePath -> FilePath -> Maybe [FilePath]+stripPrefixDir root path = go (splitDirectories root) (splitDirectories path)+  where+    go [] rest = Just rest+    go (r : rs) (p : ps) | r == p = go rs ps+    go _ _ = Nothing++-- | Preprocess a file whose contents are already in memory, searching the given+-- directories for @#include@ targets as they are requested.+--+-- Includes are read from disk rather than preloaded because that is what cpphs+-- and hpp do: they take the entry file's contents and go to the file system for+-- the rest. Preloading them for aihc-cpp alone would hand it an advantage the+-- other two cannot have.+runAihc :: [FilePath] -> FilePath -> BS.ByteString -> IO Result+runAihc dirs path source =+  go (preprocess config source)+  where+    config =+      defaultConfig+        { configInputFile = path,+          configMacros = M.union (M.fromList predefinedMacros) (configMacros defaultConfig)+        }+    go (Done r) = pure r+    go (NeedInclude req k) = do+      contents <- firstExisting (candidates req)+      go (k contents)+    candidates req = [dir </> includePath req | dir <- includeDirs req]+    includeDirs req =+      let fromDir = takeDirectory (includeFrom req)+       in (if null fromDir then takeDirectory path else fromDir) : dirs+    firstExisting [] = pure Nothing+    firstExisting (candidate : rest) = do+      exists <- doesFileExist candidate+      if exists then Just <$> BS.readFile candidate else firstExisting rest
+ bench/include/HsBaseConfig.h view
@@ -0,0 +1,80 @@+/*+ * A stand-in for the HsBaseConfig.h that GHC ships with base, for the+ * benchmark corpus only.+ *+ * Eighteen modules in a Stackage snapshot include this header. The real one is+ * around nine hundred lines, almost all of it errno constants that no module in+ * the corpus refers to; what they do use is the HTYPE_ family, which names the+ * Haskell type corresponding to a C typedef and is substituted into type+ * declarations. Only those are defined here.+ *+ * The mapping below describes a conventional 64-bit Unix. Nothing is compiled,+ * only preprocessed, so the values need only be defined and expand to something+ * shaped like a type name. Written from the macro names the real header+ * defines rather than copied from it, so that this repository stays under a+ * single licence.+ */++#pragma once++/* C scalar types. */+#define HTYPE_CHAR Int8+#define HTYPE_SIGNED_CHAR Int8+#define HTYPE_UNSIGNED_CHAR Word8+#define HTYPE_SHORT Int16+#define HTYPE_UNSIGNED_SHORT Word16+#define HTYPE_INT Int32+#define HTYPE_UNSIGNED_INT Word32+#define HTYPE_LONG Int64+#define HTYPE_UNSIGNED_LONG Word64+#define HTYPE_LONG_LONG Int64+#define HTYPE_UNSIGNED_LONG_LONG Word64+#define HTYPE_FLOAT Float+#define HTYPE_DOUBLE Double+#define HTYPE_WCHAR_T Int32++/* <stdint.h> and <stddef.h> typedefs. */+#define HTYPE_SIZE_T Word64+#define HTYPE_PTRDIFF_T Int64+#define HTYPE_INTPTR_T Int64+#define HTYPE_UINTPTR_T Word64+#define HTYPE_INTMAX_T Int64+#define HTYPE_UINTMAX_T Word64+#define HTYPE_SIG_ATOMIC_T Int32++/* POSIX typedefs. */+#define HTYPE_DEV_T Word64+#define HTYPE_INO_T Word64+#define HTYPE_MODE_T Word32+#define HTYPE_OFF_T Int64+#define HTYPE_PID_T Int32+#define HTYPE_NLINK_T Word64+#define HTYPE_UID_T Word32+#define HTYPE_GID_T Word32+#define HTYPE_SSIZE_T Int64+#define HTYPE_ID_T Word32+#define HTYPE_KEY_T Int32+#define HTYPE_BLKSIZE_T Int64+#define HTYPE_BLKCNT_T Int64+#define HTYPE_FSBLKCNT_T Word64+#define HTYPE_FSFILCNT_T Word64+#define HTYPE_RLIM_T Word64+#define HTYPE_CLOCKID_T Int32+#define HTYPE_TIMER_T Word64+#define HTYPE_TIME_T Int64+#define HTYPE_CLOCK_T Int64+#define HTYPE_USECONDS_T Word32+#define HTYPE_SUSECONDS_T Int64+#define HTYPE_NLINK_T_SIGNED 0++/* Terminal control typedefs. */+#define HTYPE_CC_T Word8+#define HTYPE_SPEED_T Word64+#define HTYPE_TCFLAG_T Word64++/* Feature flags the corpus checks for. */+#define HAVE_UNISTD_H 1+#define HAVE_SYS_TYPES_H 1+#define HAVE_SYS_STAT_H 1+#define HAVE_TERMIOS_H 1+#define HAVE_SIGNAL_H 1
+ bench/include/MachDeps.h view
@@ -0,0 +1,65 @@+/*+ * A stand-in for GHC's MachDeps.h, for the benchmark corpus only.+ *+ * Around a hundred modules in a Stackage snapshot include this header. In a+ * real build GHC supplies it from the RTS include directory; a bare source tree+ * has no such directory, so those modules fail to preprocess and drop out of+ * the measurement — and the tools disagree about whether that is fatal, which+ * made the failure counts describe include resolution rather than the+ * preprocessors.+ *+ * The values below describe a conventional 64-bit platform. They are not read+ * for accuracy: nothing here is compiled, only preprocessed, so all that+ * matters is that the macros are defined and that arithmetic in #if conditions+ * evaluates. This is written from the macro names GHC's header defines rather+ * than copied from it, so that this repository stays under a single licence.+ */++#pragma once++#define WORD_SIZE_IN_BITS 64+#define WORD_SIZE_IN_BITS_FLOAT 64++#define SIZEOF_HSCHAR 4+#define ALIGNMENT_HSCHAR 4+#define SIZEOF_HSINT 8+#define ALIGNMENT_HSINT 8+#define SIZEOF_HSWORD 8+#define ALIGNMENT_HSWORD 8+#define SIZEOF_HSFLOAT 4+#define ALIGNMENT_HSFLOAT 4+#define SIZEOF_HSDOUBLE 8+#define ALIGNMENT_HSDOUBLE 8+#define SIZEOF_HSPTR 8+#define ALIGNMENT_HSPTR 8+#define SIZEOF_HSFUNPTR 8+#define ALIGNMENT_HSFUNPTR 8+#define SIZEOF_HSSTABLEPTR 8+#define ALIGNMENT_HSSTABLEPTR 8++#define SIZEOF_INT8 1+#define ALIGNMENT_INT8 1+#define SIZEOF_WORD8 1+#define ALIGNMENT_WORD8 1+#define SIZEOF_INT16 2+#define ALIGNMENT_INT16 2+#define SIZEOF_WORD16 2+#define ALIGNMENT_WORD16 2+#define SIZEOF_INT32 4+#define ALIGNMENT_INT32 4+#define SIZEOF_WORD32 4+#define ALIGNMENT_WORD32 4+#define SIZEOF_INT64 8+#define ALIGNMENT_INT64 8+#define SIZEOF_WORD64 8+#define ALIGNMENT_WORD64 8++#define SIZEOF_VOID_P 8+#define ALIGNMENT_VOID_P 8+#define SIZEOF_LONG 8+#define ALIGNMENT_LONG 8+#define SIZEOF_UNSIGNED_LONG 8+#define ALIGNMENT_UNSIGNED_LONG 8++#define TAG_BITS 3+#define TAG_MASK ((1 << TAG_BITS) - 1)
+ bench/include/cabal_macros.h view
@@ -0,0 +1,27 @@+/*+ * A deliberately empty stand-in for the cabal_macros.h that Cabal generates,+ * for the benchmark corpus only.+ *+ * Cabal writes one of these per package during a build, defining VERSION_ and+ * MIN_VERSION_ for that package's own dependencies. A bare source tree has+ * none, so a module that includes it by name fails to resolve the include —+ * and because the three preprocessors disagree about whether an unresolvable+ * include is fatal, that turned into a difference in the failure counts that+ * said nothing about preprocessing.+ *+ * This file exists so the include resolves. It defines nothing on purpose.+ *+ * A snapshot-wide file with a MIN_VERSION_ for all 3441 packages was tried and+ * is not here, for two reasons. Resolving the include is nearly all of the+ * benefit: with the macros defined, only two more modules preprocessed+ * cleanly, because an undefined macro in an #if simply takes the other branch+ * rather than failing. And it cannot be pre-included the way a real build+ * pre-includes cabal_macros.h: even pruned to the packages the corpus names it+ * is 212KB against an average module of 12KB, which turned a 71MiB corpus into+ * 1.3GiB and made the benchmark measure macro-file parsing (3.26s to 69.56s).+ *+ * A real per-package cabal_macros.h is small because it holds only that+ * package's dependencies. A shared one cannot be, so this one holds nothing.+ */++#pragma once
src/Aihc/Cpp.hs view
@@ -39,16 +39,14 @@     atEnd,     findNewline,     fromByteString,-    lineSlice,     peekByte,     peekByteAt,     skipNewline,     skipWhile,-    sliceText,-    toText,+    sliceBytes,   ) import Aihc.Cpp.Evaluator (evalCondition)-import Aihc.Cpp.Parser (Directive (..), parseDirective)+import Aihc.Cpp.Parser (Directive (..), isSpaceChar, parseDirective) import Aihc.Cpp.Scanner (expandLineBySpanMultiline, lineScanFinalCDepth, lineScanFinalHsDepth, lineScanSpans, scanLine, scanLineDepthOnly) import Aihc.Cpp.Types   ( CondFrame (..),@@ -65,29 +63,29 @@     Step (..),     currentActive,     defaultConfig,+    defineMacro,     emptyState,     mkFrame,+    setMacroTable,+    undefMacro,   ) import Data.ByteString (ByteString) import qualified Data.ByteString as BS import qualified Data.ByteString.Builder as BSB+import qualified Data.ByteString.Char8 as C import qualified Data.ByteString.Lazy as BSL-import Data.Char (isSpace)-import Data.Map.Strict (Map) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe) 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.Encoding as TE+import qualified Data.Text.Encoding.Error as TEE import System.FilePath (takeDirectory, (</>))  -- $setup -- >>> :set -XOverloadedStrings -- >>> import qualified Data.Map.Strict as M--- >>> import qualified Data.Text as T--- >>> import qualified Data.Text.IO as T+-- >>> import qualified Data.ByteString.Char8 as C  -- | Preprocess C preprocessor directives in the input. --@@ -105,7 +103,7 @@ -- Object-like macros are expanded in the output: -- -- >>> let Done r = preprocess defaultConfig "#define FOO 42\nThe answer is FOO"--- >>> T.putStr (resultOutput r)+-- >>> C.putStr (resultOutput r) -- #line 1 "<input>" -- <BLANKLINE> -- The answer is 42@@ -113,7 +111,7 @@ -- Function-like macros are also supported: -- -- >>> let Done r = preprocess defaultConfig "#define MAX(a,b) ((a) > (b) ? (a) : (b))\nMAX(3, 5)"--- >>> T.putStr (resultOutput r)+-- >>> C.putStr (resultOutput r) -- #line 1 "<input>" -- <BLANKLINE> -- ((3) > (5) ? (3) : (5))@@ -125,7 +123,7 @@ -- >>> :{ -- let Done r = preprocess defaultConfig --       "#define DEBUG 1\n#if DEBUG\ndebug mode\n#else\nrelease mode\n#endif"--- in T.putStr (resultOutput r)+-- in C.putStr (resultOutput r) -- :} -- #line 1 "<input>" -- <BLANKLINE>@@ -144,7 +142,7 @@ -- >>> :{ -- let NeedInclude req k = preprocess defaultConfig "#include \"header.h\"\nmain code" --     Done r = k (Just "-- header content")--- in T.putStr (resultOutput r)+-- in C.putStr (resultOutput r) -- :} -- #line 1 "<input>" -- #line 1 "./header.h"@@ -158,7 +156,7 @@ -- let NeedInclude _ k = preprocess defaultConfig "#include \"missing.h\"" --     Done r = k Nothing -- in do---   T.putStr (resultOutput r)+--   C.putStr (resultOutput r) --   mapM_ print (resultDiagnostics r) -- :} -- #line 1 "<input>"@@ -171,7 +169,7 @@ -- >>> :{ -- let Done r = preprocess defaultConfig "#warning This is a warning" -- in do---   T.putStr (resultOutput r)+--   C.putStr (resultOutput r) --   mapM_ print (resultDiagnostics r) -- :} -- #line 1 "<input>"@@ -183,12 +181,38 @@ -- >>> :{ -- let Done r = preprocess defaultConfig "#error Build failed\nthis line is not processed" -- in do---   T.putStr (resultOutput r)+--   C.putStr (resultOutput r) --   mapM_ print (resultDiagnostics r) -- :} -- #line 1 "<input>" -- <BLANKLINE> -- Diagnostic {diagSeverity = Error, diagMessage = "Build failed", diagFile = "<input>", diagLine = 1}+--+-- === Source encoding+--+-- The preprocessor is agnostic to the source encoding. Every character+-- that is significant to CPP is ASCII, so the input is never decoded:+-- bytes the preprocessor did not itself generate are copied from+-- 'ByteString' input to 'ByteString' output verbatim. A Latin-1 module, a+-- UTF-8 one, or a file with no consistent encoding at all all pass+-- through unchanged, and no input can make 'preprocess' fail to decode+-- something or raise an exception.+--+-- Byte 169 (0xA9, a Latin-1 copyright sign) is neither decoded nor+-- rewritten; it is shown escaped here only because that keeps this+-- example's output pure ASCII:+--+-- >>> let Done r = preprocess defaultConfig "-- \169 2026\nx = 1\n"+-- >>> resultOutput r+-- "#line 1 \"<input>\"\n-- \169 2026\nx = 1\n"+--+-- This is deliberately looser than GHC, which decodes source as UTF-8 --+-- but only where it must lex a token. GHC accepts an undecodable byte+-- inside a comment (real Hackage packages ship such modules) and rejects+-- one inside a string literal. Rejecting the file here would fail+-- modules that genuinely compile, so encoding is left to the caller: the+-- output bytes are exactly what a compiler front-end should lex, and its+-- lexer decides what is valid. preprocess :: Config -> ByteString -> Step preprocess cfg input =   let cursor = fromByteString input@@ -196,14 +220,12 @@   where     initialState =       let st0 = emitLine (linePragma 1 (configInputFile cfg)) (emptyState (configInputFile cfg))-       in st0-            { stMacros = M.map ObjectMacro (configMacros cfg)-            }+       in setMacroTable (M.map ObjectMacro (configMacros cfg)) st0      finish st =-      let out = TL.toStrict (TB.toLazyText (stOutput st))+      let out = BSL.toStrict (BSB.toLazyByteString (stOutput st))           outWithTrailingNewline =-            if T.null out+            if BS.null out               then out               else out <> "\n"        in Done@@ -213,8 +235,8 @@               }  -- | Find the next line in the cursor, handling backslash-continuation--- for directive lines. Returns (lineCursor, lineSpan, restCursor) where:---   * lineCursor is a sub-cursor bounded to the logical line content+-- for directive lines. Returns (lineText, lineSpan, restCursor) where:+--   * lineText is the logical line content, without its newline --   * lineSpan is the number of physical lines consumed (>= 1) --   * restCursor is positioned after the line (past the newline) --@@ -222,7 +244,7 @@ -- (after optional whitespace), matching CPP semantics. -- For continuation lines, a new ByteString is allocated with the -- backslash-newline sequences removed.-nextLine :: Cursor -> (Cursor, Int, Cursor)+nextLine :: Cursor -> (ByteString, Int, Cursor) nextLine cur =   let eol = findNewline cur       lineStart = curPos cur@@ -234,10 +256,10 @@         then -- Backslash continuation: join lines, stripping '\' and '\n'           joinContinuationLines cur lineStart lineEnd rest         else-          let lineText = sliceText lineStart lineEnd cur+          let lineText = sliceBytes lineStart lineEnd cur            in if hasGccStringContinuation emptyQuoteState lineText                 then joinStringContinuationLines cur lineStart lineEnd rest-                else (lineSlice lineEnd cur, 1, rest)+                else (lineText, 1, rest)  -- | Check if the bytes from curPos to lineEnd start with '#' after -- optional whitespace. This determines whether backslash-continuation@@ -253,7 +275,7 @@ -- | Join backslash-continuation lines into a single logical line. -- Builds a new ByteString with '\<newline>' sequences removed. -- Returns (joinedCursor, physicalLineCount, restCursor).-joinContinuationLines :: Cursor -> Int -> Int -> Cursor -> (Cursor, Int, Cursor)+joinContinuationLines :: Cursor -> Int -> Int -> Cursor -> (ByteString, Int, Cursor) joinContinuationLines origCur lineStart firstLineEnd firstRest =   let buf = curBuf origCur       -- First segment: from lineStart to firstLineEnd - 1 (exclude '\')@@ -264,7 +286,7 @@       | atEnd rest =           -- No more input; finalize           let joined = BSL.toStrict (BSB.toLazyByteString acc)-           in (fromByteString joined, spanCount, rest)+           in (joined, spanCount, rest)       | otherwise =           let eol = findNewline rest               segStart = curPos rest@@ -280,7 +302,7 @@                   -- Last line of continuation                   let segment = BSB.byteString (sliceBS segStart segEnd (curBuf origCur))                       joined = BSL.toStrict (BSB.toLazyByteString (acc <> segment))-                   in (fromByteString joined, spanCount + 1, rest')+                   in (joined, spanCount + 1, rest')  -- | Slice a ByteString from position @start@ to @end@ (exclusive). sliceBS :: Int -> Int -> ByteString -> ByteString@@ -302,46 +324,50 @@ -- final backslash is the CPP continuation marker.  GHC's default CPP-like -- handling also accepts the single-backslash spelling, so only the double -- spelling is spliced here.-hasGccStringContinuation :: QuoteState -> Text -> Bool+--+-- The cheap suffix test comes first: 'scanQuoteState' walks the whole line,+-- and almost no line in real source ends in a double backslash, so testing+-- the two trailing bytes first removes a full scan from every line.+hasGccStringContinuation :: QuoteState -> ByteString -> Bool hasGccStringContinuation st lineText =-  qsInString (scanQuoteState st lineText) && "\\\\" `T.isSuffixOf` lineText+  "\\\\" `C.isSuffixOf` lineText && qsInString (scanQuoteState st lineText) -joinStringContinuationLines :: Cursor -> Int -> Int -> Cursor -> (Cursor, Int, Cursor)+joinStringContinuationLines :: Cursor -> Int -> Int -> Cursor -> (ByteString, Int, Cursor) joinStringContinuationLines origCur lineStart firstLineEnd firstRest =   let buf = curBuf origCur-      firstSegment = sliceText lineStart (firstLineEnd - 1) origCur+      firstSegment = sliceBytes lineStart (firstLineEnd - 1) origCur       firstBytes = BSB.byteString (sliceBS lineStart (firstLineEnd - 1) buf)    in go firstBytes 1 firstRest (scanQuoteState emptyQuoteState firstSegment)   where     go !acc !spanCount !rest !quoteState       | atEnd rest =           let joined = BSL.toStrict (BSB.toLazyByteString acc)-           in (fromByteString joined, spanCount, rest)+           in (joined, spanCount, rest)       | otherwise =           let eol = findNewline rest               segStart = curPos rest               segEnd = curPos eol               rest' = fromMaybe eol (skipNewline eol)-              segmentText = sliceText segStart segEnd origCur+              segmentText = sliceBytes segStart segEnd origCur            in if hasGccStringContinuation quoteState segmentText                 then                   let segmentBytes = BSB.byteString (sliceBS segStart (segEnd - 1) (curBuf origCur))-                      scannedText = sliceText segStart (segEnd - 1) origCur+                      scannedText = sliceBytes segStart (segEnd - 1) origCur                    in go (acc <> segmentBytes) (spanCount + 1) rest' (scanQuoteState quoteState scannedText)                 else                   let segmentBytes = BSB.byteString (sliceBS segStart segEnd (curBuf origCur))                       joined = BSL.toStrict (BSB.toLazyByteString (acc <> segmentBytes))-                   in (fromByteString joined, spanCount + 1, rest')+                   in (joined, spanCount + 1, rest') -scanQuoteState :: QuoteState -> Text -> QuoteState+scanQuoteState :: QuoteState -> ByteString -> QuoteState scanQuoteState = go   where     go st txt =-      case T.uncons txt of+      case C.uncons txt of         Nothing -> st         Just (c, rest)           | qsInString st ->-              if qsEscaped st && isSpace c+              if qsEscaped st && isSpaceChar c                 then go st {qsEscaped = False} (dropStringGapClose rest)                 else                   go@@ -362,8 +388,8 @@           | otherwise -> go st {qsEscaped = False} rest      dropStringGapClose txt =-      let afterSpace = T.dropWhile isSpace txt-       in case T.uncons afterSpace of+      let afterSpace = C.dropWhile isSpaceChar txt+       in case C.uncons afterSpace of             Just ('\\', rest) -> rest             _ -> afterSpace @@ -378,13 +404,12 @@           k (emitBlankLines 1 st)         else k st processFile filePath cursor trailingNl stack !lineNo st k =-  let (lineCur, lineSpan, restCursor) = nextLine cursor+  let (lineText, lineSpan, restCursor) = nextLine cursor       -- Detect if this line was followed by a newline (vs EOF).       -- If so and restCursor is at EOF, the file had a trailing newline.       hasTrailingNl = trailingNl && not (atEnd restCursor) || (trailingNl && atEnd restCursor)       -- Actually: trailingNl flag is set at processFile entry for includes.       -- We just propagate it. The check at atEnd above handles the final empty line.-      lineText = toText lineCur       startsInBlockComment = stHsBlockCommentDepth st > 0 || stCBlockCommentDepth st > 0       parsedDirective =         if startsInBlockComment@@ -395,7 +420,7 @@    in if not isActive && not (stSkippingDanglingElse st)         then -- === Fast path for inactive branches ===         -- Only track comment depth; skip full span scanning and macro expansion.-          let (finalHs, finalC) = scanLineDepthOnly (stHsBlockCommentDepth st) (stCBlockCommentDepth st) lineCur+          let (finalHs, finalC) = scanLineDepthOnly (stHsBlockCommentDepth st) (stCBlockCommentDepth st) lineText               advanceSt st' =                 st'                   { stCurrentLine = nextLineNo,@@ -422,7 +447,7 @@                           }                    in handleDirective ctx st directive         else -- === Normal path: full scan + expansion ===-          let lineScan = scanLine (stHsBlockCommentDepth st) (stCBlockCommentDepth st) lineCur+          let lineScan = scanLine (stHsBlockCommentDepth st) (stCBlockCommentDepth st) lineText               advanceLineState st' =                 st'                   { stCurrentLine = nextLineNo,@@ -494,8 +519,8 @@   | atEnd cur = (hsDepth, cDepth)   | otherwise =       let eol = findNewline cur-          lineCur = lineSlice (curPos eol) cur-          (hsDepth', cDepth') = scanLineDepthOnly hsDepth cDepth lineCur+          lineText = sliceBytes (curPos cur) (curPos eol) cur+          (hsDepth', cDepth') = scanLineDepthOnly hsDepth cDepth lineText           rest = fromMaybe eol (skipNewline eol)        in scanConsumedLines hsDepth' cDepth' rest (remaining - 1) @@ -521,11 +546,11 @@ handleDirective ctx st directive =   case directive of     DirDefineObject name value ->-      mutateMacrosWhenActive ctx st (M.insert name (ObjectMacro value))+      mutateMacrosWhenActive ctx st (defineMacro name (ObjectMacro value))     DirDefineFunction name params body ->-      mutateMacrosWhenActive ctx st (M.insert name (FunctionMacro params body))+      mutateMacrosWhenActive ctx st (defineMacro name (FunctionMacro params body))     DirUndef name ->-      mutateMacrosWhenActive ctx st (M.delete name)+      mutateMacrosWhenActive ctx st (undefMacro name)     DirInclude kind includeTarget ->       handleIncludeDirective ctx st kind includeTarget     DirIf expr ->@@ -547,20 +572,20 @@         _ : rest ->           continueBlankWithStack ctx rest st     DirWarning msg ->-      addDiagnosticWhenActive ctx Warning msg st+      addDiagnosticWhenActive ctx Warning (messageText msg) st     DirError msg ->       if currentActive (lcStack ctx)         then           lcDone             ctx-            (emitDirectiveBlank ctx (addDiag Error msg (lcFilePath ctx) (lcLineNo ctx) st))+            (emitDirectiveBlank ctx (addDiag Error (messageText msg) (lcFilePath ctx) (lcLineNo ctx) st))         else continueBlank ctx st     DirLine n mPath ->       handleLineDirective ctx st n mPath     DirPragmaOnce ->       handlePragmaOnceDirective ctx st     DirUnsupported name ->-      addDiagnosticWhenActive ctx Warning ("unsupported directive: " <> name) st+      addDiagnosticWhenActive ctx Warning ("unsupported directive: " <> messageText name) st  emitDirectiveBlank :: LineContext -> EngineState -> EngineState emitDirectiveBlank ctx = emitBlankLines (lcLineSpan ctx)@@ -571,10 +596,10 @@ continueBlankWithStack :: LineContext -> [CondFrame] -> EngineState -> Step continueBlankWithStack ctx stack st = lcContinueWith ctx stack (emitDirectiveBlank ctx st) -mutateMacrosWhenActive :: LineContext -> EngineState -> (Map Text MacroDef -> Map Text MacroDef) -> Step+mutateMacrosWhenActive :: LineContext -> EngineState -> (EngineState -> EngineState) -> Step mutateMacrosWhenActive ctx st mutate =   if currentActive (lcStack ctx)-    then continueBlank ctx (st {stMacros = mutate (stMacros st)})+    then continueBlank ctx (mutate st)     else continueBlank ctx st  addDiagnosticWhenActive :: LineContext -> Severity -> Text -> EngineState -> Step@@ -594,7 +619,7 @@   let frame = mkFrame (currentActive (lcStack ctx)) cond    in continueBlankWithStack ctx (frame : lcStack ctx) st -handleElifDirective :: LineContext -> EngineState -> Text -> Step+handleElifDirective :: LineContext -> EngineState -> ByteString -> Step handleElifDirective ctx st expr =   case lcStack ctx of     [] ->@@ -637,13 +662,13 @@                   }            in continueBlankWithStack ctx (f' : rest) st -handleIncludeDirective :: LineContext -> EngineState -> IncludeKind -> Text -> Step+handleIncludeDirective :: LineContext -> EngineState -> IncludeKind -> ByteString -> Step handleIncludeDirective ctx st kind includeTarget   | not (currentActive (lcStack ctx)) = continueBlank ctx st   | S.member includeFilePath (stPragmaOnceFiles st) = continueBlank ctx st   | otherwise = NeedInclude includeReq nextStep   where-    includePathText = T.unpack includeTarget+    includePathText = C.unpack includeTarget     includeFilePath =       case kind of         IncludeLocal -> takeDirectory (lcFilePath ctx) </> includePathText@@ -659,7 +684,7 @@     nextStep Nothing =       lcContinue         ctx-        (addDiag Error ("missing include: " <> includeTarget) (lcFilePath ctx) (lcLineNo ctx) st)+        (addDiag Error ("missing include: " <> messageText includeTarget) (lcFilePath ctx) (lcLineNo ctx) st)     nextStep (Just includeBytes) =       let includeCursor = fromByteString includeBytes           -- Include files treat trailing newlines as producing an extra@@ -702,11 +727,11 @@             (stWithLinePragma {stCurrentLine = lineNumber})             (lcDone ctx) -emitLine :: Text -> EngineState -> EngineState+emitLine :: ByteString -> EngineState -> EngineState emitLine line st =-  let sep = if stOutputLineCount st > 0 then TB.singleton '\n' else mempty+  let sep = if stOutputLineCount st > 0 then BSB.char8 '\n' else mempty    in st-        { stOutput = stOutput st <> sep <> TB.fromText line,+        { stOutput = stOutput st <> sep <> BSB.byteString line,           stOutputLineCount = stOutputLineCount st + 1         } @@ -714,12 +739,21 @@ emitBlankLines n st   | n <= 0 = st   | otherwise =-      let newlines = mconcat (replicate n (TB.singleton '\n'))+      let newlines = mconcat (replicate n (BSB.char8 '\n'))        in st             { stOutput = stOutput st <> newlines,               stOutputLineCount = stOutputLineCount st + n             } +-- | Turn a fragment of source into human-readable message text.+--+-- This is the only place the preprocessor decodes anything. It is a+-- display concern: 'Diagnostic' is meant to be shown to a person, so+-- invalid UTF-8 becomes U+FFFD here rather than propagating bytes into+-- the message. 'resultOutput' is never decoded and never substituted.+messageText :: ByteString -> Text+messageText = TE.decodeUtf8With TEE.lenientDecode+ addDiag :: Severity -> Text -> FilePath -> Int -> EngineState -> EngineState addDiag sev msg filePath lineNo st =   st@@ -733,5 +767,5 @@           : stDiagnosticsRev st     } -linePragma :: Int -> FilePath -> Text-linePragma n path = "#line " <> T.pack (show n) <> " \"" <> T.pack path <> "\""+linePragma :: Int -> FilePath -> ByteString+linePragma n path = "#line " <> C.pack (show n) <> " \"" <> C.pack path <> "\""
src/Aihc/Cpp/Cursor.hs view
@@ -8,19 +8,19 @@ -- A lightweight cursor abstraction over a strict 'ByteString'. The cursor -- tracks a position into a shared buffer, enabling O(1) peeking and -- zero-copy slicing. All CPP-significant bytes are ASCII (0x00-0x7F),--- so byte-level operations are safe; non-ASCII bytes (>= 0x80) can be--- bulk-copied without decoding.+-- so byte-level operations are safe; non-ASCII bytes (>= 0x80) are+-- bulk-copied and never decoded, which is what makes the preprocessor+-- agnostic to the source encoding. module Aihc.Cpp.Cursor   ( Cursor (..),     fromByteString,-    fromText,-    toText,+    toBytes,     null,     peekByte,     peekByte2,     advance,     advance2,-    sliceText,+    sliceBytes,     sliceSince,     skipWhile,     skipToInteresting,@@ -38,8 +38,6 @@  import Data.ByteString (ByteString) import qualified Data.ByteString as BS-import Data.Text (Text)-import qualified Data.Text.Encoding as TE import Data.Word (Word8) import Prelude hiding (null) @@ -56,13 +54,9 @@ fromByteString :: ByteString -> Cursor fromByteString bs = Cursor bs 0 --- | Create a cursor from 'Text' by encoding to UTF-8.-fromText :: Text -> Cursor-fromText = fromByteString . TE.encodeUtf8---- | Decode the remaining bytes from the cursor position as UTF-8 'Text'.-toText :: Cursor -> Text-toText (Cursor buf pos) = TE.decodeUtf8 (BS.drop pos buf)+-- | The remaining bytes from the cursor position, as a zero-copy slice.+toBytes :: Cursor -> ByteString+toBytes (Cursor buf pos) = BS.drop pos buf  -- | Is the cursor at the end of input? null :: Cursor -> Bool@@ -107,17 +101,17 @@ advance2 (Cursor buf pos) = Cursor buf (pos + 2) {-# INLINE advance2 #-} --- | Extract a zero-copy 'Text' slice from position @start@ to position--- @end@ (exclusive) in the cursor's buffer.-sliceText :: Int -> Int -> Cursor -> Text-sliceText start end (Cursor buf _) =-  TE.decodeUtf8 (BS.take (end - start) (BS.drop start buf))-{-# INLINE sliceText #-}+-- | Extract a zero-copy slice from position @start@ to position @end@+-- (exclusive) in the cursor's buffer.+sliceBytes :: Int -> Int -> Cursor -> ByteString+sliceBytes start end (Cursor buf _) =+  BS.take (end - start) (BS.drop start buf)+{-# INLINE sliceBytes #-} --- | Extract a zero-copy 'Text' slice from the given start position to--- the cursor's current position.-sliceSince :: Int -> Cursor -> Text-sliceSince start cur = sliceText start (curPos cur) cur+-- | Extract a zero-copy slice from the given start position to the+-- cursor's current position.+sliceSince :: Int -> Cursor -> ByteString+sliceSince start cur = sliceBytes start (curPos cur) cur {-# INLINE sliceSince #-}  -- | Advance the cursor while the predicate holds for the current byte.@@ -166,14 +160,14 @@ -- positioned at the newline (or at EOF). The bytes from the -- original position to the returned position form the line content -- (without the newline).+-- 'BS.elemIndex' is a @memchr@, which searches many bytes at a time; the+-- byte-at-a-time loop this replaces also allocated a fresh 'Cursor' per+-- byte of every line in the input. findNewline :: Cursor -> Cursor-findNewline = go-  where-    go !cur = case peekByte cur of-      Nothing -> cur-      Just 0x0A -> cur -- '\n'-      Just _ -> go (advance cur)-{-# INLINE findNewline #-}+findNewline (Cursor buf pos) =+  case BS.elemIndex 0x0A (BS.drop pos buf) of -- '\n'+    Just offset -> Cursor buf (pos + offset)+    Nothing -> Cursor buf (BS.length buf)  -- | Advance past a newline byte if the cursor is currently on one. -- Returns 'Nothing' at EOF, 'Just cursor' after the newline otherwise.
src/Aihc/Cpp/Evaluator.hs view
@@ -1,3 +1,4 @@+{-# LANGUAGE BangPatterns #-} {-# LANGUAGE OverloadedStrings #-}  module Aihc.Cpp.Evaluator@@ -22,52 +23,60 @@   ) where -import Aihc.Cpp.Parser (isIdentChar, isIdentStart, isOpChar)-import Aihc.Cpp.Types (EngineState (..), MacroDef (..))-import Data.Char (isDigit, isSpace)+import Aihc.Cpp.Parser (isIdentChar, isIdentStart, isOpChar, isSpaceChar)+import Aihc.Cpp.Types (EngineState (..), MacroDef (..), bloomMember)+import Data.Bits ((.&.))+import Data.ByteString (ByteString)+import qualified Data.ByteString as BS+import qualified Data.ByteString.Builder as BSB+import qualified Data.ByteString.Char8 as C+import qualified Data.ByteString.Lazy as BSL+import qualified Data.ByteString.Unsafe as BSU+import Data.Char (isDigit) 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+import Data.Word (Word8)  -- | 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))+expandMacros :: EngineState -> ByteString -> ByteString+expandMacros st = expandWith st S.empty  -- | 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 :: EngineState -> ByteString -> [ByteString] -> (ByteString, 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+              combined = C.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+countExtraLinesConsumed :: EngineState -> ByteString -> [ByteString] -> Int+countExtraLinesConsumed st txt moreLines+  -- A module that defines no function-like macro cannot have a call+  -- spanning lines, and most do not; skipping the scan saves a second+  -- walk over every line of the file.+  | funBloom == 0 = 0+  | otherwise = scanForFunctionMacro False False False txt   where     macros = stMacros st+    funBloom = stFunMacroBloom st -    scanForFunctionMacro :: Bool -> Bool -> Bool -> Text -> Int-    scanForFunctionMacro _ _ _ t | T.null t = 0+    scanForFunctionMacro :: Bool -> Bool -> Bool -> ByteString -> Int+    scanForFunctionMacro _ _ _ t | C.null t = 0     scanForFunctionMacro inString inChar escaped t =-      case T.uncons t of+      case C.uncons t of         Nothing -> 0         Just (c, rest)           | inString ->@@ -84,23 +93,25 @@           | 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'+              let (ident, rest') = C.span isIdentChar t+               in if not (bloomMember funBloom (BS.head ident))+                    then scanForFunctionMacro False False False rest'+                    else 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+    tryMultilineCallArgs :: ByteString -> Maybe Int+    tryMultilineCallArgs rest = seekOpenParen (C.dropWhile isSpaceChar rest) 0 -    seekOpenParen :: Text -> Int -> Maybe Int+    seekOpenParen :: ByteString -> Int -> Maybe Int     seekOpenParen remaining extraLines =-      case T.uncons remaining of+      case C.uncons remaining of         Just ('(', afterOpen) ->           findClosingParen 0 afterOpen extraLines         Just _ ->@@ -109,20 +120,20 @@           case drop extraLines moreLines of             [] -> Nothing             (nextLine : _) ->-              seekOpenParen (T.dropWhile isSpace nextLine) (extraLines + 1)+              seekOpenParen (C.dropWhile isSpaceChar nextLine) (extraLines + 1) -    findClosingParen :: Int -> Text -> Int -> Maybe Int+    findClosingParen :: Int -> ByteString -> Int -> Maybe Int     findClosingParen = goClosing False False False       where-        goClosing :: Bool -> Bool -> Bool -> Int -> Text -> Int -> Maybe Int+        goClosing :: Bool -> Bool -> Bool -> Int -> ByteString -> Int -> Maybe Int         goClosing inString inChar escaped depth remaining extraLines =-          case T.uncons remaining of+          case C.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)+                  goClosing inString inChar escaped depth (C.cons '\n' nextLine) (extraLines + 1)             Just (ch, rest)               | inString ->                   let escaped' = ch == '\\' && not escaped@@ -142,246 +153,389 @@               | 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+-- | Blue-paint macro expansion: expand @txt@, leaving any name in+-- @painted@ alone so that a macro cannot re-enter itself. A single pass+-- with a suppression set replaces the previous iterate-up-to-32 fixpoint+-- approach.+--+-- The scan walks byte offsets and copies nothing until a macro actually+-- expands, so text that names no macro comes back as the very+-- 'ByteString' that went in. That is the case that matters: this runs on+-- every line of every module, and almost no line expands anything.+expandWith :: EngineState -> Set ByteString -> ByteString -> ByteString+expandWith st painted txt0 =+  case scan txt0 0 0 False False False mempty False of+    (_, False) -> txt0+    (acc, True) -> builderToBytes acc+  where+    macros = stMacros st+    bloom = stMacroBloom st --- | 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)+    -- \| @scan buf i flushed inString inChar escaped acc changed@ walks+    -- @buf@ from offset @i@; everything before @flushed@ is already in+    -- @acc@. An expansion may continue in a different buffer (a+    -- function-like call whose argument list held a line comment is+    -- rewritten), so the buffer travels with the loop.+    scan :: ByteString -> Int -> Int -> Bool -> Bool -> Bool -> BSB.Builder -> Bool -> (BSB.Builder, Bool)+    scan !buf !i !flushed !inString !inChar !escaped acc !changed+      | i >= len = (acc <> slice buf flushed len, changed)+      -- Matched against a 'case' rather than bound in a @where@: a+      -- @where@ binding the end-of-input guard does not use is a thunk,+      -- and this loop runs once per byte of the corpus.+      | otherwise = case BS.index buf i of+          c+            | inString ->+                let escaped' = c == 0x5C && not escaped -- '\\'+                    inString' = escaped || c /= 0x22 -- '"'+                 in scan buf (i + 1) flushed inString' False escaped' acc changed+            | inChar ->+                let escaped' = c == 0x5C && not escaped -- '\\'+                    inChar' = escaped || c /= 0x27 -- '\''+                 in scan buf (i + 1) flushed False inChar' escaped' acc changed+            | c == 0x22 -> scan buf (i + 1) flushed True False False acc changed -- '"'+            | c == 0x27 -> scan buf (i + 1) flushed False True False acc changed -- '\''+            | isIdentStartByte c -> expandIdent buf i flushed acc changed+            -- A block comment can only open on '{', so the three-byte test+            -- is gated on that byte rather than run against every byte.+            | c == 0x7B && startsHsComment buf len i ->+                -- Copied through verbatim, so there is nothing to flush.+                scan buf (hsCommentEnd buf i) flushed False False False acc changed+            | c == 0x2D && i + 1 < len && BS.index buf (i + 1) == 0x2D ->+                -- Haskell line comment: the rest is not expanded.+                (acc <> slice buf flushed len, changed)+            | otherwise ->+                scan buf (skipDull buf len (i + 1)) flushed False False False acc changed+      where+        len = BS.length buf --- | 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)+    -- \| Handle the identifier starting at @i@.+    --+    -- Split in two so that the common case — an identifier that can name+    -- no macro — allocates nothing. Everything the rare path needs+    -- (@name@, the painted set, the continuations) would otherwise be a+    -- thunk built once per identifier in the corpus.+    expandIdent :: ByteString -> Int -> Int -> BSB.Builder -> Bool -> (BSB.Builder, Bool)+    expandIdent !buf !i !flushed acc !changed+      -- No macro name starts with this byte: much the commonest outcome,+      -- and it costs one bit test rather than a walk of the macro map.+      | not (bloomMember bloom (BS.index buf i)) =+          scan buf end flushed False False False acc changed+      | otherwise = expandNamed buf i end (substr buf i end) flushed acc changed+      where+        !end = identEnd buf i +    -- \| Handle an identifier whose first byte a macro name could share.+    expandNamed :: ByteString -> Int -> Int -> ByteString -> Int -> BSB.Builder -> Bool -> (BSB.Builder, Bool)+    expandNamed !buf !i !end !name !flushed acc !changed+      | S.member name painted = verbatim+      | name == "__LINE__" = replaceWith (BSB.string8 (show (stCurrentLine st)))+      | name == "__FILE__" = replaceWith (BSB.string8 (show (stCurrentFile st)))+      | otherwise =+          case M.lookup name macros of+            Just (ObjectMacro replacement) ->+              replaceWith+                ( BSB.byteString+                    (expandWith st painted' (normalizeObjectReplacement replacement))+                )+            Just (FunctionMacro params body) ->+              case parseCallArgs (BS.drop end buf) of+                Just (args, restAfter)+                  | length args == length params ->+                      -- Arguments are expanded in the caller's paint context,+                      -- before @name@ is painted, so a nested call to the+                      -- same macro inside an argument still expands.+                      let macroArgs = map (macroArg st painted) args+                          body' = substituteMacroArgs (M.fromList (zip params macroArgs)) body+                          expanded = expandWith st painted' body'+                       in scan+                            restAfter+                            0+                            0+                            False+                            False+                            False+                            (acc <> slice buf flushed i <> BSB.byteString expanded)+                            True+                _ -> verbatim+            Nothing -> verbatim+      where+        painted' = S.insert name painted+        verbatim = scan buf end flushed False False False acc changed+        replaceWith b =+          scan buf end end False False False (acc <> slice buf flushed i <> b) True++-- | The offset just past the identifier starting at @i@.+identEnd :: ByteString -> Int -> Int+identEnd buf = go+  where+    len = BS.length buf+    -- 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.+    go !i+      | i < len && isIdentByte (BSU.unsafeIndex buf i) = go (i + 1)+      | otherwise = i++-- | Advance past bytes that can neither start an identifier nor open a+-- literal or a comment, so runs of whitespace, digits and punctuation are+-- stepped over without re-entering the guard chain per byte.+skipDull :: ByteString -> Int -> Int -> Int+skipDull buf len = go+  where+    -- 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.+    go !i+      | i < len && isDullByte (BSU.unsafeIndex buf i) = go (i + 1)+      | otherwise = i++isDullByte :: Word8 -> Bool+isDullByte b =+  not (isIdentStartByte b)+    && b /= 0x22 -- '"'+    && b /= 0x27 -- '\''+    && b /= 0x7B -- '{'+    && b /= 0x2D -- '-'+{-# INLINE isDullByte #-}++-- | Given that @buf@ has @{@ at @i@, does a Haskell block comment open+-- there? @{-#@ is a pragma, not a comment.+startsHsComment :: ByteString -> Int -> Int -> Bool+startsHsComment buf len i =+  i + 1 < len+    && BS.index buf (i + 1) == 0x2D -- '-'+    && (i + 2 >= len || BS.index buf (i + 2) /= 0x23) -- '#'++-- | The offset just past the Haskell block comment opening at @i@, or the+-- end of the buffer if it is never closed.+hsCommentEnd :: ByteString -> Int -> Int+hsCommentEnd buf = go (0 :: Int)+  where+    len = BS.length buf+    go :: Int -> Int -> Int+    go !depth !i+      | i + 1 >= len = len+      | b2 == 0x2D && b1 == 0x7B = go (depth + 1) (i + 2) -- '{-'+      | b2 == 0x7D && b1 == 0x2D = if depth <= 1 then i + 2 else go (depth - 1) (i + 2) -- '-}'+      | otherwise = go depth (i + 1)+      where+        b1 = BS.index buf i+        b2 = BS.index buf (i + 1)++-- | Byte-level 'isIdentStart'. See 'Aihc.Cpp.Parser.isIdentStart' for why+-- every byte >= 0x80 qualifies.+isIdentStartByte :: Word8 -> Bool+isIdentStartByte b =+  b == 0x5F -- '_'+    || (b >= 0x41 && b <= 0x5A) -- 'A'-'Z'+    || (b >= 0x61 && b <= 0x7A) -- 'a'-'z'+    || b >= 0x80+{-# INLINE isIdentStartByte #-}++-- | Byte-level 'Aihc.Cpp.Parser.isIdentChar'.+isIdentByte :: Word8 -> Bool+isIdentByte b = isIdentStartByte b || (b >= 0x30 && b <= 0x39)+{-# INLINE isIdentByte #-}++-- | The bytes of @buf@ in @[from, to)@, as a zero-copy slice.+substr :: ByteString -> Int -> Int -> ByteString+substr buf from to = BS.take (to - from) (BS.drop from buf)+{-# INLINE substr #-}++slice :: ByteString -> Int -> Int -> BSB.Builder+slice buf from to+  | to <= from = mempty+  | otherwise = BSB.byteString (substr buf from to)+{-# INLINE slice #-}++-- | A function-like macro argument in both the forms the replacement list+-- can need: the raw spelling (used by @#@ and @##@, which see arguments+-- unexpanded) and the macro-expanded spelling (used everywhere else).+data MacroArg = MacroArg+  { macroArgRaw :: !ByteString,+    macroArgExpanded :: !ByteString+  }++-- | Build a 'MacroArg' by expanding the argument text in the paint context of+-- the call site.+macroArg :: EngineState -> Set ByteString -> ByteString -> MacroArg+macroArg st painted raw = MacroArg raw (expandWith st painted raw)+ -- | 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+normalizeObjectReplacement :: ByteString -> ByteString+normalizeObjectReplacement = C.dropWhileEnd isSpaceChar . go False False False mempty   where-    go :: Bool -> Bool -> Bool -> TB.Builder -> Text -> Text-    go _ _ _ acc txt | T.null txt = builderToText acc+    go :: Bool -> Bool -> Bool -> BSB.Builder -> ByteString -> ByteString+    go _ _ _ acc txt | C.null txt = builderToBytes acc     go inString inChar escaped acc txt =-      case T.uncons txt of-        Nothing -> builderToText acc+      case C.uncons txt of+        Nothing -> builderToBytes 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+               in go inString' False escaped' (acc <> BSB.char8 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 ->+               in go False inChar' escaped' (acc <> BSB.char8 c) rest+          | c == '"' -> go True False False (acc <> BSB.char8 c) rest+          | c == '\'' -> go False True False (acc <> BSB.char8 c) rest+          | "/*" `C.isPrefixOf` txt ->               let (commentText, remaining) = consumeCBlockComment txt                   replacement = commentReplacement commentText-               in go False False False (acc <> TB.fromText replacement) remaining+               in go False False False (acc <> BSB.byteString replacement) remaining           | otherwise ->-              go False False False (acc <> TB.singleton c) rest+              go False False False (acc <> BSB.char8 c) rest -consumeCBlockComment :: Text -> (Text, Text)+consumeCBlockComment :: ByteString -> (ByteString, ByteString) consumeCBlockComment txt =-  let afterOpen = T.drop 2 txt-      (inside, suffix) = T.breakOn "*/" afterOpen-   in if T.null suffix+  let afterOpen = C.drop 2 txt+      (inside, suffix) = BS.breakSubstring "*/" afterOpen+   in if C.null suffix         then (txt, "")-        else ("/*" <> inside <> "*/", T.drop 2 suffix)+        else ("/*" <> inside <> "*/", C.drop 2 suffix) -commentReplacement :: Text -> Text+commentReplacement :: ByteString -> ByteString commentReplacement commentText   | commentText == "/**/" = ""-  | otherwise = T.replicate (T.length (commentBody commentText)) " "+  | otherwise = C.replicate (charWidth (commentBody commentText)) ' ' -commentBody :: Text -> Text+-- | Number of characters in a UTF-8 buffer, for column alignment: count+-- every byte that is not a UTF-8 continuation byte. On valid UTF-8 this+-- is the character count; on anything else it degrades gracefully instead+-- of failing, and on ASCII it is just the length.+charWidth :: ByteString -> Int+charWidth = BS.foldl' step 0+  where+    step !n b = if b .&. 0xC0 == 0x80 then n else n + 1++commentBody :: ByteString -> ByteString commentBody commentText =-  if "/*" `T.isPrefixOf` commentText && "*/" `T.isSuffixOf` commentText-    then T.dropEnd 2 (T.drop 2 commentText)-    else T.drop 2 commentText+  if "/*" `C.isPrefixOf` commentText && "*/" `C.isSuffixOf` commentText+    then C.take (C.length commentText - 4) (C.drop 2 commentText)+    else C.drop 2 commentText  -- | Parse function-like macro call arguments.-parseCallArgs :: Text -> Maybe ([Text], Text)+parseCallArgs :: ByteString -> Maybe ([ByteString], ByteString) parseCallArgs input = do-  ('(', rest) <- T.uncons (T.dropWhile isSpace input)+  ('(', rest) <- C.uncons (C.dropWhile isSpaceChar input)   parseArgs False False False 0 [] mempty rest -parseArgs :: Bool -> Bool -> Bool -> Int -> [Text] -> TB.Builder -> Text -> Maybe ([Text], Text)+parseArgs :: Bool -> Bool -> Bool -> Int -> [ByteString] -> BSB.Builder -> ByteString -> Maybe ([ByteString], ByteString) parseArgs inString inChar escaped depth argsRev current remaining =-  case T.uncons remaining of+  case C.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+           in parseArgs inString' False escaped' depth argsRev (current <> BSB.char8 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+           in parseArgs False inChar' escaped' depth argsRev (current <> BSB.char8 ch) rest       | startsHsBlockComment remaining ->           let (commentText, afterComment) = consumeHsBlockComment remaining-           in parseArgs False False False depth argsRev (current <> TB.fromText commentText) afterComment+           in parseArgs False False False depth argsRev (current <> BSB.byteString commentText) afterComment       | ch == '"' ->-          parseArgs True False False depth argsRev (current <> TB.singleton ch) rest+          parseArgs True False False depth argsRev (current <> BSB.char8 ch) rest       | ch == '\'' ->-          parseArgs False True False depth argsRev (current <> TB.singleton ch) rest+          parseArgs False True False depth argsRev (current <> BSB.char8 ch) rest       | ch == '(' ->-          parseArgs False False False (depth + 1) argsRev (current <> TB.singleton ch) rest+          parseArgs False False False (depth + 1) argsRev (current <> BSB.char8 ch) rest       | ch == ')' && depth > 0 ->-          parseArgs False False False (depth - 1) argsRev (current <> TB.singleton ch) rest+          parseArgs False False False (depth - 1) argsRev (current <> BSB.char8 ch) rest       | ch == ')' && depth == 0 ->-          let arg = trimSpacesText (builderToText current)+          let arg = trimSpacesBytes (builderToBytes current)               argsRev' =-                if T.null arg && null argsRev+                if C.null arg && null argsRev                   then [""]                   else arg : argsRev            in Just (reverse argsRev', rest)       | ch == ',' && depth == 0 ->-          let arg = trimSpacesText (builderToText current)+          let arg = trimSpacesBytes (builderToBytes current)            in parseArgs False False False depth (arg : argsRev) mempty rest       | ch == '-' && depth == 0,-        Just ('-', afterDash) <- T.uncons rest ->+        Just ('-', afterDash) <- C.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+                  let currentText = builderToBytes current+                      arg = trimSpacesBytes currentText+                      trailingWS = C.takeWhileEnd isSpaceChar currentText+                      argsRev' = if C.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+          parseArgs False False False depth argsRev (current <> BSB.char8 ch) rest  -- | Find the last ')' in text and split before it.-findLastCloseParen :: Text -> Maybe (Text, Text)+findLastCloseParen :: ByteString -> Maybe (ByteString, ByteString) findLastCloseParen txt =-  case T.findIndex (== ')') (T.reverse txt) of+  case C.elemIndexEnd ')' txt of     Nothing -> Nothing-    Just revIdx ->-      let idx = T.length txt - revIdx - 1-       in Just (T.take idx txt, T.drop (idx + 1) txt)+    Just idx -> Just (C.take idx txt, C.drop (idx + 1) txt) -startsHsBlockComment :: Text -> Bool+startsHsBlockComment :: ByteString -> Bool startsHsBlockComment txt =-  case T.uncons txt of+  case C.uncons txt of     Just ('{', rest) ->-      case T.uncons rest of+      case C.uncons rest of         Just ('-', rest') ->-          case T.uncons rest' of+          case C.uncons rest' of             Just ('#', _) -> False             _ -> True         _ -> False     _ -> False -consumeHsBlockComment :: Text -> (Text, Text)+consumeHsBlockComment :: ByteString -> (ByteString, ByteString) consumeHsBlockComment = go 0 mempty   where-    go :: Int -> TB.Builder -> Text -> (Text, Text)+    go :: Int -> BSB.Builder -> ByteString -> (ByteString, ByteString)     go depth acc txt =-      case T.uncons txt of-        Nothing -> (builderToText acc, "")+      case C.uncons txt of+        Nothing -> (builderToBytes acc, "")         Just (c, rest) ->-          case T.uncons rest of+          case C.uncons rest of             Just ('-', rest')               | c == '{' ->-                  go (depth + 1) (acc <> TB.fromText "{-") rest'+                  go (depth + 1) (acc <> BSB.byteString "{-") rest'             Just ('}', rest')               | c == '-' && depth <= 1 ->-                  (builderToText (acc <> TB.fromText "-}"), rest')+                  (builderToBytes (acc <> BSB.byteString "-}"), rest')             Just ('}', rest')               | c == '-' ->-                  go (depth - 1) (acc <> TB.fromText "-}") rest'+                  go (depth - 1) (acc <> BSB.byteString "-}") rest'             _ ->-              go depth (acc <> TB.singleton c) rest+              go depth (acc <> BSB.char8 c) rest  data Piece-  = PieceWhitespace !Text+  = PieceWhitespace !ByteString   | PiecePaste-  | PieceRaw !Text-  | PieceParam !Text+  | PieceRaw !ByteString+  | PieceParam !ByteString -substituteParams :: Map Text Text -> Text -> Text-substituteParams = substituteParamsBuilder+substituteParams :: Map ByteString ByteString -> ByteString -> ByteString+substituteParams subs = substituteMacroArgs (M.map (\arg -> MacroArg arg arg) subs)  -- | 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+--+-- Parameters render as their macro-expanded argument, except as operands of+-- @#@ and @##@, which use the raw argument text.+substituteMacroArgs :: Map ByteString MacroArg -> ByteString -> ByteString+substituteMacroArgs subs = renderPieces . collapseTokenPastes . collapseStringizing . tokenizeReplacementList   where-    tokenizeReplacementList :: Text -> [Piece]+    tokenizeReplacementList :: ByteString -> [Piece]     tokenizeReplacementList txt =-      case T.uncons txt of+      case C.uncons txt of         Nothing -> []         Just (c, rest)-          | isSpace c ->-              let (spaces, remaining) = T.span isSpace txt+          | isSpaceChar c ->+              let (spaces, remaining) = C.span isSpaceChar txt                in PieceWhitespace spaces : tokenizeReplacementList remaining           | c == '"' ->               let (literal, remaining) = scanQuoted '"' txt@@ -389,143 +543,148 @@           | c == '\'' ->               let (literal, remaining) = scanQuoted '\'' txt                in PieceRaw literal : tokenizeReplacementList remaining-          | "/*" `T.isPrefixOf` txt ->+          | "/*" `C.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)+          | "##" `C.isPrefixOf` txt ->+              PiecePaste : tokenizeReplacementList (C.drop 2 txt)           | isIdentStart c ->-              let (ident, remaining) = T.span isIdentChar txt+              let (ident, remaining) = C.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+              PieceRaw (C.singleton c) : tokenizeReplacementList rest -    scanQuoted :: Char -> Text -> (Text, Text)+    scanQuoted :: Char -> ByteString -> (ByteString, ByteString)     scanQuoted quote = go False mempty       where         go escaped acc remaining =-          case T.uncons remaining of-            Nothing -> (builderToText acc, "")+          case C.uncons remaining of+            Nothing -> (builderToBytes acc, "")             Just (c, rest)               | c == quote && not escaped ->-                  (builderToText (acc <> TB.singleton c), rest)+                  (builderToBytes (acc <> BSB.char8 c), rest)               | c == '\\' ->-                  go (not escaped) (acc <> TB.singleton c) rest+                  go (not escaped) (acc <> BSB.char8 c) rest               | otherwise ->-                  go False (acc <> TB.singleton c) rest+                  go False (acc <> BSB.char8 c) rest      collapseStringizing :: [Piece] -> [Piece]     collapseStringizing [] = []     collapseStringizing (PieceRaw "#" : PieceParam name : rest) =-      PieceRaw (stringizeArgument (lookupParam name)) : collapseStringizing rest+      PieceRaw (stringizeArgument (lookupParamRaw name)) : collapseStringizing rest     collapseStringizing (PieceRaw "#" : rest) =       PieceRaw "#" : collapseStringizing rest     collapseStringizing (piece : rest) = piece : collapseStringizing rest +    -- The accumulator is held reversed: appending to the end of a list once+    -- per piece is quadratic, and a macro body expanded on every line of a+    -- module makes that the single hottest allocation in the preprocessor.+    -- Reversed, the piece to the left of a @##@ is just the head.     collapseTokenPastes :: [Piece] -> [Piece]     collapseTokenPastes = go []       where-        go acc [] = acc+        go acc [] = reverse acc         go acc (piece : rest) =           case piece of             PiecePaste ->-              let (accNoSpace, _) = trimTrailingWhitespace acc+              let accNoSpace = dropWhile isWhitespacePiece 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')+               in case (accNoSpace, restAfterSpace) of+                    (leftPiece : accInit, rightPiece : remaining) ->+                      go (PieceRaw (renderPieceRaw leftPiece <> renderPieceRaw rightPiece) : accInit) remaining+                    _ -> go (reverse leadingSpace <> (PieceRaw "##" : acc)) restAfterSpace+            _ -> go (piece : acc) rest      isWhitespacePiece :: Piece -> Bool     isWhitespacePiece (PieceWhitespace _) = True     isWhitespacePiece _ = False -    lookupParam :: Text -> Text-    lookupParam name = M.findWithDefault name name subs+    lookupParamWith :: (MacroArg -> ByteString) -> ByteString -> ByteString+    lookupParamWith field name = maybe name field (M.lookup name subs) -    renderPieces :: [Piece] -> Text-    renderPieces = T.concat . map renderPiece+    lookupParamRaw :: ByteString -> ByteString+    lookupParamRaw = lookupParamWith macroArgRaw -    renderPiece :: Piece -> Text-    renderPiece piece =+    renderPieces :: [Piece] -> ByteString+    renderPieces = C.concat . map renderPiece++    renderPiece :: Piece -> ByteString+    renderPiece = renderPieceWith macroArgExpanded++    -- \| Render an operand of @##@, which sees the raw argument text.+    renderPieceRaw :: Piece -> ByteString+    renderPieceRaw = renderPieceWith macroArgRaw++    renderPieceWith :: (MacroArg -> ByteString) -> Piece -> ByteString+    renderPieceWith field piece =       case piece of         PieceWhitespace txt -> txt         PiecePaste -> "##"         PieceRaw txt -> txt-        PieceParam name -> lookupParam name+        PieceParam name -> lookupParamWith field name -    stringizeArgument :: Text -> Text+    stringizeArgument :: ByteString -> ByteString     stringizeArgument arg =       let normalized = normalizeWhitespace arg-          escaped = T.concatMap escapeStringChar normalized-       in T.cons '"' (T.snoc escaped '"')+          escaped = C.concatMap escapeStringChar normalized+       in C.cons '"' (C.snoc escaped '"') -    normalizeWhitespace :: Text -> Text-    normalizeWhitespace = T.unwords . T.words+    -- Not 'C.words'/'C.unwords': those treat byte 0xA0 as whitespace and+    -- would split a multi-byte character down the middle.+    normalizeWhitespace :: ByteString -> ByteString+    normalizeWhitespace =+      C.intercalate " " . filter (not . C.null) . C.splitWith isSpaceChar -    escapeStringChar :: Char -> Text+    escapeStringChar :: Char -> ByteString     escapeStringChar '"' = "\\\""     escapeStringChar '\\' = "\\\\"-    escapeStringChar c = T.singleton c+    escapeStringChar c = C.singleton c -evalCondition :: EngineState -> Text -> Bool+evalCondition :: EngineState -> ByteString -> Bool evalCondition st expr = eval expr /= 0   where     macros = stMacros st     eval = evalNumeric . replaceRemainingWithZero . expandMacros st . replaceDefined macros -evalNumeric :: Text -> Integer+evalNumeric :: ByteString -> 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)+data Token = TOp ByteString | TNum Integer | TIdent ByteString | TOpenParen | TCloseParen deriving (Show) -tokenize :: Text -> [Token]+tokenize :: ByteString -> [Token] tokenize input =-  case T.uncons input of+  case C.uncons input of     Nothing -> []     Just (c, rest)-      | isSpace c ->-          tokenize (T.dropWhile isSpace rest)+      | isSpaceChar c ->+          tokenize (C.dropWhile isSpaceChar 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+          let (num, remaining) = C.span isDigit input+           in case C.readInteger num of+                Just (value, _) -> TNum value : tokenize remaining+                Nothing -> tokenize remaining       | isIdentStart c ->-          let (ident, remaining) = T.span isIdentChar input+          let (ident, remaining) = C.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+          let (op, remaining) = C.span isOpChar input+           in if C.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 :: ([Token] -> (Integer, [Token])) -> [ByteString] -> [Token] -> (Integer, [Token]) binary next ops ts =   let (v1, ts1) = next ts    in go v1 ts1@@ -574,32 +733,32 @@         _ -> (v, ts1) parseAtom ts = (0, ts) -replaceDefined :: Map Text MacroDef -> Text -> Text+replaceDefined :: Map ByteString MacroDef -> ByteString -> ByteString replaceDefined macros = go   where     go txt =-      case T.uncons txt of+      case C.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))+          | "defined" `C.isPrefixOf` txt && not (nextCharIsIdent (C.drop 7 txt)) ->+              expandDefined (C.dropWhile isSpaceChar (C.drop 7 txt))           | otherwise ->-              T.cons c (go rest)+              C.cons c (go rest)      expandDefined rest =-      case T.uncons rest of+      case C.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+          let rest' = C.dropWhile isSpaceChar restAfterOpen+              (name, restAfterName0) = C.span isIdentChar rest'+              restAfterName = C.dropWhile isSpaceChar restAfterName0+           in case C.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+          let (name, restAfterName) = C.span isIdentChar rest+           in if C.null name                 then boolLiteral False <> go rest                 else boolLiteral (M.member name macros) <> go restAfterName @@ -607,25 +766,25 @@     boolLiteral False = " 0 "      nextCharIsIdent remaining =-      case T.uncons remaining of+      case C.uncons remaining of         Just (c, _) -> isIdentChar c         Nothing -> False -replaceRemainingWithZero :: Text -> Text+replaceRemainingWithZero :: ByteString -> ByteString replaceRemainingWithZero = go   where     go txt =-      case T.uncons txt of+      case C.uncons txt of         Nothing -> ""         Just (c, rest)           | isIdentStart c ->-              let (_, remaining) = T.span isIdentChar txt+              let (_, remaining) = C.span isIdentChar txt                in " 0 " <> go remaining           | otherwise ->-              T.cons c (go rest)+              C.cons c (go rest) -builderToText :: TB.Builder -> Text-builderToText = TL.toStrict . TB.toLazyText+builderToBytes :: BSB.Builder -> ByteString+builderToBytes = BSL.toStrict . BSB.toLazyByteString -trimSpacesText :: Text -> Text-trimSpacesText = T.dropWhileEnd isSpace . T.dropWhile isSpace+trimSpacesBytes :: ByteString -> ByteString+trimSpacesBytes = C.dropWhileEnd isSpaceChar . C.dropWhile isSpaceChar
src/Aihc/Cpp/Parser.hs view
@@ -1,5 +1,14 @@ {-# LANGUAGE OverloadedStrings #-} +-- |+-- Module      : Aihc.Cpp.Parser+-- Description : Directive parsing over raw bytes+-- License     : Unlicense+--+-- Directives are parsed straight from the input bytes. Every character+-- that is significant to the C preprocessor is ASCII, so no decoding is+-- required; bytes >= 0x80 are only ever carried along inside identifiers,+-- macro bodies and message text. module Aihc.Cpp.Parser   ( Directive (..),     parseDirective,@@ -8,54 +17,56 @@     parseInclude,     parseLineDirective,     parseIdentifier,-    parseQuotedText,+    parseQuoted,     parseDefineParams,     isIdentStart,     isIdentChar,     isOpChar,+    isSpaceChar,+    strip,+    stripStart,   ) where  import Aihc.Cpp.Types (IncludeKind (..))-import Data.Char (isAlphaNum, isDigit, isLetter)-import Data.Text (Text)-import qualified Data.Text as T-import qualified Data.Text.Read as TR+import Data.ByteString (ByteString)+import qualified Data.ByteString.Char8 as C+import Data.Char (isAsciiLower, isAsciiUpper, isDigit)  data Directive-  = DirDefineObject !Text !Text-  | DirDefineFunction !Text ![Text] !Text-  | DirUndef !Text-  | DirInclude !IncludeKind !Text-  | DirIf !Text-  | DirIfDef !Text-  | DirIfNDef !Text-  | DirElif !Text+  = DirDefineObject !ByteString !ByteString+  | DirDefineFunction !ByteString ![ByteString] !ByteString+  | DirUndef !ByteString+  | DirInclude !IncludeKind !ByteString+  | DirIf !ByteString+  | DirIfDef !ByteString+  | DirIfNDef !ByteString+  | DirElif !ByteString   | DirElse   | DirEndIf   | DirLine !Int !(Maybe FilePath)   | DirPragmaOnce-  | DirWarning !Text-  | DirError !Text-  | DirUnsupported !Text+  | DirWarning !ByteString+  | DirError !ByteString+  | DirUnsupported !ByteString -parseDirective :: Text -> Maybe Directive+parseDirective :: ByteString -> Maybe Directive parseDirective raw =-  let trimmed = T.stripStart raw-   in if "#" `T.isPrefixOf` trimmed+  let trimmed = stripStart raw+   in if "#" `C.isPrefixOf` trimmed         then-          let body = T.stripStart (T.drop 1 trimmed)-           in case T.uncons body of-                Just (c, _) | isLetter c || isDigit c -> parseDirectiveBody body+          let body = stripStart (C.drop 1 trimmed)+           in case C.uncons body of+                Just (c, _) | isIdentStart c || isDigit c -> parseDirectiveBody body                 _ -> Nothing         else Nothing -parseDirectiveBody :: Text -> Maybe Directive+parseDirectiveBody :: ByteString -> Maybe Directive parseDirectiveBody body =-  let (name, rest0) = T.span isIdentChar body-      rest = T.stripStart rest0-   in if T.null name-        then case T.uncons body of+  let (name, rest0) = C.span isIdentChar body+      rest = stripStart rest0+   in if C.null name+        then case C.uncons body of           Just (c, _) | isDigit c -> parseLineDirective body           _ -> Nothing         else case name of@@ -77,77 +88,106 @@           "error" -> Just (DirError rest)           _ -> Nothing -parseLineDirective :: Text -> Maybe Directive+parseLineDirective :: ByteString -> Maybe Directive parseLineDirective body =-  case TR.decimal body of-    Left _ -> Nothing-    Right (lineNumber, rest0) ->-      let rest = T.stripStart rest0-       in case parseQuotedText rest of-            Nothing -> Just (DirLine lineNumber Nothing)-            Just path -> Just (DirLine lineNumber (Just (T.unpack path)))+  case C.uncons body of+    -- 'C.readInt' also accepts a leading sign; a #line number must not.+    Just (c, _) | isDigit c ->+      case C.readInt body of+        Nothing -> Nothing+        Just (lineNumber, rest0) ->+          let rest = stripStart rest0+           in case parseQuoted rest of+                Nothing -> Just (DirLine lineNumber Nothing)+                Just path -> Just (DirLine lineNumber (Just (C.unpack path)))+    _ -> Nothing -parsePragma :: Text -> Maybe Directive+parsePragma :: ByteString -> Maybe Directive parsePragma body =-  case T.words body of-    ["once"] -> Just DirPragmaOnce-    _ -> Nothing+  if strip body == "once" then Just DirPragmaOnce else Nothing -parseDefine :: Text -> Maybe Directive+parseDefine :: ByteString -> Maybe Directive parseDefine rest = do-  let (name, rest0) = T.span isIdentChar rest-  if T.null name+  let (name, rest0) = C.span isIdentChar rest+  if C.null name     then Nothing-    else case T.uncons rest0 of+    else case C.uncons rest0 of       Just ('(', afterOpen) ->         let (params, restAfterParams) = parseDefineParams afterOpen          in case params of               Nothing -> Just (DirUnsupported "define-function-macro")-              Just names -> Just (DirDefineFunction name names (T.stripStart restAfterParams))-      _ -> Just (DirDefineObject name (T.stripStart rest0))+              Just names -> Just (DirDefineFunction name names (stripStart restAfterParams))+      _ -> Just (DirDefineObject name (stripStart rest0)) -parseDefineParams :: Text -> (Maybe [Text], Text)+parseDefineParams :: ByteString -> (Maybe [ByteString], ByteString) parseDefineParams input =-  let (inside, suffix) = T.breakOn ")" input-   in if T.null suffix+  let (inside, suffix) = C.break (== ')') input+   in if C.null suffix         then (Nothing, "")         else-          let rawParams = T.splitOn "," inside-              params = map (T.takeWhile isIdentChar . T.strip) rawParams-           in if T.null (T.strip inside)-                then (Just [], T.drop 1 suffix)+          let rawParams = C.split ',' inside+              params = map (C.takeWhile isIdentChar . strip) rawParams+           in if C.null (strip inside)+                then (Just [], C.drop 1 suffix)                 else-                  if any T.null params-                    then (Nothing, T.drop 1 suffix)-                    else (Just params, T.drop 1 suffix)+                  if any C.null params+                    then (Nothing, C.drop 1 suffix)+                    else (Just params, C.drop 1 suffix) -parseIdentifier :: Text -> Maybe Text+parseIdentifier :: ByteString -> Maybe ByteString parseIdentifier txt =-  let ident = T.takeWhile isIdentChar (T.stripStart txt)-   in if T.null ident then Nothing else Just ident+  let ident = C.takeWhile isIdentChar (stripStart txt)+   in if C.null ident then Nothing else Just ident -parseInclude :: Text -> Maybe Directive+parseInclude :: ByteString -> Maybe Directive parseInclude txt =-  case T.uncons (T.stripStart txt) of+  case C.uncons (stripStart txt) of     Just ('"', rest) ->-      let (path, suffix) = T.breakOn "\"" rest-       in if T.null suffix then Nothing else Just (DirInclude IncludeLocal path)+      let (path, suffix) = C.break (== '"') rest+       in if C.null suffix then Nothing else Just (DirInclude IncludeLocal path)     Just ('<', rest) ->-      let (path, suffix) = T.breakOn ">" rest-       in if T.null suffix then Nothing else Just (DirInclude IncludeSystem path)+      let (path, suffix) = C.break (== '>') rest+       in if C.null suffix then Nothing else Just (DirInclude IncludeSystem path)     _ -> Nothing -parseQuotedText :: Text -> Maybe Text-parseQuotedText txt = do-  ('"', rest) <- T.uncons txt-  let (path, suffix) = T.breakOn "\"" rest-  if T.null suffix then Nothing else Just path+parseQuoted :: ByteString -> Maybe ByteString+parseQuoted txt = do+  ('"', rest) <- C.uncons txt+  let (path, suffix) = C.break (== '"') rest+  if C.null suffix then Nothing else Just path +-- | ASCII whitespace.+--+-- Deliberately not 'Data.Char.isSpace': applied to a byte, that would+-- classify 0xA0 (Latin-1 NBSP, and a perfectly ordinary UTF-8+-- continuation byte) as whitespace and split a multi-byte character in+-- half. For the same reason this module avoids 'C.words' and 'C.strip'.+isSpaceChar :: Char -> Bool+isSpaceChar c =+  c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' || c == '\v'++-- | Drop leading ASCII whitespace.+stripStart :: ByteString -> ByteString+stripStart = C.dropWhile isSpaceChar++-- | Drop leading and trailing ASCII whitespace.+strip :: ByteString -> ByteString+strip = C.dropWhile isSpaceChar . C.dropWhileEnd isSpaceChar++-- | First character of an identifier.+--+-- Any byte >= 0x80 qualifies, so a non-ASCII identifier is scanned as one+-- token regardless of the source encoding, and a byte that decodes to+-- nothing at all is simply part of whatever token contains it. isIdentStart :: Char -> Bool-isIdentStart c = c == '_' || isLetter c+isIdentStart c = c == '_' || isAsciiAlpha c || c >= '\x80' +-- | Subsequent characters of an identifier. See 'isIdentStart'. isIdentChar :: Char -> Bool-isIdentChar c = c == '_' || isAlphaNum c+isIdentChar c = c == '_' || isAsciiAlpha c || isDigit c || c >= '\x80'++isAsciiAlpha :: Char -> Bool+isAsciiAlpha c = isAsciiLower c || isAsciiUpper c  isOpChar :: Char -> Bool isOpChar c =
src/Aihc/Cpp/Scanner.hs view
@@ -12,26 +12,24 @@  import Aihc.Cpp.Cursor   ( Cursor (..),-    advance,-    advance2,-    bufLength,     findNewline,     null,-    peekByte,-    peekByte2,     skipNewline,-    skipToInteresting,-    sliceText,+    sliceBytes,   ) import Aihc.Cpp.Evaluator (expandMacros, expandMacrosMultiline) import Aihc.Cpp.Types (EngineState)-import Data.Text (Text)-import qualified Data.Text as T+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 :: !Text+    lineSpanText :: !ByteString   }  data LineScan = LineScan@@ -41,9 +39,9 @@   }  -- | Expand macros in a list of line spans (single-line, no lookahead).-expandLineBySpan :: EngineState -> [LineSpan] -> Text+expandLineBySpan :: EngineState -> [LineSpan] -> ByteString expandLineBySpan st =-  T.concat . map expandSpan+  C.concat . map expandSpan   where     expandSpan lineChunk       | lineSpanInBlockComment lineChunk = lineSpanText lineChunk@@ -57,35 +55,42 @@ -- 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 -> (Text, Int)+expandLineBySpanMultiline :: EngineState -> [LineSpan] -> Cursor -> (ByteString, Int) expandLineBySpanMultiline st spans futureCursor =   let commentSpans = filter lineSpanInBlockComment spans-      hasLineComment = any (\s -> "--" `T.isPrefixOf` lineSpanText s) commentSpans-      hasCBlockComment = any (T.all (== ' ') . lineSpanText) commentSpans+      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 = T.concat [lineSpanText s | s <- spans]+          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 = T.concat [lineSpanText s | s <- spans]+              let codeText = concatSpans spans                   futureCodeLines = cursorToLines futureCursor                in expandMacrosMultiline st codeText futureCodeLines --- | Extract lines from a cursor as a lazy list of Text values.--- Each line is the text up to the next newline (or EOF).-cursorToLines :: Cursor -> [Text]+-- | 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 = sliceText (curPos cur) (curPos eol) cur+          lineText = sliceBytes (curPos cur) (curPos eol) cur        in lineText : maybe [] cursorToLines (skipNewline eol)  -- | Lightweight scan that only tracks block comment depth changes.@@ -93,76 +98,75 @@ -- Used for inactive conditional branches where only comment depth -- tracking is needed (no macro expansion or span splitting). ----- Accepts a 'Cursor' positioned at the start of the line content.--- The cursor should be bounded to the line (e.g., via 'lineSlice').-scanLineDepthOnly :: Int -> Int -> Cursor -> (Int, Int)-scanLineDepthOnly = goDepth+-- Takes the text of one logical line.+scanLineDepthOnly :: Int -> Int -> ByteString -> (Int, Int)+scanLineDepthOnly hsDepth0 cDepth0 line = goDepth hsDepth0 cDepth0 0   where-    goDepth :: Int -> Int -> Cursor -> (Int, Int)-    goDepth !hsDepth !cDepth !cur-      | null cur = (hsDepth, cDepth)-      | otherwise =-          case peekByte2 cur of-            Nothing ->-              -- One byte left, no two-char sequence possible-              (hsDepth, cDepth)-            Just (b1, b2)-              | cDepth > 0 ->-                  if b1 == 0x2A && b2 == 0x2F -- '*/'-                    then goDepth hsDepth 0 (advance2 cur)-                    else goDepth hsDepth cDepth (advance cur)-              | hsDepth > 0 && b1 == 0x2D && b2 == 0x7D -> -- '-}'-                  goDepth (hsDepth - 1) cDepth (advance2 cur)-              | b1 == 0x7B && b2 == 0x2D -> -- '{-'-                  let cur' = advance2 cur-                   in case peekByte cur' of-                        Just 0x23 ->-                          -- {-# is a pragma, not a comment-                          goDepth hsDepth cDepth (advance cur)-                        _ ->-                          goDepth (hsDepth + 1) cDepth cur'-              | hsDepth == 0 && b1 == 0x2F && b2 == 0x2A -> -- '/*'-                  goDepth hsDepth 1 (advance2 cur)-              | hsDepth == 0 && b1 == 0x2D && b2 == 0x2D -> -- '--' line comment-                  (hsDepth, cDepth)-              | otherwise ->-                  goDepth hsDepth cDepth (advance cur)+    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. Uses a byte-level cursor for--- efficient scanning instead of character-by-character T.uncons/T.cons.+-- either inside or outside block comments. ----- Accepts a 'Cursor' positioned at the start of the line content.--- The cursor should be bounded to the line (e.g., via 'lineSlice').+-- 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 UTF-8 encoded input. C89 comment+-- comments) are zero-copy slices of the raw input. C89 comment -- content is replaced with spaces to preserve column alignment.-scanLine :: Int -> Int -> Cursor -> LineScan-scanLine hsDepth0 cDepth0 cursor0 =+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-          []-          (curPos cursor0)-          (hsDepth0 > 0 || cDepth0 > 0)-          cursor0+        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 -> Cursor -> Bool -> [LineSpan]-    emit acc start end cur inComment+    emit :: [LineSpan] -> Int -> Int -> Bool -> [LineSpan]+    emit acc start end inComment       | start >= end = acc-      | otherwise = LineSpan inComment (sliceText start end cur) : acc+      | otherwise = LineSpan inComment (BS.take (end - start) (BS.drop start line)) : acc     {-# INLINE emit #-}      go ::@@ -174,215 +178,144 @@       [LineSpan] ->       Int ->       Bool ->-      Cursor ->+      Int ->       ([LineSpan], Int, Int)-    go-      !hsDepth-      !cDepth-      !inString-      !inChar-      !escaped-      !acc-      !spanStart-      !spanInComment-      !cur-        -- End of input: flush the accumulated span-        | null cur =-            (emit acc spanStart (curPos cur) cur spanInComment, hsDepth, cDepth)-        | otherwise =-            case peekByte2 cur of-              Nothing ->-                -- === Only one byte left ===-                if cDepth > 0-                  then-                    -- In C comment: flush accumulated, emit space-                    let acc' = emit acc spanStart (curPos cur) cur spanInComment-                     in (LineSpan True " " : acc', hsDepth, cDepth)-                  else-                    -- Include this last byte in the accumulated span-                    let inCommentNow = hsDepth > 0-                        cur' = advance cur-                     in (emit acc spanStart (curPos cur') cur inCommentNow, hsDepth, cDepth)-              Just (b1, b2) ->-                -- === C block comment mode ===-                if cDepth > 0-                  then-                    if b1 == 0x2A && b2 == 0x2F -- '*/'-                      then-                        let acc' = emit acc spanStart (curPos cur) cur spanInComment-                            cur' = advance2 cur-                         in go-                              hsDepth-                              0-                              False-                              False-                              False-                              (LineSpan True "  " : acc')-                              (curPos cur')-                              False-                              cur'-                      else-                        let acc' = emit acc spanStart (curPos cur) cur spanInComment-                            cur' = advance cur-                         in go-                              hsDepth-                              cDepth-                              False-                              False-                              False-                              (LineSpan True " " : acc')-                              (curPos cur')-                              True-                              cur'-                  -- === Line comment: -- (outside strings and hs comments) ===-                  else-                    if not inString-                      && not inChar-                      && hsDepth == 0-                      && b1 == 0x2D-                      && b2 == 0x2D -- '--'-                      then-                        let acc' = emit acc spanStart (curPos cur) cur spanInComment-                            restText = sliceText (curPos cur) (bufLength cur) cur-                         in (LineSpan True restText : acc', hsDepth, cDepth)-                      -- === Inside string literal ===-                      else-                        if inString-                          then-                            let escaped' = not escaped && b1 == 0x5C -- '\\'-                                inString' = escaped || b1 /= 0x22 -- '"'-                             in go-                                  hsDepth-                                  cDepth-                                  inString'-                                  False-                                  escaped'-                                  acc-                                  spanStart-                                  spanInComment-                                  (advance cur)-                          -- === Inside char literal ===-                          else-                            if inChar-                              then-                                let escaped' = not escaped && b1 == 0x5C -- '\\'-                                    inChar' = escaped || b1 /= 0x27 -- '\''-                                 in go-                                      hsDepth-                                      cDepth-                                      False-                                      inChar'-                                      escaped'-                                      acc-                                      spanStart-                                      spanInComment-                                      (advance cur)-                              -- === Start of string literal ===-                              else-                                if hsDepth == 0 && b1 == 0x22 -- '"'-                                  then-                                    go-                                      hsDepth-                                      cDepth-                                      True-                                      False-                                      False-                                      acc-                                      spanStart-                                      spanInComment-                                      (advance cur)-                                  -- === Start of char literal ===-                                  else-                                    if hsDepth == 0 && b1 == 0x27 -- '\''-                                      then-                                        go-                                          hsDepth-                                          cDepth-                                          False-                                          True-                                          False-                                          acc-                                          spanStart-                                          spanInComment-                                          (advance cur)-                                      -- === End of Haskell block comment: -} ===-                                      else-                                        if hsDepth > 0 && b1 == 0x2D && b2 == 0x7D -- '-}'-                                          then-                                            let cur' = advance2 cur-                                                hsDepth' = hsDepth - 1-                                                -- Flush everything up to and including -} as a comment span-                                                acc' = emit acc spanStart (curPos cur') cur True-                                                inCommentAfter = hsDepth' > 0-                                             in go-                                                  hsDepth'-                                                  cDepth-                                                  False-                                                  False-                                                  False-                                                  acc'-                                                  (curPos cur')-                                                  inCommentAfter-                                                  cur'-                                          -- === Start of Haskell block comment: {- (but not {-#) ===-                                          else-                                            if b1 == 0x7B && b2 == 0x2D -- '{-'-                                              then-                                                let cur' = advance2 cur-                                                 in case peekByte cur' of-                                                      Just 0x23 ->-                                                        -- '#' => pragma {-#, not a block comment-                                                        -- Advance past '{' only, continue in same mode-                                                        go-                                                          hsDepth-                                                          cDepth-                                                          False-                                                          False-                                                          False-                                                          acc-                                                          spanStart-                                                          spanInComment-                                                          (advance cur)-                                                      _ ->-                                                        -- Flush any text before {-, emit {- as comment-                                                        let acc' = emit acc spanStart (curPos cur) cur spanInComment-                                                            acc'' = LineSpan True "{-" : acc'-                                                         in go-                                                              (hsDepth + 1)-                                                              cDepth-                                                              False-                                                              False-                                                              False-                                                              acc''-                                                              (curPos cur')-                                                              True-                                                              cur'-                                              -- === Start of C block comment: /* ===-                                              else-                                                if hsDepth == 0 && b1 == 0x2F && b2 == 0x2A -- '/*'-                                                  then-                                                    let acc' = emit acc spanStart (curPos cur) cur spanInComment-                                                        cur' = advance2 cur-                                                     in go-                                                          hsDepth-                                                          1-                                                          False-                                                          False-                                                          False-                                                          (LineSpan True "  " : acc')-                                                          (curPos cur')-                                                          True-                                                          cur'-                                                  -- === Normal byte: bulk-skip non-interesting bytes ===-                                                  else-                                                    let cur' = skipToInteresting (advance cur)-                                                     in go-                                                          hsDepth-                                                          cDepth-                                                          False-                                                          False-                                                          False-                                                          acc-                                                          spanStart-                                                          spanInComment-                                                          cur'+    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)
src/Aihc/Cpp/Types.hs view
@@ -14,6 +14,11 @@     Step (..),     EngineState (..),     emptyState,+    defineMacro,+    undefMacro,+    setMacroTable,+    macroFirstByte,+    bloomMember,     CondFrame (..),     currentActive,     mkFrame,@@ -24,13 +29,16 @@  import Aihc.Cpp.Cursor (Cursor) import Control.DeepSeq (NFData)+import Data.Bits (setBit, testBit, (.&.)) import Data.ByteString (ByteString)+import qualified Data.ByteString as BS+import qualified Data.ByteString.Builder as BSB 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.Lazy.Builder as TB+import Data.Word (Word64, Word8) import GHC.Generics (Generic)  -- $setup@@ -45,12 +53,16 @@     -- | User-defined macros. These are expanded as object-like macros.     -- Note that the values should include any necessary quoting. For     -- example, to define a string macro, use @"\"value\""@.-    configMacros :: !(Map Text Text)+    --+    -- Names and bodies are raw bytes: the preprocessor never decodes+    -- them, so a @-D@ flag taken straight from @argv@ can be passed+    -- through unchanged whatever its encoding.+    configMacros :: !(Map ByteString ByteString)   }  data MacroDef-  = ObjectMacro !Text-  | FunctionMacro ![Text] !Text+  = ObjectMacro !ByteString+  | FunctionMacro ![ByteString] !ByteString   deriving (Eq, Show)  -- | Default configuration with sensible defaults.@@ -106,6 +118,12 @@   { -- | The severity of the diagnostic.     diagSeverity :: !Severity,     -- | The diagnostic message text.+    --+    -- Unlike 'Result', a diagnostic is meant to be shown to a human, so+    -- this is 'Text'. Message fragments taken from the source (an+    -- @#error@ message, an include path) are decoded as UTF-8 with+    -- invalid bytes replaced by U+FFFD; that substitution affects the+    -- message only, never 'resultOutput'.     diagMessage :: !Text,     -- | The file where the diagnostic occurred.     diagFile :: !FilePath,@@ -116,8 +134,12 @@  -- | The result of preprocessing. data Result = Result-  { -- | The preprocessed output text.-    resultOutput :: !Text,+  { -- | The preprocessed output.+    --+    -- Bytes the preprocessor did not itself generate are copied through+    -- verbatim, so the output carries the input's encoding, whatever it+    -- was. See 'Aihc.Cpp.preprocess' for the encoding contract.+    resultOutput :: !ByteString,     -- | Any diagnostics (warnings or errors) emitted during preprocessing.     resultDiagnostics :: ![Diagnostic]   }@@ -134,8 +156,19 @@     NeedInclude !IncludeRequest !(Maybe ByteString -> Step)  data EngineState = EngineState-  { stMacros :: !(Map Text MacroDef),-    stOutput :: !TB.Builder,+  { stMacros :: !(Map ByteString MacroDef),+    -- | Which bytes any macro name can start with, as a 64-bit set (see+    -- 'macroFirstByte'). Nearly every identifier in a Haskell module names+    -- no macro, and testing one bit rejects it without the string+    -- comparisons a 'Map' lookup would run. Kept in step with 'stMacros'+    -- by 'setMacros'; @_@ is always a member, because @__LINE__@ and+    -- @__FILE__@ are recognised without being in the map.+    stMacroBloom :: {-# UNPACK #-} !Word64,+    -- | The same, restricted to function-like macros, and 0 when a module+    -- defines none — which is the common case, and lets the multi-line+    -- call lookahead skip the line entirely.+    stFunMacroBloom :: {-# UNPACK #-} !Word64,+    stOutput :: !BSB.Builder,     stOutputLineCount :: {-# UNPACK #-} !Int,     stDiagnosticsRev :: ![Diagnostic],     stPragmaOnceFiles :: !(Set FilePath),@@ -150,6 +183,8 @@ emptyState filePath =   EngineState     { stMacros = M.empty,+      stMacroBloom = underscoreBloom,+      stFunMacroBloom = 0,       stOutput = mempty,       stOutputLineCount = 0,       stDiagnosticsRev = [],@@ -160,6 +195,63 @@       stCurrentFile = filePath,       stCurrentLine = 1     }++-- | Define a macro, keeping the first-byte blooms in step. Adding a name+-- only ever sets bits, so this costs one @Map@ insert rather than a walk+-- of the whole table.+defineMacro :: ByteString -> MacroDef -> EngineState -> EngineState+defineMacro name def st =+  st+    { stMacros = M.insert name def (stMacros st),+      stMacroBloom = setBit (stMacroBloom st) bit',+      stFunMacroBloom = case def of+        FunctionMacro _ _ -> setBit (stFunMacroBloom st) bit'+        ObjectMacro _ -> stFunMacroBloom st+    }+  where+    bit' = macroFirstByte name++-- | Undefine a macro. Removing a name can clear a bit, which only a full+-- pass can tell, so the blooms are rebuilt; @#undef@ is rare enough for+-- that not to matter.+undefMacro :: ByteString -> EngineState -> EngineState+undefMacro name st = setMacroTable (M.delete name (stMacros st)) st++-- | Replace the macro table wholesale and rebuild the blooms from it.+setMacroTable :: Map ByteString MacroDef -> EngineState -> EngineState+setMacroTable macros st =+  let (allBloom, funBloom) = M.foldrWithKey step (underscoreBloom, 0) macros+   in st+        { stMacros = macros,+          stMacroBloom = allBloom,+          stFunMacroBloom = funBloom+        }+  where+    step name def (allBloom, funBloom) =+      let bit' = macroFirstByte name+          allBloom' = setBit allBloom bit'+       in case def of+            FunctionMacro _ _ -> (allBloom', setBit funBloom bit')+            ObjectMacro _ -> (allBloom', funBloom)++-- | Bloom containing only @_@, the first byte of @__LINE__@ and @__FILE__@.+underscoreBloom :: Word64+underscoreBloom = setBit 0 (macroFirstByte "_")++-- | Which bit of a bloom a name's first byte occupies: the low six bits of+-- that byte. Identifiers start with a letter, @_@, or a byte >= 0x80, and+-- those map to distinct bits across @A-Z@, @a-z@ and @_@, so the filter is+-- exact for ASCII names and merely approximate for the rest.+macroFirstByte :: ByteString -> Int+macroFirstByte name+  | BS.null name = 0+  | otherwise = fromIntegral (BS.head name .&. 0x3F)+{-# INLINE macroFirstByte #-}++-- | Could a name starting with this byte be in the bloom?+bloomMember :: Word64 -> Word8 -> Bool+bloomMember bloom b = testBit bloom (fromIntegral (b .&. 0x3F))+{-# INLINE bloomMember #-}  data CondFrame = CondFrame   { frameOuterActive :: !Bool,
test/Spec.hs view
@@ -2,10 +2,12 @@  module Main (main) where -import Aihc.Cpp (Config (..), Result (..), Step (..), defaultConfig, preprocess)+import Aihc.Cpp (Config (..), Diagnostic (..), Result (..), Severity (..), Step (..), defaultConfig, preprocess)+import qualified Data.ByteString as BS+import qualified Data.ByteString.Char8 as C import qualified Data.Map.Strict as M-import qualified Data.Text as T import qualified Data.Text.Encoding as TE+import Data.Word (Word8) import Test.Progress (CaseMeta (..), Outcome (..), evaluateCase, loadManifest) import Test.Tasty (TestTree, defaultMain, testGroup) import Test.Tasty.HUnit (Assertion, assertFailure, testCase)@@ -20,11 +22,159 @@         "cpp-oracle"         ( checks             <> [linePragmaTest, dateTimeTest, functionMacroArgumentTest, functionMacroUnclosedCallTest, definedConditionSpacingTest, stringContinuationTests, tokenPastingTests, ccallLineCommentTest]-            <> [pragmaOnceTest]+            <> [pragmaOnceTest, macroRescanTests, pragmaInsideBlockCommentTests, encodingTests]+            <> [lineSpanTests]             <> [QC.testProperty "dummy quickcheck property" prop_dummy]         )     ) +-- | The preprocessor must be agnostic to the source encoding: bytes it+-- did not generate itself are copied through verbatim, whatever they+-- encode. GHC itself accepts an undecodable byte inside a comment (it+-- only rejects one where a token must be lexed), so rejecting such a+-- file outright would fail modules that really do compile.+encodingTests :: TestTree+encodingTests =+  testGroup+    "source encoding"+    [ testCase "latin-1 byte in a comment survives byte-for-byte" $ do+        -- The Stackage module that first exposed this: Ebnf2ps's+        -- Defaults.hs is ISO-8859-1 and has 0xA9 ((c)) in a comment.+        let input = "{-# LANGUAGE CPP #-}\n-- " <> byte 0xA9 <> " 2026\n#define X 1\nx = X\n"+            cfg = defaultConfig {configInputFile = "Defaults.hs"}+        preprocessTo cfg input+          @?= ("#line 1 \"Defaults.hs\"\n{-# LANGUAGE CPP #-}\n-- " <> byte 0xA9 <> " 2026\n\nx = 1\n"),+      testCase "no diagnostics for undecodable bytes" $ do+        let input = "-- " <> byte 0xA9 <> "\nx = 1\n"+        diagnosticsOf defaultConfig input @?= [],+      testCase "latin-1 bytes survive in a string literal and in code" $ do+        let input = "#define X 1\ns = \"" <> byte 0xE9 <> "\"\nc = X -- " <> byte 0xFF <> "\n"+        preprocessTo defaultConfig input+          @?= ("#line 1 \"<input>\"\n\ns = \"" <> byte 0xE9 <> "\"\nc = 1 -- " <> byte 0xFF <> "\n"),+      testCase "a lone continuation byte is not treated as whitespace" $ do+        -- 0xA0 is Latin-1 NBSP and 'Data.Char.isSpace'; treating it as+        -- whitespace would split a UTF-8 character in half.+        let input = "#define A" <> byte 0xA0 <> "B 1\nA" <> byte 0xA0 <> "B\n"+        preprocessTo defaultConfig input+          @?= "#line 1 \"<input>\"\n\n1\n",+      testCase "utf-8 identifiers still expand" $ do+        let input = TE.encodeUtf8 "#define caf\xe9 42\ncaf\xe9\n"+        preprocessTo defaultConfig input+          @?= TE.encodeUtf8 "#line 1 \"<input>\"\n\n42\n",+      testCase "arbitrary non-text bytes round-trip unchanged" $ do+        let payload = BS.pack [0x00, 0x80, 0xFE, 0xFF, 0xC0, 0x80, 0xED, 0xA0, 0x80]+            input = "-- " <> payload <> "\nx = 1\n"+        preprocessTo defaultConfig input+          @?= ("#line 1 \"<input>\"\n-- " <> payload <> "\nx = 1\n"),+      testCase "an undecodable include is preprocessed like any other" $ do+        let includeBytes = "-- " <> byte 0xA9 <> "\n"+        case preprocess defaultConfig {configInputFile = "root.hs"} "#include \"bad.h\"\nx = 1\n" of+          NeedInclude _ k ->+            case k (Just includeBytes) of+              Done result -> do+                [d | d <- resultDiagnostics result, diagSeverity d == Error] @?= []+                (byte 0xA9 `BS.isInfixOf` resultOutput result) @?= True+              _ -> assertFailure "expected Done"+          _ -> assertFailure "expected NeedInclude"+    ]++-- | Number of non-overlapping occurrences of a substring.+countSubstring :: BS.ByteString -> BS.ByteString -> Int+countSubstring needle = go 0+  where+    go n hay =+      case BS.breakSubstring needle hay of+        (_, rest)+          | BS.null rest -> n+          | otherwise -> go (n + 1) (BS.drop (BS.length needle) rest)++-- | A single raw byte, with no encoding applied.+byte :: Word8 -> BS.ByteString+byte b = BS.pack [b]++-- | Run 'preprocess' to completion and return the raw output bytes.+preprocessTo :: Config -> BS.ByteString -> BS.ByteString+preprocessTo cfg input =+  case preprocess cfg input of+    Done result -> resultOutput result+    _ -> error "expected Done"++diagnosticsOf :: Config -> BS.ByteString -> [Diagnostic]+diagnosticsOf cfg input =+  case preprocess cfg input of+    Done result -> resultDiagnostics result+    _ -> error "expected Done"++-- | Where a line splits into code and comment spans.+--+-- The line scanner has a fast path for a line that is one code span, and+-- it decides that with the same string- and char-literal state machine+-- the general scanner uses. A line that only looks like it opens a+-- comment must not take the fast path, and one that only looks like it+-- does not must not miss it: whether a span is code or comment decides+-- whether macros in it expand, so a disagreement is silent.+lineSpanTests :: TestTree+lineSpanTests =+  testGroup+    "line spans"+    [ expands "{- inside a string opens no comment" "x = \"a {- b\" ++ FOO\n" "x = \"a {- b\" ++ 1\n",+      expands "a brace in a char literal opens no comment" "c = '{'\n" "c = '{'\n",+      expands "-- inside a string starts no line comment" "x = \"a -- b\" ++ FOO\n" "x = \"a -- b\" ++ 1\n",+      expands "/* inside a string starts no C comment" "x = \"/*\" ++ FOO\n" "x = \"/*\" ++ 1\n",+      expands "a {-# pragma is not a comment" "{-# LANGUAGE CPP #-}\n" "{-# LANGUAGE CPP #-}\n",+      expands "a closed string leaves literal state" "x = \"a\\\\\\\\\" ++ FOO\n" "x = \"a\\\\\\\\\" ++ 1\n",+      -- The remaining three are the cases the fast path must decline.+      expands+        "a macro inside an inline block comment does not expand"+        "x = 1 {- FOO -} + FOO\n"+        "x = 1 {- FOO -} + 1\n",+      expands+        "a macro after a line comment marker does not expand"+        "x = 1 -- FOO\n"+        "x = 1 -- FOO\n",+      expands+        "a C block comment becomes spaces of the same width"+        "x = 1 /* FOO */ + FOO\n"+        "x = 1           + 1\n",+      -- A prime is lexed as an unterminated char literal, so the rest of+      -- the line is inside a literal and nothing in it expands. That is+      -- long-standing behaviour, pinned here because the fast path has to+      -- reproduce it to agree with the general scanner.+      expands "an unterminated char literal suppresses expansion" "x' = FOO\n" "x' = FOO\n",+      -- The three below are the cases where getting the literal state+      -- wrong changes the output rather than merely costing a fast path.+      -- A C comment is replaced by spaces, so which lines are inside one+      -- is visible; a Haskell comment is not, which is why these use /*.+      expands+        "a string closes before a C comment opens"+        "x = \"a\" /* FOO\nFOO\n*/\ny = FOO\n"+        "x = \"a\"       \n   \n  \ny = 1\n",+      expands+        "a char literal closes before a C comment opens"+        "c = 'x' /* FOO\nFOO\n*/\ny = FOO\n"+        "c = 'x'       \n   \n  \ny = 1\n",+      -- A line whose last byte is the first half of a two-byte sequence+      -- must not make the scanner look past the end of it. 'preprocess'+      -- promises never to raise, so an out-of-range read here would be a+      -- broken contract rather than a wrong answer.+      expands "a line ending in a lone -" "x = 1 -\nFOO\n" "x = 1 -\n1\n",+      expands "a line ending in a lone {" "x = {\nFOO\n" "x = {\n1\n",+      expands "a line ending in a lone /" "x = /\nFOO\n" "x = /\n1\n",+      expands "a line ending in a lone *" "x = 1 *\nFOO\n" "x = 1 *\n1\n",+      expands "a line that is a lone -" "-\n" "-\n",+      expands "a line that is a lone {" "{\n" "{\n",+      -- A function-like call opened inside a line comment is not a call,+      -- so the following line must not be swallowed as its continuation.+      testCase "a line comment ends the multi-line call lookahead" $+        preprocessTo defaultConfig "#define F(a) a\nx = 1 -- F(\n  2)\ny = F(3)\n"+          @?= "#line 1 \"<input>\"\n\nx = 1 -- F(\n  2)\ny = 3\n"+    ]+  where+    expands name body expected =+      testCase name $+        preprocessTo defaultConfig ("#define FOO 1\n" <> body)+          @?= ("#line 1 \"<input>\"\n\n" <> expected)+ -- | Dummy QuickCheck property that always passes. -- Added so that --quickcheck-tests flag is accepted by the test suite. prop_dummy :: Bool@@ -85,12 +235,12 @@ linePragmaTest :: TestTree linePragmaTest =   testCase "include emits line pragmas" $-    case preprocess defaultConfig {configInputFile = "root.hs"} (TE.encodeUtf8 "before\n#include \"nested.inc\"\nafter") of+    case preprocess defaultConfig {configInputFile = "root.hs"} "before\n#include \"nested.inc\"\nafter" of       NeedInclude _ k ->         case k (Just "inside") of           Done result -> do-            let out = T.lines (resultOutput result)-                hasIncludePragma = any (T.isSuffixOf "nested.inc\"") out+            let out = C.lines (resultOutput result)+                hasIncludePragma = any (C.isSuffixOf "nested.inc\"") out             if hasIncludePragma && "#line 3 \"root.hs\"" `elem` out               then pure ()               else assertFailure "expected include line pragmas in output"@@ -100,7 +250,7 @@ functionMacroArgumentTest :: TestTree functionMacroArgumentTest =   testCase "function-like macro keeps nested argument text" $-    case preprocess defaultConfig (TE.encodeUtf8 "#define PAIR(x,y) x + y\nPAIR((1 + 2), 3)") of+    case preprocess defaultConfig "#define PAIR(x,y) x + y\nPAIR((1 + 2), 3)" of       Done result ->         resultOutput result @?= "#line 1 \"<input>\"\n\n(1 + 2) + 3\n"       _ -> assertFailure "expected Done"@@ -108,7 +258,7 @@ functionMacroUnclosedCallTest :: TestTree functionMacroUnclosedCallTest =   testCase "unterminated function-like call does not expand macro" $-    case preprocess defaultConfig (TE.encodeUtf8 "#define ID() replaced\nID(") of+    case preprocess defaultConfig "#define ID() replaced\nID(" of       Done result ->         resultOutput result @?= "#line 1 \"<input>\"\n\nID(\n"       _ -> assertFailure "expected Done"@@ -116,9 +266,9 @@ definedConditionSpacingTest :: TestTree definedConditionSpacingTest =   testCase "defined handles whitespace around parenthesized name" $-    case preprocess defaultConfig (TE.encodeUtf8 "#define FLAG 1\n#if defined   ( FLAG )\nok\n#else\nbad\n#endif") of+    case preprocess defaultConfig "#define FLAG 1\n#if defined   ( FLAG )\nok\n#else\nbad\n#endif" of       Done result ->-        if "ok\n" `T.isInfixOf` resultOutput result && not ("bad\n" `T.isInfixOf` resultOutput result)+        if "ok\n" `C.isInfixOf` resultOutput result && not ("bad\n" `C.isInfixOf` resultOutput result)           then pure ()           else assertFailure ("expected ok branch to be active, output was: " <> show (resultOutput result))       _ -> assertFailure "expected Done"@@ -130,32 +280,32 @@     [ testCase "ordinary string accepts GCC double-backslash continuation" $         assertPreprocessOutput           gccStringContinuationInput-          (T.unlines ["#line 1 \"<input>\"", "x = \"a\\       \\b\""]),+          (C.unlines ["#line 1 \"<input>\"", "x = \"a\\       \\b\""]),       testCase "ordinary string preserves GHC single-backslash gap" $         assertPreprocessOutput           ghcStringGapInput-          (T.unlines ["#line 1 \"<input>\"", "x = \"a\\", "       \\b\""]),+          (C.unlines ["#line 1 \"<input>\"", "x = \"a\\", "       \\b\""]),       testCase "function macro argument accepts GCC double-backslash continuation" $         assertPreprocessOutput           gccStringContinuationMacroInput-          (T.unlines ["#line 1 \"<input>\"", "", "x = \"a\\       \\b\""]),+          (C.unlines ["#line 1 \"<input>\"", "", "x = \"a\\       \\b\""]),       testCase "function macro argument preserves GHC single-backslash gap" $         assertPreprocessOutput           ghcStringGapMacroInput-          (T.unlines ["#line 1 \"<input>\"", "", "x = \"a\\", "       \\b\""]),+          (C.unlines ["#line 1 \"<input>\"", "", "x = \"a\\", "       \\b\""]),       testCase "GCC continuation tracks string gaps across concatenation" $         assertPreprocessOutput           gccStringContinuationConcatInput-          (T.unlines ["#line 1 \"<input>\"", "x = \"a\\       \\\" <> y <> \"\\n\\       \\b\""]),+          (C.unlines ["#line 1 \"<input>\"", "x = \"a\\       \\\" <> y <> \"\\n\\       \\b\""]),       testCase "double backslash outside strings is not line-spliced" $         assertPreprocessOutput           nonStringDoubleBackslashInput-          (T.unlines ["#line 1 \"<input>\"", "", "x = foo \\\\", "       bar"])+          (C.unlines ["#line 1 \"<input>\"", "", "x = foo \\\\", "       bar"])     ] -assertPreprocessOutput :: T.Text -> T.Text -> Assertion+assertPreprocessOutput :: BS.ByteString -> BS.ByteString -> Assertion assertPreprocessOutput input expected =-  case preprocess defaultConfig (TE.encodeUtf8 input) of+  case preprocess defaultConfig input of     Done result -> resultOutput result @?= expected     _ -> assertFailure "expected Done" @@ -164,35 +314,35 @@   testGroup     "token pasting"     [ testCase "CCALL macro expands stringizing and token pasting" $-        case preprocess defaultConfig (TE.encodeUtf8 ccallMacroInput) of+        case preprocess defaultConfig ccallMacroInput of           Done result ->-            if "foreign import ccall unsafe \"foo\"" `T.isInfixOf` resultOutput result-              && "c_foo :: Int -> IO Int" `T.isInfixOf` resultOutput result+            if "foreign import ccall unsafe \"foo\"" `C.isInfixOf` resultOutput result+              && "c_foo :: Int -> IO Int" `C.isInfixOf` resultOutput result               then pure ()               else assertFailure ("expected CCALL expansion in output, got: " <> show (resultOutput result))           _ -> assertFailure "expected Done",       testCase "token pasting joins both sides without expanding arguments first" $-        case preprocess defaultConfig (TE.encodeUtf8 tokenPasteRawArgInput) of+        case preprocess defaultConfig tokenPasteRawArgInput of           Done result ->             resultOutput result @?= "#line 1 \"<input>\"\n\n\nXY\n"           _ -> assertFailure "expected Done",       testCase "token pasting result is rescanned for further macro expansion" $-        case preprocess defaultConfig (TE.encodeUtf8 tokenPasteRescanInput) of+        case preprocess defaultConfig tokenPasteRescanInput of           Done result ->             resultOutput result @?= "#line 1 \"<input>\"\n\n\n42\n"           _ -> assertFailure "expected Done",       testCase "token pasting supports prefix and suffix forms" $-        case preprocess defaultConfig (TE.encodeUtf8 tokenPasteAffixInput) of+        case preprocess defaultConfig tokenPasteAffixInput of           Done result ->             resultOutput result @?= "#line 1 \"<input>\"\n\n\nleft right\n"           _ -> assertFailure "expected Done",       testCase "token pasting supports chained concatenation" $-        case preprocess defaultConfig (TE.encodeUtf8 tokenPasteChainedInput) of+        case preprocess defaultConfig tokenPasteChainedInput of           Done result ->             resultOutput result @?= "#line 1 \"<input>\"\n\nfoobar\n"           _ -> assertFailure "expected Done",       testCase "token pasting survives Haskell block comments in arguments" $-        case preprocess defaultConfig (TE.encodeUtf8 tokenPasteHsCommentInput) of+        case preprocess defaultConfig tokenPasteHsCommentInput of           Done result ->             resultOutput result               @?= "#line 1 \"<input>\"\n\n{-# INLINE _bar #-}; _bar :: LensP Foo Baz{-comment-}; _bar = lens bar $ \\ Foo {..} bar_ -> Foo {bar = bar_, ..}\n"@@ -202,11 +352,11 @@ ccallLineCommentTest :: TestTree ccallLineCommentTest =   testCase "CCALL macro with -- comment in argument list" $-    case preprocess defaultConfig (TE.encodeUtf8 ccallLineCommentInput) of+    case preprocess defaultConfig ccallLineCommentInput of       Done result ->-        if "foreign import ccall unsafe \"xls_wb_sheetcount\"" `T.isInfixOf` resultOutput result-          && "c_xls_wb_sheetcount :: XLSWorkbook -> IO CInt" `T.isInfixOf` resultOutput result-          && " -- Int32" `T.isInfixOf` resultOutput result+        if "foreign import ccall unsafe \"xls_wb_sheetcount\"" `C.isInfixOf` resultOutput result+          && "c_xls_wb_sheetcount :: XLSWorkbook -> IO CInt" `C.isInfixOf` resultOutput result+          && " -- Int32" `C.isInfixOf` resultOutput result           then pure ()           else assertFailure ("expected CCALL expansion with line comment, got: " <> show (resultOutput result))       _ -> assertFailure "expected Done"@@ -214,20 +364,98 @@ pragmaOnceTest :: TestTree pragmaOnceTest =   testCase "#pragma once skips repeated includes" $-    case preprocess defaultConfig {configInputFile = "root.hs"} (TE.encodeUtf8 "#include \"guarded.inc\"\n#include \"guarded.inc\"\nafter") of+    case preprocess defaultConfig {configInputFile = "root.hs"} "#include \"guarded.inc\"\n#include \"guarded.inc\"\nafter" of       NeedInclude _ k1 ->         case k1 (Just "#pragma once\ninside") of           NeedInclude {} -> assertFailure "second include should be skipped"           Done result -> do             let output = resultOutput result-            if T.count "inside" output == 1 && "after\n" `T.isSuffixOf` output+            if countSubstring "inside" output == 1 && "after\n" `C.isSuffixOf` output               then pure ()               else assertFailure ("expected guarded include once, got: " <> show output)       Done _ -> assertFailure "expected include continuation step" -ccallLineCommentInput :: T.Text+-- | Rescanning of macro expansion results, and the C standard's+-- non-recursive-expansion ("blue paint") rule that keeps it terminating.+--+-- These live here rather than in the progress corpus because cpphs, the+-- oracle that corpus compares against, has no recursion guard and diverges+-- on every self-referential case below.+macroRescanTests :: TestTree+macroRescanTests =+  testGroup+    "macro rescanning"+    [ testCase "self-referential object macro expands exactly once" $+        assertPreprocessOutput+          (C.unlines ["#define SELF SELF", "e = SELF"])+          (C.unlines ["#line 1 \"<input>\"", "", "e = SELF"]),+      testCase "self-referential object macro keeps surrounding tokens" $+        assertPreprocessOutput+          (C.unlines ["#define SELF x + SELF", "e = SELF"])+          (C.unlines ["#line 1 \"<input>\"", "", "e = x + SELF"]),+      testCase "self-referential function macro expands exactly once" $+        assertPreprocessOutput+          (C.unlines ["#define REC(x) REC(x)", "f = REC(1)"])+          (C.unlines ["#line 1 \"<input>\"", "", "f = REC(1)"]),+      testCase "mutually recursive function macros terminate" $+        assertPreprocessOutput+          (C.unlines ["#define PING(x) PONG(x)", "#define PONG(x) PING(x)", "g = PING(2)"])+          (C.unlines ["#line 1 \"<input>\"", "", "", "g = PING(2)"]),+      testCase "paint does not leak into a nested call of the same macro" $+        assertPreprocessOutput+          (C.unlines ["#define F(x) (x)", "#define G(x) F(x) + F(F(x))", "h = G(3)"])+          (C.unlines ["#line 1 \"<input>\"", "", "", "h = (3) + ((3))"])+    ]++pragmaInsideBlockCommentTests :: TestTree+pragmaInsideBlockCommentTests =+  testGroup+    "pragma inside a Haskell block comment"+    [ testCase "#-} does not close the enclosing comment" $+        case preprocess defaultConfig pragmaInBlockCommentInput of+          Done result -> do+            resultDiagnostics result @?= []+            resultOutput result+              @?= "#line 1 \"<input>\"\n{-\n#if 0\n{-# INLINABLE foo #-}\n#endif\n-}\nlive\n"+          _ -> assertFailure "expected Done",+      testCase "commented-out #if 0 does not delete live code" $+        case preprocess defaultConfig pragmaInBlockCommentElseInput of+          Done result -> do+            resultDiagnostics result @?= []+            if "kept" `C.isInfixOf` resultOutput result+              then pure ()+              else assertFailure ("expected commented-out branch to stay intact, got: " <> show (resultOutput result))+          _ -> assertFailure "expected Done"+    ]++pragmaInBlockCommentInput :: BS.ByteString+pragmaInBlockCommentInput =+  C.unlines+    [ "{-",+      "#if 0",+      "{-# INLINABLE foo #-}",+      "#endif",+      "-}",+      "live"+    ]++pragmaInBlockCommentElseInput :: BS.ByteString+pragmaInBlockCommentElseInput =+  C.unlines+    [ "{-",+      "{-# INLINABLE foo #-}",+      "#if 0",+      "kept",+      "#else",+      "also kept",+      "#endif",+      "-}",+      "live"+    ]++ccallLineCommentInput :: BS.ByteString ccallLineCommentInput =-  T.unlines+  C.unlines     [ "#define CCALL(name,signature) \\",       "foreign import ccall unsafe #name \\",       "    c_##name :: signature",@@ -235,9 +463,9 @@       "CCALL(xls_wb_sheetcount, XLSWorkbook -> IO CInt -- Int32)"     ] -ccallMacroInput :: T.Text+ccallMacroInput :: BS.ByteString ccallMacroInput =-  T.unlines+  C.unlines     [ "#define CCALL(name,signature) \\",       "foreign import ccall unsafe #name \\",       "    c_##name :: signature",@@ -245,85 +473,85 @@       "CCALL(foo, Int -> IO Int)"     ] -tokenPasteRawArgInput :: T.Text+tokenPasteRawArgInput :: BS.ByteString tokenPasteRawArgInput =-  T.unlines+  C.unlines     [ "#define X Y",       "#define JOIN(a,b) a##b",       "JOIN(X,Y)"     ] -tokenPasteRescanInput :: T.Text+tokenPasteRescanInput :: BS.ByteString tokenPasteRescanInput =-  T.unlines+  C.unlines     [ "#define VALUE 42",       "#define JOIN(a,b) a##b",       "JOIN(VAL,UE)"     ] -tokenPasteAffixInput :: T.Text+tokenPasteAffixInput :: BS.ByteString tokenPasteAffixInput =-  T.unlines+  C.unlines     [ "#define PREFIX(name) left##name",       "#define SUFFIX(name) name##right",       "PREFIX() SUFFIX()"     ] -tokenPasteChainedInput :: T.Text+tokenPasteChainedInput :: BS.ByteString tokenPasteChainedInput =-  T.unlines+  C.unlines     [ "#define CHAIN(a,b,c) a##b##c",       "CHAIN(foo,bar,)"     ] -tokenPasteHsCommentInput :: T.Text+tokenPasteHsCommentInput :: BS.ByteString tokenPasteHsCommentInput =-  T.unlines+  C.unlines     [ "#define LENS(S,F,A) {-# INLINE _/**/F #-}; _/**/F :: LensP S A; _/**/F = lens F $ \\ S {..} F/**/_ -> S {F = F/**/_, ..}",       "LENS(Foo,bar,Baz{-comment-})"     ] -gccStringContinuationInput :: T.Text+gccStringContinuationInput :: BS.ByteString gccStringContinuationInput =-  T.unlines+  C.unlines     [ "x = \"a\\\\",       "       \\b\""     ] -ghcStringGapInput :: T.Text+ghcStringGapInput :: BS.ByteString ghcStringGapInput =-  T.unlines+  C.unlines     [ "x = \"a\\",       "       \\b\""     ] -gccStringContinuationMacroInput :: T.Text+gccStringContinuationMacroInput :: BS.ByteString gccStringContinuationMacroInput =-  T.unlines+  C.unlines     [ "#define ID(x) x",       "x = ID(\"a\\\\",       "       \\b\")"     ] -ghcStringGapMacroInput :: T.Text+ghcStringGapMacroInput :: BS.ByteString ghcStringGapMacroInput =-  T.unlines+  C.unlines     [ "#define ID(x) x",       "x = ID(\"a\\",       "       \\b\")"     ] -gccStringContinuationConcatInput :: T.Text+gccStringContinuationConcatInput :: BS.ByteString gccStringContinuationConcatInput =-  T.unlines+  C.unlines     [ "x = \"a\\\\",       "       \\\" <> y <> \"\\n\\\\",       "       \\b\""     ] -nonStringDoubleBackslashInput :: T.Text+nonStringDoubleBackslashInput :: BS.ByteString nonStringDoubleBackslashInput =-  T.unlines+  C.unlines     [ "#define ID(x) x",       "x = ID(foo \\\\",       "       bar)"
+ test/Test/Fixtures/progress/function-macro-arg-rescan.hs view
@@ -0,0 +1,5 @@+#define ID(x) x+#define TWICE(x) ID(x) + ID(x)+#define SUM(a,b) (a + b)+c = ID(ID(7))+d = TWICE(SUM(1, 2))
+ test/Test/Fixtures/progress/macro-rescan-repeated-name.hs view
@@ -0,0 +1,3 @@+#define F(x) (x)+#define G(x) F(x) + F(F(x))+h = G(3)
test/Test/Fixtures/progress/manifest.tsv view
@@ -45,3 +45,10 @@ thyme-lens-comment-paste	macro	thyme-lens-comment-paste.hs	pass macro-c-block-comment-literal	macro	macro-c-block-comment-literal.hs	pass function-macro-c-block-comment	macro	function-macro-c-block-comment.hs	pass+nested-function-macro-rescan	macro	nested-function-macro-rescan.hs	pass+function-macro-arg-rescan	macro	function-macro-arg-rescan.hs	pass+macro-rescan-repeated-name	macro	macro-rescan-repeated-name.hs	pass+# Encoding-agnostic passthrough is covered by the "source encoding" tests in+# test/Spec.hs, not by a fixture here: the cpphs oracle reads its input as+# locale-decoded String and aborts on bytes it cannot decode, so it cannot+# act as an oracle for a non-UTF-8 fixture.
+ test/Test/Fixtures/progress/nested-function-macro-rescan.hs view
@@ -0,0 +1,5 @@+#define WRAP(x) (x)+#define INNER WRAP(1)+#define OUTER WRAP(INNER)+a = INNER+b = OUTER
test/Test/Progress.hs view
@@ -17,6 +17,8 @@ import Data.Maybe (fromMaybe) import Data.Text (Text) import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import qualified Data.Text.Encoding.Error as TEE import qualified Data.Text.IO as TIO import GHC.IO.Handle (hDuplicate, hDuplicateTo) import Language.Preprocessor.Cpphs (BoolOptions (..), CpphsOptions (..), defaultCpphsOptions, runCpphs)@@ -170,7 +172,9 @@   result <- drive (preprocess defaultConfig {configInputFile = sourcePath} source)   let errors = [diagMessage d | d <- resultDiagnostics result, diagSeverity d == Error]   case errors of-    [] -> pure (Right (resultOutput result))+    -- The oracle is 'String'-based, so compare decoded text. Fixtures are+    -- all ASCII; byte-exactness of the output is covered in Spec.hs.+    [] -> pure (Right (TE.decodeUtf8With TEE.lenientDecode (resultOutput result)))     (msg : _) -> pure (Left (T.unpack msg))   where     drive (Done result) = pure result