packages feed

tala-syntax-0.1.0: src/Progmasoft/Tala/Diagnostic/Protocol.hs

-- SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
-- SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1

{- | Versioned, bounded binary representation of compiler diagnostics.

The wire model is separate from the frontend's current Error/Warning model
so native stages can also report informational messages and editor hints.
-}
module Progmasoft.Tala.Diagnostic.Protocol
    ( DiagnosticDocument (..)
    , DiagnosticRecord (..)
    , ProtocolSeverity (..)
    , DiagnosticArgument (..)
    , DiagnosticLocation (..)
    , DiagnosticRelatedLocation (..)
    , DiagnosticFix (..)
    , DiagnosticTextEdit (..)
    , DiagnosticProtocolLimits (..)
    , DiagnosticProtocolError (..)
    , defaultDiagnosticProtocolLimits
    , diagnosticDocument
    , encodeDiagnosticDocument
    , decodeDiagnosticDocument
    ) where

import Control.Monad (replicateM, unless, when)
import Data.Bits (shiftL, (.|.))
import Data.ByteString qualified as ByteString
import Data.ByteString.Builder qualified as Builder
import Data.ByteString.Lazy qualified as LazyByteString
import Data.Char (isAsciiUpper, isDigit, ord)
import Data.List (nub)
import Data.Word (Word16, Word32, Word8)
import Progmasoft.Tala.AST (SourcePosition (..), SourceSpan (..))
import Progmasoft.Tala.Diagnostic

protocolVersion :: Word16
protocolVersion = 1

magic :: [Word8]
magic = map (fromIntegral . ord) "TADG"

-- | Named text value used to format a diagnostic message.
data DiagnosticArgument = DiagnosticArgument
    { argumentName :: String
    , argumentValue :: String
    }
    deriving stock (Eq, Ord, Read, Show)

-- | Wire-level severity shared with native compiler and editor clients.
data ProtocolSeverity
    = ProtocolError
    | ProtocolWarning
    | ProtocolInformation
    | ProtocolHint
    deriving stock (Bounded, Enum, Eq, Ord, Read, Show)

{- | Source path and zero-based scalar coordinates for one diagnostic span.

Columns count Unicode scalar values; clients must not reinterpret them as
byte offsets or UTF-16 code-unit positions.
-}
data DiagnosticLocation = DiagnosticLocation
    { locationSource :: FilePath
    , locationStartLine :: Word32
    , locationStartColumn :: Word32
    , locationEndLine :: Word32
    , locationEndColumn :: Word32
    }
    deriving stock (Eq, Ord, Read, Show)

-- | Secondary location that explains a diagnostic's context.
data DiagnosticRelatedLocation = DiagnosticRelatedLocation
    { relatedLocation :: DiagnosticLocation
    , relatedMessage :: String
    }
    deriving stock (Eq, Ord, Read, Show)

-- | Replacement text for one source range in a code action.
data DiagnosticTextEdit = DiagnosticTextEdit
    { editLocation :: DiagnosticLocation
    , editReplacement :: String
    }
    deriving stock (Eq, Ord, Read, Show)

-- | Atomic editor action containing one or more source edits.
data DiagnosticFix = DiagnosticFix
    { fixTitle :: String
    , fixEdits :: [DiagnosticTextEdit]
    }
    deriving stock (Eq, Ord, Read, Show)

-- | One versioned diagnostic, including related spans and suggested fixes.
data DiagnosticRecord = DiagnosticRecord
    { recordStage :: DiagnosticStage
    , recordSeverity :: ProtocolSeverity
    , recordCode :: String
    , recordMessage :: String
    , recordArguments :: [DiagnosticArgument]
    , recordPrimary :: Maybe DiagnosticLocation
    , recordRelated :: [DiagnosticRelatedLocation]
    , recordFixes :: [DiagnosticFix]
    }
    deriving stock (Eq, Ord, Read, Show)

-- | Ordered diagnostic records returned for a compiler operation.
newtype DiagnosticDocument = DiagnosticDocument
    { documentRecords :: [DiagnosticRecord]
    }
    deriving stock (Eq, Ord, Read, Show)

-- | Resource bounds enforced when encoding and decoding untrusted documents.
data DiagnosticProtocolLimits = DiagnosticProtocolLimits
    { maximumWireBytes :: Int
    , maximumRecords :: Int
    , maximumTextScalars :: Int
    , maximumArguments :: Int
    , maximumRelatedLocations :: Int
    , maximumFixes :: Int
    , maximumEditsPerFix :: Int
    }
    deriving stock (Eq, Ord, Read, Show)

