packages feed

aihc-parser-1.0.0.2: src/Aihc/Parser/Lex/Quoted.hs

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

module Aihc.Parser.Lex.Quoted
  ( decodeStringBody,
    processMultilineString,
    readMaybeChar,
    scanMultilineString,
    scanQuoted,
  )
where

import Data.Char (chr, digitToInt, isDigit, isHexDigit, isSpace, ord)
import Data.List qualified as List
import Data.Maybe (mapMaybe)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Lazy qualified as TL
import Data.Text.Lazy.Builder qualified as TLB

-- | Scan a quoted string body (after the opening delimiter) until the
-- unescaped closing character.  Returns @Right (body, rest)@ on success or
-- @Left body@ if the input ends without a closing delimiter.
scanQuoted :: Char -> Text -> Either Text (Text, Text)
scanQuoted endCh input = go 0 input
  where
    go consumed rest =
      case T.findIndex (\c -> c == '\\' || c == endCh) rest of
        Nothing -> Left (T.take (consumed + T.length rest) input)
        Just i ->
          let consumed' = consumed + i
              special = T.drop i rest
           in if T.null special
                then Left (T.take consumed' input)
                else case T.head special of
                  c
                    | c == endCh -> Right (T.take consumed' input, T.drop 1 special)
                    | otherwise ->
                        case consumedEscapeTail (T.drop 1 special) of
                          Just tailLen -> go (consumed' + 1 + tailLen) (T.drop tailLen (T.drop 1 special))
                          Nothing -> Left (T.take (consumed' + 1) input)

-- | Scan a multiline string body (after the opening @\"\"\"@) until an
-- unescaped closing @\"\"\"@.  Returns @Right (body, rest)@ on success or
-- @Left body@ if the input ends without a closing delimiter.
scanMultilineString :: Text -> Either Text (Text, Text)
scanMultilineString input = go 0 input
  where
    go consumed rest =
      case T.findIndex (\c -> c == '\\' || c == '"') rest of
        Nothing -> Left (T.take (consumed + T.length rest) input)
        Just i ->
          let consumed' = consumed + i
              special = T.drop i rest
           in if T.null special
                then Left (T.take consumed' input)
                else case T.head special of
                  '\\' ->
                    case consumedEscapeTail (T.drop 1 special) of
                      Just tailLen -> go (consumed' + 1 + tailLen) (T.drop tailLen (T.drop 1 special))
                      Nothing -> Left (T.take (consumed' + 1) input)
                  '"' ->
                    if "\"\"\"" `T.isPrefixOf` special
                      then Right (T.take consumed' input, T.drop 3 special)
                      else
                        if "\"\"" `T.isPrefixOf` special
                          then go (consumed' + 2) (T.drop 2 special)
                          else go (consumed' + 1) (T.drop 1 special)
                  _ -> error "unreachable: findIndex only returns backslash or quote"

-- | Determine how much text after a backslash belongs to the current
-- escape-like sequence for delimiter scanning purposes.
--
-- We intentionally recognize string gaps here so a gap-closing backslash does
-- not incorrectly escape the following quote.
consumedEscapeTail :: Text -> Maybe Int
consumedEscapeTail rest =
  if T.null rest
    then Nothing
    else do
      (_, rest') <- parseEscape rest
      pure (T.length rest - T.length rest')

-- | Decode the body of a Haskell string literal (content between the quotes,
-- without the surrounding @\"@ characters) natively on 'Text', avoiding the
-- round-trip through 'String'.  Returns 'Nothing' if the body contains an
-- invalid escape sequence, in which case the caller should fall back to the
-- raw body.
decodeStringBody :: Text -> Maybe Text
decodeStringBody inp
  | not ('\\' `T.elem` inp) = Just inp -- fast path: no escapes, no allocation
  | otherwise = TL.toStrict . TLB.toLazyText <$> go mempty inp
  where
    go :: TLB.Builder -> Text -> Maybe TLB.Builder
    go !acc t =
      let (plain, rest) = T.break (== '\\') t
          acc' = acc <> TLB.fromText plain
       in case T.uncons rest of
            Nothing -> Just acc'
            Just ('\\', after) -> case parseEscape after of
              Nothing -> Nothing
              Just (mc, rest') ->
                go (maybe acc' (\c -> acc' <> TLB.singleton c) mc) rest'
            _ -> Just acc' -- unreachable: T.break stops at '\\'

isOctDigit :: Char -> Bool
isOctDigit c = c >= '0' && c <= '7'

parseEscape :: Text -> Maybe (Maybe Char, Text)
parseEscape t = case T.uncons t of
  Nothing -> Nothing
  Just (c, rest) -> case c of
    'a' -> Just (Just '\a', rest)
    'b' -> Just (Just '\b', rest)
    'f' -> Just (Just '\f', rest)
    'n' -> Just (Just '\n', rest)
    'r' -> Just (Just '\r', rest)
    't' -> Just (Just '\t', rest)
    'v' -> Just (Just '\v', rest)
    '\\' -> Just (Just '\\', rest)
    '"' -> Just (Just '"', rest)
    '\'' -> Just (Just '\'', rest)
    '&' -> Just (Nothing, rest) -- empty escape
    '^' -> case T.uncons rest of -- control character \^X
      Just (cc, rest')
        | cc >= '@' && cc <= '_' ->
            Just (Just (chr (ord cc - 64)), rest')
      _ -> Nothing
    'x' ->
      -- hex escape \xNN  (use Integer to prevent Int overflow on long inputs)
      let (digits, rest') = T.span isHexDigit rest
       in if T.null digits
            then Nothing
            else decodeNumericEscape 16 digits rest'
    'o' ->
      -- octal escape \oNN  (use Integer to prevent Int overflow on long inputs)
      let (digits, rest') = T.span isOctDigit rest
       in if T.null digits
            then Nothing
            else decodeNumericEscape 8 digits rest'
    _
      | isDigit c -> -- decimal escape \NNN  (use Integer to prevent Int overflow on long inputs)
          let (moreDigits, rest') = T.span isDigit rest
              digits = T.cons c moreDigits
           in decodeNumericEscape 10 digits rest'
      | isSpace c -> -- gap escape \ whitespace \
          let rest' = T.dropWhile isSpace rest
           in case T.uncons rest' of
                Just ('\\', rest'') -> Just (Nothing, rest'')
                _ -> Nothing
      | otherwise -> parseNamedEscape t
  where
    decodeNumericEscape :: Integer -> Text -> Text -> Maybe (Maybe Char, Text)
    decodeNumericEscape !base digits rest' =
      let n = T.foldl' (\a d -> a * base + toInteger (digitToInt d)) (0 :: Integer) digits
       in if n > 0x10FFFF then Nothing else Just (Just (chr (fromIntegral n)), rest')

parseNamedEscape :: Text -> Maybe (Maybe Char, Text)
parseNamedEscape t = foldr tryMatch Nothing namedEscapeTable
  where
    tryMatch (name, ch) fallback =
      case T.stripPrefix name t of
        Just rest -> Just (Just ch, rest)
        Nothing -> fallback

-- Named ASCII escape sequences per the Haskell 2010 report.
-- SOH must appear before SO so the longest prefix wins.
namedEscapeTable :: [(Text, Char)]
namedEscapeTable =
  [ ("NUL", '\NUL'),
    ("SOH", '\SOH'), -- must precede SO
    ("STX", '\STX'),
    ("ETX", '\ETX'),
    ("EOT", '\EOT'),
    ("ENQ", '\ENQ'),
    ("ACK", '\ACK'),
    ("BEL", '\BEL'),
    ("BS", '\BS'),
    ("HT", '\HT'),
    ("LF", '\LF'),
    ("VT", '\VT'),
    ("FF", '\FF'),
    ("CR", '\CR'),
    ("SO", '\SO'),
    ("SI", '\SI'),
    ("DLE", '\DLE'),
    ("DC1", '\DC1'),
    ("DC2", '\DC2'),
    ("DC3", '\DC3'),
    ("DC4", '\DC4'),
    ("NAK", '\NAK'),
    ("SYN", '\SYN'),
    ("ETB", '\ETB'),
    ("CAN", '\CAN'),
    ("EM", '\EM'),
    ("SUB", '\SUB'),
    ("ESC", '\ESC'),
    ("FS", '\FS'),
    ("GS", '\GS'),
    ("RS", '\RS'),
    ("US", '\US'),
    ("SP", '\SP'),
    ("DEL", '\DEL')
  ]

processMultilineString :: String -> String
processMultilineString =
  resolveEscapes
    . stripTrailingNewline
    . stripLeadingNewline
    . List.intercalate "\n"
    . map blankToEmpty
    . stripCommonIndent
    . map expandLeadingTabs
    . splitMultilineNewlines
    . collapseStringGaps

collapseStringGaps :: String -> String
collapseStringGaps [] = []
collapseStringGaps ('\\' : rest)
  | not (null ws), '\\' : rest'' <- rest' = collapseStringGaps rest''
  where
    (ws, rest') = span (\c -> c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f') rest
collapseStringGaps (c : rest) = c : collapseStringGaps rest

splitMultilineNewlines :: String -> [String]
splitMultilineNewlines = go []
  where
    go acc [] = [reverse acc]
    go acc ('\r' : '\n' : rest) = reverse acc : go [] rest
    go acc ('\r' : rest) = reverse acc : go [] rest
    go acc ('\n' : rest) = reverse acc : go [] rest
    go acc ('\f' : rest) = reverse acc : go [] rest
    go acc (c : rest) = go (c : acc) rest

expandLeadingTabs :: String -> String
expandLeadingTabs = go 0
  where
    go col ('\t' : rest) =
      let spaces = 8 - (col `mod` 8)
       in replicate spaces ' ' ++ go (col + spaces) rest
    go col (' ' : rest) = ' ' : go (col + 1) rest
    go _ rest = rest

stripCommonIndent :: [String] -> [String]
stripCommonIndent lns =
  case mapMaybe indentOf nonBlank of
    [] -> lns
    indents -> map (dropPrefix (minimum indents)) lns
  where
    nonBlank = filter (not . all isSpace) (drop 1 lns)
    indentOf s = Just (length (takeWhile isSpace s))
    dropPrefix = drop

blankToEmpty :: String -> String
blankToEmpty s
  | all isSpace s = ""
  | otherwise = s

stripLeadingNewline :: String -> String
stripLeadingNewline ('\n' : rest) = rest
stripLeadingNewline s = s

stripTrailingNewline :: String -> String
stripTrailingNewline s
  | not (null s) && last s == '\n' = init s
  | otherwise = s

resolveEscapes :: String -> String
resolveEscapes s =
  case reads ('"' : s ++ "\"") of
    [(str, "")] -> str
    _ -> s

readMaybeChar :: Text -> Maybe Char
readMaybeChar raw =
  case reads (T.unpack raw) of
    [(c, "")] -> Just c
    _ -> Nothing