packages feed

rzk-0.11.4: test/Rzk/DiagnosticSpec.hs

{-# LANGUAGE OverloadedStrings #-}
-- | Tests for structured diagnostics (Rzk.Diagnostic): the severity/code/
-- location derived from a type error or a hole, and the JSON encoding consumed
-- by `rzk typecheck --json`.
module Rzk.DiagnosticSpec (spec) where

import           Data.Aeson                 (encode)
import qualified Data.ByteString.Lazy.Char8 as BL8
import           Data.List                  (isInfixOf)
import qualified Data.Text                  as T

import qualified Language.Rzk.Syntax        as Rzk
import           Rzk.Diagnostic
import           Rzk.TypeCheck

import           Test.Hspec

-- | All diagnostics for a module, in lenient hole mode (type errors as errors,
-- holes as hints) — the same set `rzk typecheck --json` emits.
diagnose :: T.Text -> [Diagnostic]
diagnose src =
  case Rzk.parseModule src of
    Left err -> error ("parse error: " <> T.unpack err)
    Right m  -> case typecheckModulesWithHoles [("<test>", m)] of
      Left err -> [diagnoseTypeError BottomUp err]
      Right (checked, holes) ->
        map (diagnoseTypeError BottomUp) (checkedErrors checked) ++ map diagnoseHole holes

spec :: Spec
spec = do
  describe "diagnoseTypeError" $ do
    it "produces an error-severity diagnostic with a stable code and a line" $ do
      case diagnose "#lang rzk-1\n#check U U : U\n" of
        [d] -> do
          diagnosticSeverity d `shouldBe` SeverityError
          diagnosticCode d `shouldBe` "TypeErrorNotFunction"
          (diagnosticLocation d >>= locationLine) `shouldBe` Just 2
        ds  -> expectationFailure ("expected one diagnostic, got " <> show (length ds))

  describe "diagnoseHole" $ do
    it "produces a warning-severity diagnostic carrying the goal" $ do
      case diagnose "#lang rzk-1\n#define g : (A : U) -> A -> A\n  := \\ A a -> ?goal\n" of
        [d] -> do
          diagnosticSeverity d `shouldBe` SeverityWarning
          diagnosticCode d `shouldBe` "hole"
          ("goal" `isInfixOf` diagnosticMessage d) `shouldBe` True
        ds  -> expectationFailure ("expected one diagnostic, got " <> show (length ds))

    it "leaves ordinary identity context entries unmarked" $ do
      case diagnose "#lang rzk-1\n#define g : (A : U) -> A -> A\n  := \\ A a -> ?\n" of
        [d] -> do
          diagnosticMessage d `shouldSatisfy` isInfixOf "    A : U"
          diagnosticMessage d `shouldSatisfy` isInfixOf "    a : A"
          diagnosticMessage d `shouldNotSatisfy` isInfixOf "× "
          diagnosticMessage d `shouldNotSatisfy` isInfixOf "[modality:"
        ds  -> expectationFailure ("expected one diagnostic, got " <> show (length ds))

    it "marks inaccessible modal term and cube variables" $ do
      let src = "#lang rzk-1\n\
                \#def g (A : U) (B :_op U) (i :_op 2) : U := ?\n"
      case diagnose src of
        [d] -> do
          diagnosticMessage d `shouldSatisfy` isInfixOf "    A : U"
          diagnosticMessage d `shouldNotSatisfy` isInfixOf "× A : U"
          diagnosticMessage d `shouldSatisfy`
            isInfixOf "    × B : U  [modality: ᵒᵖ, locks: _id]"
          diagnosticMessage d `shouldSatisfy`
            isInfixOf "    × i : 2  [modality: ᵒᵖ, locks: _id]"
        ds  -> expectationFailure ("expected one diagnostic, got " <> show (length ds))

    it "keeps and marks a tope that is unavailable under a modal lock" $ do
      let src = "#lang rzk-1\n\
                \#def g : (i : 2 | i === 0_2) -> _op Unit\n\
                \  := \\ i -> mod _op ?\n"
      case diagnose src of
        [d] -> do
          diagnosticMessage d `shouldSatisfy`
            isInfixOf "    × i : 2  [modality: _id, locks: ᵒᵖ]"
          diagnosticMessage d `shouldSatisfy`
            isInfixOf "    × i ≡ 0₂  [modality: _id, locks: ᵒᵖ]"
          -- the tope is not in force, so it is in topeInfo but not in topes
          let json = BL8.unpack (encode d)
          ("\"topes\":[]" `isInfixOf` json) `shouldBe` True
          ("\"accessible\":false" `isInfixOf` json) `shouldBe` True
        ds  -> expectationFailure ("expected one diagnostic, got " <> show (length ds))

  -- The structured hole payload (Rzk.Diagnostic.HoleData) exposes the goal and
  -- local context as separate rendered strings, so consumers (e.g. richer LSP
  -- hovers) need not parse the prose `message`. The pair-pattern example is
  -- reused from Rzk.HolesSpec so these assertions double as a regression that the
  -- restored binder names (t / s, not π₁ / π₂) survive into the JSON.
  describe "diagnoseHole structured payload" $ do
    let pairPattern =
          "#lang rzk-1\n\
          \#define test : (A : U) -> (x : A) -> ( (t , s) : 2 * 2 | s <= t ) -> A [ t === s |-> x ]\n\
          \  := \\ A x (t , s) -> ?\n"
    it "carries the goal, cube variables and topes as separate fields" $ do
      case diagnose pairPattern of
        [d] -> case diagnosticHole d of
          Just hd -> do
            holeDataName hd `shouldBe` Nothing
            holeDataShape hd `shouldBe` Nothing
            ("t \8801 s" `isInfixOf` holeDataGoal hd) `shouldBe` True   -- t ≡ s
            map holeDataEntryName (holeDataCubeVars hd) `shouldContain` ["(t, s)"]
            map holeDataTopeValue (holeDataTopes hd) `shouldContain` ["s \8804 t"] -- s ≤ t
          Nothing -> expectationFailure "expected a structured hole payload"
        ds  -> expectationFailure ("expected one diagnostic, got " <> show (length ds))

    it "encodes the structured hole object in JSON" $ do
      case diagnose pairPattern of
        [d] -> do
          let json = BL8.unpack (encode d)
          ("\"code\":\"hole\"" `isInfixOf` json) `shouldBe` True
          ("\"hole\":{" `isInfixOf` json) `shouldBe` True
          ("\"goal\":" `isInfixOf` json) `shouldBe` True
          ("\"cubeVars\":" `isInfixOf` json) `shouldBe` True
          ("\"topes\":[\"" `isInfixOf` json) `shouldBe` True   -- topes stay strings
          ("\"topeInfo\":[{" `isInfixOf` json) `shouldBe` True
          ("\"tope\":" `isInfixOf` json) `shouldBe` True
          ("\"modality\":" `isInfixOf` json) `shouldBe` True
          ("\"locks\":" `isInfixOf` json) `shouldBe` True
          ("\"accessible\":" `isInfixOf` json) `shouldBe` True
          -- restored binder names are present in the wire format
          ("(t, s)" `isInfixOf` json) `shouldBe` True
        ds  -> expectationFailure ("expected one diagnostic, got " <> show (length ds))

  describe "diagnoseTypeError modal context" $ do
    it "marks available and unavailable variables in an error context" $ do
      let src = "#lang rzk-1\n\
                \#def bad (A : U) (B :_op U) : U := B\n"
      case diagnose src of
        [d] -> do
          diagnosticMessage d `shouldSatisfy` isInfixOf "  A : U"
          diagnosticMessage d `shouldNotSatisfy` isInfixOf "× A : U"
          diagnosticMessage d `shouldSatisfy`
            isInfixOf "× B : U  [modality: ᵒᵖ, locks: _id]"
          diagnosticMessage d `shouldSatisfy`
            isInfixOf "variable B is inaccessible in the current modal context"
          diagnosticMessage d `shouldSatisfy`
            isInfixOf "declared modality: ᵒᵖ"
          diagnosticMessage d `shouldSatisfy`
            isInfixOf "current locks:      _id"
        ds  -> expectationFailure ("expected one diagnostic, got " <> show (length ds))

    it "distinguishes lambda and modal-introduction mismatches" $ do
      let lambdaSrc = "#lang rzk-1\n\
                      \#def bad : (A : U) -> A -> A := \\ (A :_op U) a -> a\n"
          introSrc = "#lang rzk-1\n\
                     \#def bad (A :_b U) (a :_b A) : _b A := mod _op a\n"
      case (diagnose lambdaSrc, diagnose introSrc) of
        ([lambdaErr], [introErr]) -> do
          diagnosticCode lambdaErr `shouldBe` "TypeErrorModalityMismatch"
          diagnosticMessage lambdaErr `shouldSatisfy`
            isInfixOf "modality mismatch in lambda parameter A"
          diagnosticMessage lambdaErr `shouldSatisfy`
            isInfixOf "expected from function type: _id"
          diagnosticMessage introErr `shouldSatisfy`
            isInfixOf "modality mismatch in modal introduction"
          diagnosticMessage introErr `shouldSatisfy`
            isInfixOf "expected by result type: ♭"
          diagnosticMessage introErr `shouldSatisfy`
            isInfixOf "written after `mod`:     ᵒᵖ"
        result -> expectationFailure ("expected one diagnostic for each source, got " <> show result)

    it "reports the value and type for a non-modal let-mod elimination" $ do
      let src = "#lang rzk-1\n\
                \#def bad (A : U) (x : A) : A := let mod _# y := x in y\n"
      case diagnose src of
        [d] -> do
          diagnosticCode d `shouldBe` "TypeErrorNotModal"
          diagnosticMessage d `shouldSatisfy`
            isInfixOf "cannot eliminate a non-modal value with `let mod ♯`"
          diagnosticMessage d `shouldSatisfy` isInfixOf "value: x"
          diagnosticMessage d `shouldSatisfy` isInfixOf "type:  A"
        ds  -> expectationFailure ("expected one diagnostic, got " <> show (length ds))

    it "reports the term and expected type for modal introduction" $ do
      case diagnose "#lang rzk-1\n#def bad : Unit := mod _op unit\n" of
        [d] -> do
          diagnosticCode d `shouldBe` "TypeErrorNotModal"
          diagnosticMessage d `shouldSatisfy`
            isInfixOf "cannot check modal introduction against a non-modal type"
          diagnosticMessage d `shouldSatisfy` isInfixOf "term: mod ᵒᵖ unit"
          diagnosticMessage d `shouldSatisfy` isInfixOf "expected type: Unit"
        ds  -> expectationFailure ("expected one diagnostic, got " <> show (length ds))

    it "reports the expected and actual modalities for let-mod elimination" $ do
      let src = "#lang rzk-1\n\
                \#def bad (A :_op U) (x : _op A) : _op A\n\
                \  := let mod _# a := x in mod _op a\n"
      case diagnose src of
        [d] -> do
          diagnosticCode d `shouldBe` "TypeErrorModalityMismatch"
          diagnosticMessage d `shouldSatisfy`
            isInfixOf "modality mismatch in modal elimination"
          diagnosticMessage d `shouldSatisfy`
            isInfixOf "expected by `let mod`: ♯"
          diagnosticMessage d `shouldSatisfy`
            isInfixOf "value's modality:     ᵒᵖ"
        ds  -> expectationFailure ("expected one diagnostic, got " <> show (length ds))

    it "reports the operand and its type when a modal type is ill-formed" $ do
      case diagnose "#lang rzk-1\n#def bad : U := _op unit\n" of
        [d] -> do
          diagnosticCode d `shouldBe` "TypeErrorNotTypeInModal"
          diagnosticMessage d `shouldSatisfy` isInfixOf "cannot form modal type ᵒᵖ unit"
          diagnosticMessage d `shouldSatisfy` isInfixOf "has type\n  Unit"
          diagnosticMessage d `shouldSatisfy`
            isInfixOf "but its type must be U, CUBE, or TOPE"
        ds  -> expectationFailure ("expected one diagnostic, got " <> show (length ds))

    it "uses the structured modality error when unifying function types" $ do
      let src = "#lang rzk-1\n\
                \#postulate f : ((X : U) -> U) -> Unit\n\
                \#postulate g : (X :_op U) -> U\n\
                \#def bad : Unit := f g\n"
      case diagnose src of
        [d] -> do
          diagnosticCode d `shouldBe` "TypeErrorModalityMismatch"
          diagnosticMessage d `shouldSatisfy`
            isInfixOf "cannot unify function types with different parameter modalities"
          diagnosticMessage d `shouldSatisfy` isInfixOf "expected: _id"
          diagnosticMessage d `shouldSatisfy` isInfixOf "actual:   ᵒᵖ"
        ds  -> expectationFailure ("expected one diagnostic, got " <> show (length ds))

  describe "JSON encoding" $ do
    it "encodes severity, code and message" $ do
      case diagnose "#lang rzk-1\n#check U U : U\n" of
        [d] -> do
          let json = BL8.unpack (encode d)
          ("\"severity\":\"error\"" `isInfixOf` json) `shouldBe` True
          ("\"code\":\"TypeErrorNotFunction\"" `isInfixOf` json) `shouldBe` True
        ds  -> expectationFailure ("expected one diagnostic, got " <> show (length ds))

    it "emits a null hole field for a type-error diagnostic" $ do
      case diagnose "#lang rzk-1\n#check U U : U\n" of
        [d] -> do
          diagnosticHole d `shouldBe` Nothing
          ("\"hole\":null" `isInfixOf` BL8.unpack (encode d)) `shouldBe` True
        ds  -> expectationFailure ("expected one diagnostic, got " <> show (length ds))