-- | Conservative default allocation and complexity limits for the protocol.
defaultDiagnosticProtocolLimits :: DiagnosticProtocolLimits
defaultDiagnosticProtocolLimits =
    DiagnosticProtocolLimits
        { maximumWireBytes = 16 * 1024 * 1024
        , maximumRecords = 65535
        , maximumTextScalars = 1024 * 1024
        , maximumArguments = 256
        , maximumRelatedLocations = 256
        , maximumFixes = 128
        , maximumEditsPerFix = 4096
        }

-- | Error offset, field path, and explanation for a protocol failure.
data DiagnosticProtocolError = DiagnosticProtocolError
    { protocolErrorOffset :: Int
    , protocolErrorContext :: String
    , protocolErrorMessage :: String
    }
    deriving stock (Eq, Ord, Read, Show)

-- | Convert compiler diagnostics to the richer wire-level document model.
diagnosticDocument :: [Diagnostic] -> Either DiagnosticProtocolError DiagnosticDocument
diagnosticDocument diagnostics = DiagnosticDocument <$> traverse convert diagnostics
    where
        convert value = do
            primary <- traverse sourceLocation (diagnosticSpan value)
            pure
                DiagnosticRecord
                    { recordStage = diagnosticStage value
                    , recordSeverity = protocolSeverity (diagnosticSeverity value)
                    , recordCode = diagnosticCode value
                    , recordMessage = diagnosticMessage value
                    , recordArguments = []
                    , recordPrimary = primary
                    , recordRelated = []
                    , recordFixes = []
                    }

sourceLocation :: SourceSpan -> Either DiagnosticProtocolError DiagnosticLocation
sourceLocation spanValue = do
    startLine <- sourceCoordinate "source start line" (sourceLine (sourceStart spanValue))
    startColumn <- sourceCoordinate "source start column" (sourceColumn (sourceStart spanValue))
    endLine <- sourceCoordinate "source end line" (sourceLine (sourceEnd spanValue))
    endColumn <- sourceCoordinate "source end column" (sourceColumn (sourceEnd spanValue))
    pure
        DiagnosticLocation
            { locationSource = sourceFile spanValue
            , locationStartLine = startLine
            , locationStartColumn = startColumn
            , locationEndLine = endLine
            , locationEndColumn = endColumn
            }
    where
        sourceCoordinate context value
            | value <= 0 = Left (modelError context "compiler source positions must be one-based")
            | toInteger value - 1 > toInteger (maxBound :: Word32) =
                Left (modelError context "compiler source position exceeds the protocol range")
            | otherwise = Right (fromIntegral (value - 1))

-- | Validate and encode a diagnostic document using the supplied limits.
encodeDiagnosticDocument ::
    DiagnosticProtocolLimits ->
    DiagnosticDocument ->
    Either DiagnosticProtocolError ByteString.ByteString
encodeDiagnosticDocument limits document = do
    validateDocument limits document
    encodedRecords <- traverse (encodeRecord limits) (documentRecords document)
    let payload =
            mconcat (map Builder.word8 magic)
                <> Builder.word16LE protocolVersion
                <> Builder.word16LE 0
                <> countBuilder (length (documentRecords document))
                <> mconcat encodedRecords
        bytes = LazyByteString.toStrict (Builder.toLazyByteString payload)
    if ByteString.length bytes > maximumWireBytes limits
        then Left (limitError "wire byte length" "diagnostic document exceeds configured byte limit")
        else Right bytes

encodeRecord :: DiagnosticProtocolLimits -> DiagnosticRecord -> Either DiagnosticProtocolError Builder.Builder
encodeRecord limits record = do
    arguments <- traverse encodeArgument (recordArguments record)
    primary <- traverse encodeLocation (recordPrimary record)
    related <- traverse encodeRelated (recordRelated record)
    fixes <- traverse (encodeFix limits) (recordFixes record)
    pure
        ( Builder.word8 (stageTag (recordStage record))
            <> Builder.word8 (severityTag (recordSeverity record))
            <> textBuilder (recordCode record)
            <> textBuilder (recordMessage record)
            <> countBuilder (length arguments)
            <> mconcat arguments
            <> Builder.word8 (maybe 0 (const 1) primary)
            <> maybe mempty id primary
            <> countBuilder (length related)
            <> mconcat related
            <> countBuilder (length fixes)
            <> mconcat fixes
        )

