packages feed

smirk-0.1.0.0: src/Smirk/Core/Quote.hs

{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TemplateHaskellQuotes #-}

{- |
Module      : Smirk.Core.Quote
Description : Compile-time QuasiQuoters for PCRE expressions and search-and-replace editors
Copyright   : (c) Nadia Yvette Chambers, 2026
License     : Apache-2.0

Provides Template Haskell QuasiQuoters that validate regular expressions and
stream-editor replacement templates at compile time, eliminating runtime parse
failures and overhead.

=== Available QuasiQuoters

* '[re|...|]': Compile-time validated PCRE regular expressions.
* '[ed|search///replace|]': Compile-time validated sed-style stream editor.
-}
module Smirk.Core.Quote
  ( -- * QuasiQuoters
    re
  , ed

    -- * Editor Definition & Application
  , Editor (..)
  , applyEditor
  , replaceMatch
  ) where

import Data.Char (isDigit)
import qualified Data.Map.Strict as Map
import Data.Text (Text)
import qualified Data.Text as T
import GHC.Generics (Generic)
import Language.Haskell.TH
import Language.Haskell.TH.Quote

import Smirk.Core.Bytecode (Regex)
import Smirk.Core.Compiler (compileToRegex)
import Smirk.Core.Parser (parsePCRE)
import Smirk.Engine.Sequence (RegexTarget (..))
import Smirk.Engine.VM (MatchResult (..), findAllMatches)

-- | QuasiQuoter for compile-time verified PCRE regular expressions.
-- Rejects invalid PCRE patterns at compile time with descriptive syntax errors.
--
-- === Example
--
-- @
-- let r = [re|(?<protocol>https?)://(?<domain>[a-z.]+)|]
-- @
re :: QuasiQuoter
re = QuasiQuoter
  { quoteExp  = quoteRegexExp
  , quotePat  = error "Smirk.re: Pattern matching quasiquote not supported"
  , quoteType = error "Smirk.re: Type quasiquote not supported"
  , quoteDec  = error "Smirk.re: Declaration quasiquote not supported"
  }

-- | Compile-time expression quoter for regex patterns.
quoteRegexExp :: String -> Q Exp
quoteRegexExp patternStr =
  case parsePCRE (T.pack patternStr) of
    Left err -> fail ("Smirk regex compile error: " <> T.unpack err)
    Right _  ->
      [| case parsePCRE (T.pack $(stringE patternStr)) of
           Right ast -> compileToRegex (T.pack $(stringE patternStr)) ast
           Left e    -> error ("Smirk regex internal error: " <> T.unpack e)
       |]

-- | Structured find-and-replace editor record.
data Editor s = Editor
  { editRegex       :: !Regex   -- ^ Compiled search regular expression
  , editReplacement :: !Text    -- ^ Replacement template string
  , editGlobal      :: !Bool    -- ^ If True, replace all non-overlapping occurrences; if False, first only
  } deriving (Eq, Show, Generic)

-- | QuasiQuoter for compile-time verified search-and-replace editors using @///@ delimiters.
--
-- Format: @[ed|search_pattern///replacement_string|]@ or @[ed|search///replace///1|]@ (for single replacement).
--
-- === Example
--
-- @
-- let edScript = [ed|foo([0-9]+)///bar${1}|]
-- applyEditor edScript "foo42" == "bar42"
-- @
ed :: QuasiQuoter
ed = QuasiQuoter
  { quoteExp  = quoteEditorExp
  , quotePat  = error "Smirk.ed: Pattern matching quasiquote not supported"
  , quoteType = error "Smirk.ed: Type quasiquote not supported"
  , quoteDec  = error "Smirk.ed: Declaration quasiquote not supported"
  }

-- | Compile-time expression quoter for search-and-replace editor templates.
quoteEditorExp :: String -> Q Exp
quoteEditorExp rawStr =
  case T.splitOn "///" (T.pack rawStr) of
    [searchPat, replaceStr] -> validateAndBuild searchPat replaceStr True
    [searchPat, replaceStr, opts] ->
      let isGlobal = not ("1" `T.isInfixOf` opts)
      in validateAndBuild searchPat replaceStr isGlobal
    _ ->
      fail "Smirk.ed: Expected format [ed|search_pattern///replacement_template|]"
  where
    validateAndBuild searchPat replaceStr isGlobal =
      case parsePCRE searchPat of
        Left err -> fail ("Smirk editor search regex error: " <> T.unpack err)
        Right _  ->
          [| Editor
               { editRegex = case parsePCRE (T.pack $(stringE (T.unpack searchPat))) of
                   Right ast -> compileToRegex (T.pack $(stringE (T.unpack searchPat))) ast
                   Left e    -> error ("Smirk editor search regex error: " <> T.unpack e)
               , editReplacement = T.pack $(stringE (T.unpack replaceStr))
               , editGlobal = $([| isGlobal |])
               }
           |]

-- | Apply an 't:Editor' transformation to a target sequence.
-- Finds matching slices and substitutes them according to the replacement template.
applyEditor :: RegexTarget s => Editor s -> s -> s
applyEditor editor target =
  let matches = findAllMatches editor.editRegex target
      selectedMatches = if editor.editGlobal then matches else take 1 matches
  in if null selectedMatches
       then target
       else
         let pieces = reconstructSlices target 0 selectedMatches editor.editReplacement
         in seqFromText (T.concat pieces)

-- | Reconstruct a sequence by interleaving untouched slices with substituted replacements.
reconstructSlices :: RegexTarget s => s -> Int -> [MatchResult s] -> Text -> [Text]
reconstructSlices target curIdx [] _ =
  let remLen = seqLength target - curIdx
  in [seqToText (seqSlice curIdx remLen target)]
reconstructSlices target curIdx (m : ms) replTemplate =
  let prefixLen = m.matchOffset - curIdx
      prefix = seqToText (seqSlice curIdx prefixLen target)
      replaced = replaceMatch m replTemplate
      nextIdx = m.matchOffset + m.matchLength
  in prefix : replaced : reconstructSlices target nextIdx ms replTemplate

-- | Expand capture group variables (${1}, ${name}, \1, $0, $&) in a replacement template.
replaceMatch :: RegexTarget s => MatchResult s -> Text -> Text
replaceMatch res = go
  where
    go txt
      | T.null txt = ""
      | "${" `T.isPrefixOf` txt =
          let rest = T.drop 2 txt
              (varName, after) = T.breakOn "}" rest
              varVal = lookupVar (T.unpack varName)
          in if "}" `T.isPrefixOf` after
               then varVal <> go (T.drop 1 after)
               else "${" <> go rest
      | "\\" `T.isPrefixOf` txt && T.length txt >= 2 =
          let digitChar = T.index txt 1
          in if isDigit digitChar
               then lookupVar [digitChar] <> go (T.drop 2 txt)
               else "\\" <> go (T.drop 1 txt)
      | "$&" `T.isPrefixOf` txt || "$0" `T.isPrefixOf` txt =
          seqToText res.matchedSlice <> go (T.drop 2 txt)
      | otherwise =
          let (chunk, rest) = T.break (\c -> c == '$' || c == '\\') txt
          in if T.null chunk
               then T.take 1 txt <> go (T.drop 1 txt)
               else chunk <> go rest

    lookupVar str = case reads str :: [(Int, String)] of
      [(idx, "")] -> maybe "" seqToText (Map.lookup idx res.captureGroups)
      _           -> maybe "" seqToText (Map.lookup (T.pack str) res.namedGroups)