packages feed

tala-compiler-0.1.0: test/DiagnosticProtocolTests.hs

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

module DiagnosticProtocolTests (diagnosticProtocolTests) where

import Data.ByteString qualified as ByteString
import Data.Word (Word8)
import Progmasoft.Tala.AST
import Progmasoft.Tala.Diagnostic
import Progmasoft.Tala.Diagnostic.Protocol

diagnosticProtocolTests :: [(String, Bool)]
diagnosticProtocolTests =
    [ ("diagnostic wire has a stable empty-document header", emptyGolden)
    , ("diagnostic wire round-trips a compiler diagnostic", simpleRoundTrip)
    , ("diagnostic conversion changes source positions to zero-based coordinates", convertsCoordinates)
    , ("diagnostic wire round-trips every stage", everyStageRoundTrips)
    , ("diagnostic wire round-trips both frontend severities", everySeverityRoundTrips)
    , ("diagnostic wire preserves supplementary Unicode scalars", unicodeRoundTrip)
    , ("diagnostic wire preserves arguments, related locations, and fixes", richRoundTrip)
    , ("diagnostic wire rejects bad magic", rejectsMagic)
    , ("diagnostic wire rejects unsupported versions", rejectsVersion)
    , ("diagnostic wire rejects reserved flags", rejectsFlags)
    , ("diagnostic wire rejects trailing bytes", rejectsTrailingBytes)
    , ("diagnostic wire rejects every truncated prefix", rejectsTruncation)
    , ("diagnostic wire rejects unknown stage tags", rejectsUnknownStage)
    , ("diagnostic wire rejects unknown severity tags", rejectsUnknownSeverity)
    , ("diagnostic wire rejects invalid Boolean tags", rejectsInvalidBoolean)
    , ("diagnostic wire rejects malformed codes", rejectsMalformedCode)
    , ("diagnostic wire rejects duplicate argument names", rejectsDuplicateArguments)
    , ("diagnostic wire rejects reversed source ranges", rejectsReversedRange)
    , ("diagnostic wire rejects empty fixes", rejectsEmptyFix)
    , ("diagnostic wire enforces record limits before encoding", encodeRecordLimit)
    , ("diagnostic wire enforces record limits before decoding", decodeRecordLimit)
    , ("diagnostic wire enforces text scalar limits", textLimit)
    , ("diagnostic wire enforces total byte limits", byteLimit)
    , ("diagnostic conversion rejects non-positive source coordinates", rejectsInvalidCoordinate)
    ]

emptyGolden :: Bool
emptyGolden =
    encode emptyDocument
        == Right (ByteString.pack [0x54, 0x41, 0x44, 0x47, 1, 0, 0, 0, 0, 0, 0, 0])

simpleRoundTrip :: Bool
simpleRoundTrip = case diagnosticDocument [sampleDiagnostic] of
    Left _ -> False
    Right document -> decodeEncoded document == Right document

convertsCoordinates :: Bool
convertsCoordinates = case diagnosticDocument [sampleDiagnostic] of
    Right (DiagnosticDocument [record]) ->
        recordPrimary record
            == Just (DiagnosticLocation "Main.tala" 7 12 7 18)
    _ -> False

everyStageRoundTrips :: Bool
everyStageRoundTrips =
    all
        (\stage -> roundTrips (simpleRecord {recordStage = stage}))
        [minBound .. maxBound]

everySeverityRoundTrips :: Bool
everySeverityRoundTrips =
    all
        (\severity -> roundTrips (simpleRecord {recordSeverity = severity}))
        [minBound .. maxBound]

unicodeRoundTrip :: Bool
unicodeRoundTrip =
    roundTrips
        (simpleRecord {recordMessage = "Unicode scalar: \x1f9ea; path: C:/\x03b4/Main.tala"})

richRoundTrip :: Bool
richRoundTrip = roundTrips richRecord

rejectsMagic :: Bool
rejectsMagic = rejected (replace 0 0x42 emptyBytes)

rejectsVersion :: Bool
rejectsVersion = rejected (replace 4 2 emptyBytes)

rejectsFlags :: Bool
rejectsFlags = rejected (replace 6 1 emptyBytes)

rejectsTrailingBytes :: Bool
rejectsTrailingBytes = rejected (ByteString.snoc emptyBytes 0)

rejectsTruncation :: Bool
rejectsTruncation = case encode (DiagnosticDocument [richRecord]) of
    Left _ -> False
    Right bytes ->
        all (rejected . (`ByteString.take` bytes)) [0 .. ByteString.length bytes - 1]
            && decode bytes == Right (DiagnosticDocument [richRecord])

rejectsUnknownStage :: Bool
rejectsUnknownStage = rejected (replace 12 0xff simpleBytes)

rejectsUnknownSeverity :: Bool
rejectsUnknownSeverity = rejected (replace 13 0xff simpleBytes)

rejectsInvalidBoolean :: Bool
rejectsInvalidBoolean = case primaryPresenceOffset of
    Nothing -> False
    Just offset -> rejected (replace offset 2 simpleBytes)

rejectsMalformedCode :: Bool
rejectsMalformedCode =
    left (encode (DiagnosticDocument [simpleRecord {recordCode = "lowercase"}]))
        && left (encode (DiagnosticDocument [simpleRecord {recordCode = ""}]))