encodeArgument :: DiagnosticArgument -> Either DiagnosticProtocolError Builder.Builder
encodeArgument argument =
    pure (textBuilder (argumentName argument) <> textBuilder (argumentValue argument))

encodeLocation :: DiagnosticLocation -> Either DiagnosticProtocolError Builder.Builder
encodeLocation location =
    pure
        ( textBuilder (locationSource location)
            <> Builder.word32LE (locationStartLine location)
            <> Builder.word32LE (locationStartColumn location)
            <> Builder.word32LE (locationEndLine location)
            <> Builder.word32LE (locationEndColumn location)
        )

encodeRelated :: DiagnosticRelatedLocation -> Either DiagnosticProtocolError Builder.Builder
encodeRelated related = do
    location <- encodeLocation (relatedLocation related)
    pure (location <> textBuilder (relatedMessage related))

encodeFix :: DiagnosticProtocolLimits -> DiagnosticFix -> Either DiagnosticProtocolError Builder.Builder
encodeFix _ fix = do
    edits <- traverse encodeEdit (fixEdits fix)
    pure (textBuilder (fixTitle fix) <> countBuilder (length edits) <> mconcat edits)

encodeEdit :: DiagnosticTextEdit -> Either DiagnosticProtocolError Builder.Builder
encodeEdit edit = do
    location <- encodeLocation (editLocation edit)
    pure (location <> textBuilder (editReplacement edit))

textBuilder :: String -> Builder.Builder
textBuilder value =
    countBuilder (length value)
        <> mconcat [Builder.word32LE (fromIntegral (ord character)) | character <- value]

countBuilder :: Int -> Builder.Builder
countBuilder = Builder.word32LE . fromIntegral

stageTag :: DiagnosticStage -> Word8
stageTag = fromIntegral . fromEnum

severityTag :: ProtocolSeverity -> Word8
severityTag = fromIntegral . fromEnum

protocolSeverity :: DiagnosticSeverity -> ProtocolSeverity
protocolSeverity severity = case severity of
    Error -> ProtocolError
    Warning -> ProtocolWarning

validateDocument :: DiagnosticProtocolLimits -> DiagnosticDocument -> Either DiagnosticProtocolError ()
validateDocument limits document = do
    validateCount limits "diagnostic count" (maximumRecords limits) (length (documentRecords document))
    mapM_ (validateRecord limits) (documentRecords document)

validateRecord :: DiagnosticProtocolLimits -> DiagnosticRecord -> Either DiagnosticProtocolError ()
validateRecord limits record = do
    unless
        (validCode (recordCode record))
        (Left (modelError "diagnostic code" "diagnostic code must be 1-64 ASCII uppercase, digit, or hyphen scalars"))
    when
        (null (recordMessage record))
        (Left (modelError "diagnostic message" "diagnostic message is empty"))
    validateText limits "diagnostic code" (recordCode record)
    validateText limits "diagnostic message" (recordMessage record)
    validateCount limits "diagnostic argument count" (maximumArguments limits) (length (recordArguments record))
    let names = map argumentName (recordArguments record)
    when
        (any null names)
        (Left (modelError "diagnostic argument name" "diagnostic argument name is empty"))
    when
        (length names /= length (nub names))
        (Left (modelError "diagnostic argument name" "diagnostic argument names must be unique within one record"))
    mapM_ validateArgument (recordArguments record)
    mapM_ (validateLocation limits "primary location") (recordPrimary record)
    validateCount limits "related location count" (maximumRelatedLocations limits) (length (recordRelated record))
    mapM_ validateRelated (recordRelated record)
    validateCount limits "diagnostic fix count" (maximumFixes limits) (length (recordFixes record))
    mapM_ validateFix (recordFixes record)
    where
        validateArgument argument = do
            validateText limits "diagnostic argument name" (argumentName argument)
            validateText limits "diagnostic argument value" (argumentValue argument)
        validateRelated related = do
            validateLocation limits "related location" (relatedLocation related)
            when
                (null (relatedMessage related))
                (Left (modelError "related location message" "related location message is empty"))
            validateText limits "related location message" (relatedMessage related)
        validateFix fix = do
            when
                (null (fixTitle fix))
                (Left (modelError "diagnostic fix title" "diagnostic fix title is empty"))
            when
                (null (fixEdits fix))
                (Left (modelError "diagnostic fix edits" "diagnostic fix must contain at least one edit"))
            validateText limits "diagnostic fix title" (fixTitle fix)
            validateCount limits "diagnostic edit count" (maximumEditsPerFix limits) (length (fixEdits fix))
            mapM_ validateEdit (fixEdits fix)
        validateEdit edit = do
            validateLocation limits "diagnostic edit" (editLocation edit)
            validateText limits "diagnostic edit replacement" (editReplacement edit)

