language-smtlib-0.1.0.0: src/Language/SMTLIB/Parser/SExpr.hs
-- | The incremental S-expression framer: the low-level primitive that decides
-- where one complete top-level S-expression ends.
--
-- It scans 'Text' left-to-right tracking parenthesis depth, string literals,
-- @|...|@ quoted symbols and @;@ line comments, and classifies the input as one
-- of three outcomes ('Result'):
--
-- * 'Done' — a complete frame, with the unconsumed remainder of the chunk.
-- * 'Partial' — the input ends in the middle of a frame; feed more to
-- continue (this is the \"needs more input\" signal a REPL uses to prompt
-- for a continuation line).
-- * 'Failed' — a lexical framing error.
--
-- It performs /minimal reads/: it returns 'Done' the instant a frame closes and
-- hands back the remainder, so a pipe driver never consumes past one command.
-- It only asks for more input at genuine ambiguities (an open list, an open
-- string\/quoted symbol, or a trailing @\"@ that might begin a @\"\"@ escape).
--
-- The framer is deliberately permissive about /syntax/: it accepts any
-- balanced byte sequence and leaves rich syntactic error reporting to the
-- megaparsec layer that re-parses the framed text.
module Language.SMTLIB.Parser.SExpr
( Result(..)
, FrameError(..)
, feed
, isCleanEnd
, frameSExpr
) where
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Lazy as TL
import Data.Text.Lazy.Builder (Builder)
import qualified Data.Text.Lazy.Builder as B
-- | The outcome of framing. @a@ is the framed payload (raw 'Text' for
-- 'frameSExpr').
data Result a
= Done a !Text -- ^ framed value and the unconsumed remainder
| Partial (Text -> Result a) -- ^ feed the next chunk; feed @""@ to signal EOF
| Failed FrameError !Text -- ^ framing error and the remaining input
-- | Map over the framed payload, threading through 'Partial' continuations.
-- This is how the framed 'Text' of a 'Done' is handed to the megaparsec layer.
instance Functor Result where
fmap f (Done a t) = Done (f a) t
fmap f (Partial k) = Partial (fmap f . k)
fmap _ (Failed e t) = Failed e t
-- | A framing-level error. 'EndOfInput' is benign: it reports that the stream
-- ended cleanly at a frame boundary with no partial frame in progress.
data FrameError
= UnterminatedString -- ^ EOF inside a @"..."@ literal
| UnterminatedQuotedSymbol -- ^ EOF inside a @|...|@ symbol
| UnterminatedList -- ^ EOF inside a @(...)@ with unmatched @(@
| UnexpectedCloseParen -- ^ a @)@ with no matching @(@
| EndOfInput -- ^ stream ended cleanly between frames (not an error)
deriving (Eq, Show)
-- | Apply the next input chunk to a pending 'Partial'. A non-'Partial' result
-- is returned unchanged. Feed @""@ to signal end of input.
feed :: Result a -> Text -> Result a
feed (Partial k) t = k t
feed r _ = r
-- | Whether a result is the benign end-of-stream marker (@'Failed' 'EndOfInput'@).
isCleanEnd :: Result a -> Bool
isCleanEnd (Failed EndOfInput _) = True
isCleanEnd _ = False
-- | Frame exactly one top-level S-expression from the given input, returning
-- the raw 'Text' of the frame (verbatim, including any interior comments and
-- whitespace) on success.
frameSExpr :: Text -> Result Text
frameSExpr = skip False
-- The accumulator holds the frame characters consumed so far; leading layout
-- (whitespace and comments before the frame) is never accumulated.
-- | Whitespace recognised between tokens.
isWs :: Char -> Bool
isWs c = c == ' ' || c == '\t' || c == '\n' || c == '\r'
-- | Characters that terminate a bare top-level atom.
isDelim :: Char -> Bool
isDelim c = isWs c || c == '(' || c == ')' || c == '"' || c == '|' || c == ';'
toText :: Builder -> Text
toText = TL.toStrict . B.toLazyText
done :: Builder -> Text -> Result Text
done acc rest = Done (toText acc) rest
-- | Inner lexical mode while scanning the body of a list.
data Inner = INormal | IComment | IQuo | IStr | IStrQuote
-- | Skip leading whitespace and comments, then dispatch on the first
-- significant character. The 'Bool' records whether we are currently inside a
-- leading line comment.
skip :: Bool -> Text -> Result Text
skip inComment t = case T.uncons t of
Nothing -> Partial $ \more ->
if T.null more then Failed EndOfInput T.empty else skip inComment more
Just (c, rest)
| inComment -> skip (not (c == '\n' || c == '\r')) rest
| c == ';' -> skip True rest
| isWs c -> skip False rest
| c == '(' -> inList INormal 1 (B.singleton '(') rest
| c == ')' -> Failed UnexpectedCloseParen t
| c == '"' -> inStr (B.singleton '"') rest
| c == '|' -> inQuo (B.singleton '|') rest
| otherwise -> inSimple (B.singleton c) rest
-- | A bare top-level atom, ending at the first delimiter or at EOF.
inSimple :: Builder -> Text -> Result Text
inSimple acc t = case T.uncons t of
Nothing -> Partial $ \more ->
if T.null more then done acc T.empty else inSimple acc more
Just (c, _)
| isDelim c -> done acc t -- delimiter not consumed
Just (c, rest) -> inSimple (acc <> B.singleton c) rest
-- | A top-level @"..."@ string literal.
inStr :: Builder -> Text -> Result Text
inStr acc t = case T.uncons t of
Nothing -> Partial $ \more ->
if T.null more then Failed UnterminatedString T.empty else inStr acc more
Just (c, rest)
| c == '"' -> inStrQuote (acc <> B.singleton '"') rest
| otherwise -> inStr (acc <> B.singleton c) rest
-- | Just consumed a @"@ inside a top-level string; decide whether it closed the
-- string or begins a @""@ escape.
inStrQuote :: Builder -> Text -> Result Text
inStrQuote acc t = case T.uncons t of
Nothing -> Partial $ \more ->
if T.null more then done acc T.empty else inStrQuote acc more -- EOF: string closed
Just (c, rest)
| c == '"' -> inStr (acc <> B.singleton '"') rest -- "" escape
| otherwise -> done acc t -- closed; c is remainder
-- | A top-level @|...|@ quoted symbol.
inQuo :: Builder -> Text -> Result Text
inQuo acc t = case T.uncons t of
Nothing -> Partial $ \more ->
if T.null more then Failed UnterminatedQuotedSymbol T.empty else inQuo acc more
Just (c, rest)
| c == '|' -> done (acc <> B.singleton '|') rest
| otherwise -> inQuo (acc <> B.singleton c) rest
-- | Inside a list at the given depth (>= 1).
inList :: Inner -> Int -> Builder -> Text -> Result Text
inList inner depth acc t = case T.uncons t of
Nothing -> Partial $ \more ->
if T.null more then Failed UnterminatedList T.empty
else inList inner depth acc more
Just (c, rest) -> case inner of
INormal -> normalChar depth acc c rest
IComment
| c == '\n' || c == '\r' -> inList INormal depth (acc <> B.singleton c) rest
| otherwise -> inList IComment depth (acc <> B.singleton c) rest
IQuo
| c == '|' -> inList INormal depth (acc <> B.singleton c) rest
| otherwise -> inList IQuo depth (acc <> B.singleton c) rest
IStr
| c == '"' -> inList IStrQuote depth (acc <> B.singleton c) rest
| otherwise -> inList IStr depth (acc <> B.singleton c) rest
IStrQuote
| c == '"' -> inList IStr depth (acc <> B.singleton c) rest -- "" escape
| otherwise -> normalChar depth acc c rest -- string closed
-- | Process one character in normal (non-string\/comment) list context.
normalChar :: Int -> Builder -> Char -> Text -> Result Text
normalChar depth acc c rest =
let acc' = acc <> B.singleton c in
case c of
'(' -> inList INormal (depth + 1) acc' rest
')' | depth == 1 -> done acc' rest
| otherwise -> inList INormal (depth - 1) acc' rest
'"' -> inList IStr depth acc' rest
'|' -> inList IQuo depth acc' rest
';' -> inList IComment depth acc' rest
_ -> inList INormal depth acc' rest