rejectsDuplicateArguments :: Bool
rejectsDuplicateArguments =
    left
        ( encode
            ( DiagnosticDocument
                [ simpleRecord
                    { recordArguments =
                        [DiagnosticArgument "name" "first", DiagnosticArgument "name" "second"]
                    }
                ]
            )
        )

rejectsReversedRange :: Bool
rejectsReversedRange =
    left
        ( encode
            ( DiagnosticDocument
                [ simpleRecord
                    { recordPrimary = Just (DiagnosticLocation "Main.tala" 4 1 3 9)
                    }
                ]
            )
        )

rejectsEmptyFix :: Bool
rejectsEmptyFix =
    left
        ( encode
            ( DiagnosticDocument
                [simpleRecord {recordFixes = [DiagnosticFix "Apply correction" []]}]
            )
        )

encodeRecordLimit :: Bool
encodeRecordLimit =
    let limits = defaultDiagnosticProtocolLimits {maximumRecords = 0}
     in left (encodeDiagnosticDocument limits (DiagnosticDocument [simpleRecord]))

decodeRecordLimit :: Bool
decodeRecordLimit =
    let limits = defaultDiagnosticProtocolLimits {maximumRecords = 0}
     in left (decodeDiagnosticDocument limits simpleBytes)

textLimit :: Bool
textLimit =
    let limits = defaultDiagnosticProtocolLimits {maximumTextScalars = 2}
     in left (encodeDiagnosticDocument limits (DiagnosticDocument [simpleRecord]))

byteLimit :: Bool
byteLimit =
    let limits = defaultDiagnosticProtocolLimits {maximumWireBytes = 11}
     in left (decodeDiagnosticDocument limits emptyBytes)
            && left (encodeDiagnosticDocument limits emptyDocument)

rejectsInvalidCoordinate :: Bool
rejectsInvalidCoordinate =
    left
        ( diagnosticDocument
            [ sampleDiagnostic
                { diagnosticSpan =
                    Just (SourceSpan "Main.tala" (SourcePosition 0 1) (SourcePosition 1 1))
                }
            ]
        )

sampleDiagnostic :: Diagnostic
sampleDiagnostic =
    Diagnostic
        TypeCheckerStage
        Error
        "TAT204"
        (Just (SourceSpan "Main.tala" (SourcePosition 8 13) (SourcePosition 8 19)))
        "argument cannot be converted"

simpleRecord :: DiagnosticRecord
simpleRecord =
    DiagnosticRecord
        { recordStage = ParserStage
        , recordSeverity = ProtocolError
        , recordCode = "TAP100"
        , recordMessage = "expected declaration"
        , recordArguments = []
        , recordPrimary = Nothing
        , recordRelated = []
        , recordFixes = []
        }

richRecord :: DiagnosticRecord
richRecord =
    DiagnosticRecord
        { recordStage = TypeCheckerStage
        , recordSeverity = ProtocolWarning
        , recordCode = "TAT204"
        , recordMessage = "argument {actual} cannot be converted to {expected}"
        , recordArguments =
            [ DiagnosticArgument "actual" "String"
            , DiagnosticArgument "expected" "int"
            ]
        , recordPrimary = Just mainLocation
        , recordRelated =
            [ DiagnosticRelatedLocation
                (DiagnosticLocation "Library.tala" 2 4 2 12)
                "parameter is declared here"
            ]
        , recordFixes =
            [ DiagnosticFix
                "Convert the argument"
                [DiagnosticTextEdit mainLocation "value.ToInt()"]
            ]
        }

mainLocation :: DiagnosticLocation
mainLocation = DiagnosticLocation "Main.tala" 7 12 7 18

emptyDocument :: DiagnosticDocument
emptyDocument = DiagnosticDocument []

emptyBytes :: ByteString.ByteString
emptyBytes = either (const ByteString.empty) id (encode emptyDocument)

simpleBytes :: ByteString.ByteString
simpleBytes = either (const ByteString.empty) id (encode (DiagnosticDocument [simpleRecord]))

-- The primary-presence byte follows the fixed header, tags, two counted scalar
-- strings, and the zero argument count. Computing it keeps this test readable
-- while still pinning the exact wire layout.
primaryPresenceOffset :: Maybe Int
primaryPresenceOffset =
    let scalarBytes value = 4 + 4 * length value
        offset = 12 + 2 + scalarBytes (recordCode simpleRecord) + scalarBytes (recordMessage simpleRecord) + 4
     in if offset < ByteString.length simpleBytes then Just offset else Nothing

encode :: DiagnosticDocument -> Either DiagnosticProtocolError ByteString.ByteString
encode = encodeDiagnosticDocument defaultDiagnosticProtocolLimits

decode :: ByteString.ByteString -> Either DiagnosticProtocolError DiagnosticDocument
decode = decodeDiagnosticDocument defaultDiagnosticProtocolLimits

decodeEncoded :: DiagnosticDocument -> Either DiagnosticProtocolError DiagnosticDocument
decodeEncoded document = encode document >>= decode

roundTrips :: DiagnosticRecord -> Bool
roundTrips record = decodeEncoded (DiagnosticDocument [record]) == Right (DiagnosticDocument [record])

rejected :: ByteString.ByteString -> Bool
rejected = left . decode

left :: Either leftValue rightValue -> Bool
left value = case value of
    Left _ -> True
    Right _ -> False

replace :: Int -> Word8 -> ByteString.ByteString -> ByteString.ByteString
replace index value bytes =
    ByteString.take index bytes
        <> ByteString.singleton value
        <> ByteString.drop (index + 1) bytes