validateLocation :: DiagnosticProtocolLimits -> String -> DiagnosticLocation -> Either DiagnosticProtocolError ()
validateLocation limits context location = do
    when
        (null (locationSource location))
        (Left (modelError context "diagnostic source identity is empty"))
    validateText limits "diagnostic source" (locationSource location)
    unless
        ( positionPrecedes
            (locationStartLine location, locationStartColumn location)
            (locationEndLine location, locationEndColumn location)
        )
        (Left (modelError context "diagnostic range end precedes its start"))

positionPrecedes :: (Word32, Word32) -> (Word32, Word32) -> Bool
positionPrecedes (leftLine, leftColumn) (rightLine, rightColumn) =
    leftLine < rightLine || (leftLine == rightLine && leftColumn <= rightColumn)

validateText :: DiagnosticProtocolLimits -> String -> String -> Either DiagnosticProtocolError ()
validateText limits context value = do
    validateCount limits context (maximumTextScalars limits) (length value)
    unless
        (all isUnicodeScalar value)
        (Left (modelError context "text contains a non-scalar Unicode value"))

validateCount :: DiagnosticProtocolLimits -> String -> Int -> Int -> Either DiagnosticProtocolError ()
validateCount _ context maximumCount value
    | value < 0 || value > maximumCount || toInteger value > toInteger (maxBound :: Word32) =
        Left (limitError context "collection count exceeds configured limit")
    | otherwise = Right ()

validCode :: String -> Bool
validCode code =
    not (null code)
        && length code <= 64
        && all (\character -> isAsciiUpper character || isDigit character || character == '-') code

isUnicodeScalar :: Char -> Bool
isUnicodeScalar character =
    let value = ord character
     in value <= 0x10ffff && not (value >= 0xd800 && value <= 0xdfff)

modelError :: String -> String -> DiagnosticProtocolError
modelError = DiagnosticProtocolError 0

limitError :: String -> String -> DiagnosticProtocolError
limitError = DiagnosticProtocolError 0

data DecodeState = DecodeState
    { decodeBytes :: ByteString.ByteString
    , decodeOffset :: Int
    , decodeLimits :: DiagnosticProtocolLimits
    }

newtype Decoder value = Decoder
    { runDecoder :: DecodeState -> Either DiagnosticProtocolError (value, DecodeState)
    }

instance Functor Decoder where
    fmap transform parser = Decoder $ \state -> do
        (value, next) <- runDecoder parser state
        pure (transform value, next)

instance Applicative Decoder where
    pure value = Decoder (Right . (value,))
    function <*> argument = Decoder $ \state -> do
        (transform, afterFunction) <- runDecoder function state
        (value, afterArgument) <- runDecoder argument afterFunction
        pure (transform value, afterArgument)

instance Monad Decoder where
    parser >>= continuation = Decoder $ \state -> do
        (value, next) <- runDecoder parser state
        runDecoder (continuation value) next

-- | Decode one complete document, rejecting unsupported versions and trailing bytes.
decodeDiagnosticDocument ::
    DiagnosticProtocolLimits ->
    ByteString.ByteString ->
    Either DiagnosticProtocolError DiagnosticDocument
decodeDiagnosticDocument limits bytes
    | ByteString.length bytes > maximumWireBytes limits =
        Left (limitError "wire byte length" "diagnostic document exceeds configured byte limit")
    | otherwise = do
        (document, state) <- runDecoder decodeDocument (DecodeState bytes 0 limits)
        unless
            (decodeOffset state == ByteString.length bytes)
            (Left (DiagnosticProtocolError (decodeOffset state) "document" "bytes remain after diagnostic document"))
        validateDocument limits document
        pure document

decodeDocument :: Decoder DiagnosticDocument
decodeDocument = do
    observedMagic <- replicateM 4 (byte "magic")
    unlessDecoder (observedMagic == magic) "magic" "input is not a Tala diagnostic document"
    version <- word16 "version"
    unlessDecoder (version == protocolVersion) "version" "unsupported diagnostic protocol version"
    flags <- word16 "flags"
    unlessDecoder (flags == 0) "flags" "reserved diagnostic flags must be zero"
    count <- boundedCount "diagnostic count" maximumRecords
    DiagnosticDocument <$> replicateM count decodeRecord

decodeRecord :: Decoder DiagnosticRecord
decodeRecord = do
    stage <- decodeStage
    severity <- decodeSeverity
    code <- text "diagnostic code"
    message <- text "diagnostic message"
    argumentCount <- boundedCount "diagnostic argument count" maximumArguments
    arguments <-
        replicateM argumentCount (DiagnosticArgument <$> text "diagnostic argument name" <*> text "diagnostic argument value")
    hasPrimary <- boolean "primary location presence"
    primary <- if hasPrimary then Just <$> decodeLocation else pure Nothing
    relatedCount <- boundedCount "related location count" maximumRelatedLocations
    related <- replicateM relatedCount (DiagnosticRelatedLocation <$> decodeLocation <*> text "related location message")
    fixCount <- boundedCount "diagnostic fix count" maximumFixes
    fixes <- replicateM fixCount decodeFix
    pure (DiagnosticRecord stage severity code message arguments primary related fixes)

decodeLocation :: Decoder DiagnosticLocation
decodeLocation =
    DiagnosticLocation
        <$> text "diagnostic source"
        <*> word32 "diagnostic start line"
        <*> word32 "diagnostic start column"
        <*> word32 "diagnostic end line"
        <*> word32 "diagnostic end column"

decodeFix :: Decoder DiagnosticFix
decodeFix = do
    title <- text "diagnostic fix title"
    editCount <- boundedCount "diagnostic edit count" maximumEditsPerFix
    edits <- replicateM editCount (DiagnosticTextEdit <$> decodeLocation <*> text "diagnostic edit replacement")
    pure (DiagnosticFix title edits)

decodeStage :: Decoder DiagnosticStage
decodeStage = do
    tag <- byte "diagnostic stage"
    if fromIntegral tag <= fromEnum (maxBound :: DiagnosticStage)
        then pure (toEnum (fromIntegral tag))
        else failDecoder "diagnostic stage" "unknown diagnostic stage tag"

decodeSeverity :: Decoder ProtocolSeverity
decodeSeverity = do
    tag <- byte "diagnostic severity"
    if fromIntegral tag <= fromEnum (maxBound :: ProtocolSeverity)
        then pure (toEnum (fromIntegral tag))
        else failDecoder "diagnostic severity" "unknown diagnostic severity tag"

text :: String -> Decoder String
text context = do
    count <- boundedCount context maximumTextScalars
    values <- replicateM count (word32 context)
    if all validScalar values
        then pure (map (toEnum . fromIntegral) values)
        else failDecoder context "text contains a non-scalar Unicode value"
    where
        validScalar value = value <= 0x10ffff && not (value >= 0xd800 && value <= 0xdfff)

boundedCount :: String -> (DiagnosticProtocolLimits -> Int) -> Decoder Int
boundedCount context selector = do
    value <- word32 context
    limits <- Decoder (\state -> Right (decodeLimits state, state))
    if toInteger value <= toInteger (selector limits)
        then pure (fromIntegral value)
        else failDecoder context "collection count exceeds configured limit"

boolean :: String -> Decoder Bool
boolean context = do
    value <- byte context
    case value of
        0 -> pure False
        1 -> pure True
        _ -> failDecoder context "boolean byte must be zero or one"

byte :: String -> Decoder Word8
byte context = Decoder $ \state ->
    if decodeOffset state >= ByteString.length (decodeBytes state)
        then Left (DiagnosticProtocolError (decodeOffset state) context "input ended before field was complete")
        else
            Right
                ( ByteString.index (decodeBytes state) (decodeOffset state)
                , state {decodeOffset = decodeOffset state + 1}
                )

word16 :: String -> Decoder Word16
word16 context = do
    low <- byte context
    high <- byte context
    pure (fromIntegral low .|. (fromIntegral high `shiftL` 8))

word32 :: String -> Decoder Word32
word32 context = do
    a <- byte context
    b <- byte context
    c <- byte context
    d <- byte context
    pure
        ( fromIntegral a
            .|. (fromIntegral b `shiftL` 8)
            .|. (fromIntegral c `shiftL` 16)
            .|. (fromIntegral d `shiftL` 24)
        )

unlessDecoder :: Bool -> String -> String -> Decoder ()
unlessDecoder condition context message =
    if condition then pure () else failDecoder context message

failDecoder :: String -> String -> Decoder value
failDecoder context message = Decoder $ \state ->
    Left (DiagnosticProtocolError (decodeOffset state) context message)