rzk-0.9.0: src/Rzk/Diagnostic.hs
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
-- | Structured diagnostics for rzk: type errors and holes as data (severity,
-- a stable code, a source location, and a message) rather than a single
-- pre-formatted string. The core library produces these; the LSP maps them to
-- LSP diagnostics, and the CLI can emit them as JSON (@rzk typecheck --json@).
--
-- Locations are line-level: rzk currently retains only file + line at the point
-- an error is produced (the column is discarded, and core terms keep no
-- per-node position), so diagnostics point at the enclosing command's line.
module Rzk.Diagnostic where
import Data.Aeson (ToJSON (..), Value (String), object,
(.=))
import Language.Rzk.Free.Syntax (VarIdent)
import Rzk.TypeCheck
-- | Diagnostic severity, mirroring the usual LSP levels.
data Severity
= SeverityError
| SeverityWarning
| SeverityInformation
| SeverityHint
deriving (Eq, Show)
-- | A structured diagnostic. Independent of any editor protocol: the LSP maps
-- it to its own @Diagnostic@, and the CLI serialises it as JSON.
--
-- == JSON wire format (@rzk typecheck --json@)
--
-- This is a stable format that downstream tools (e.g. richer LSP hovers) pin
-- to. Each diagnostic is an object:
--
-- > { "severity": "error" | "warning" | "information" | "hint"
-- > , "code": <string> -- "hole", or a TypeError tag (e.g. "TypeErrorUnify")
-- > , "location": { "file": <string|null>, "line": <int|null> } | null
-- > , "message": <string> -- human-facing prose (the CLI/LSP display text)
-- > , "hole": <HoleData> | null -- present only for hole diagnostics (see 'HoleData')
-- > }
--
-- The @message@ field is kept for back-compat (the LSP and the CLI human mode
-- use it). Structured consumers should read the @hole@ object instead of
-- parsing @message@. For non-hole diagnostics @hole@ is @null@.
data Diagnostic = Diagnostic
{ diagnosticSeverity :: Severity
, diagnosticCode :: String -- ^ stable category, e.g. @\"TypeErrorUnify\"@ or @\"hole\"@
, diagnosticLocation :: Maybe LocationInfo -- ^ file + line (line-level granularity)
, diagnosticMessage :: String
, diagnosticHole :: Maybe HoleData -- ^ structured hole payload ('Nothing' for type errors)
} deriving (Eq, Show)
-- | The structured payload of a hole diagnostic: the goal and local context as
-- already-rendered display strings (the same strings the LSP/CLI show, i.e.
-- @show@ = @printTree@ on the underlying terms). Consumers render hole panels
-- from these fields directly, without parsing the human-facing @message@.
--
-- == JSON wire format
--
-- > { "name": <string|null> -- the ?name, if the hole was named
-- > , "goal": <string> -- the expected type (the goal)
-- > , "shape": { "binder": <string>, "tope": <string> } | null
-- > , "termVars": [ { "name": <string>, "type": <string> }, ... ]
-- > , "cubeVars": [ { "name": <string>, "type": <string> }, ... ]
-- > , "topes": [ <string>, ... ] -- local tope assumptions (excludes ⊤)
-- > }
--
-- Notes for consumers:
--
-- * @shape@ is non-null only for a shape-restricted /argument/ goal, where the
-- goal reads @(binder : goal | tope)@. For an /extension/ type the boundary is
-- already part of @goal@ (a restricted type @A [ … ↦ … ]@), and @shape@ is
-- @null@ — read the boundary out of @goal@.
-- * @cubeVars@ names can be patterns like @\"(t, s)\"@ (pair-pattern binders);
-- they are display strings, not single identifiers.
data HoleData = HoleData
{ holeDataName :: Maybe String -- ^ the @?name@, if named
, holeDataGoal :: String -- ^ the goal (expected type)
, holeDataShape :: Maybe (String, String) -- ^ (binder, tope) for a shape-argument goal
, holeDataTermVars :: [(String, String)] -- ^ local hypotheses: (name, type)
, holeDataCubeVars :: [(String, String)] -- ^ local cube variables: (name, type)
, holeDataTopes :: [String] -- ^ local tope assumptions (excluding ⊤)
} deriving (Eq, Show)
instance ToJSON Severity where
toJSON = String . \case
SeverityError -> "error"
SeverityWarning -> "warning"
SeverityInformation -> "information"
SeverityHint -> "hint"
-- | Encode a location as JSON. A plain helper rather than a @ToJSON@ instance,
-- to avoid an orphan instance ('LocationInfo' is defined in "Rzk.TypeCheck").
locationToJSON :: LocationInfo -> Value
locationToJSON (LocationInfo path line) = object
[ "file" .= path
, "line" .= line
]
instance ToJSON Diagnostic where
toJSON Diagnostic{..} = object
[ "severity" .= diagnosticSeverity
, "code" .= diagnosticCode
, "location" .= fmap locationToJSON diagnosticLocation
, "message" .= diagnosticMessage
, "hole" .= diagnosticHole
]
instance ToJSON HoleData where
toJSON HoleData{..} = object
[ "name" .= holeDataName
, "goal" .= holeDataGoal
, "shape" .= fmap shapeToJSON holeDataShape
, "termVars" .= map entryToJSON holeDataTermVars
, "cubeVars" .= map entryToJSON holeDataCubeVars
, "topes" .= holeDataTopes
]
where
shapeToJSON (binder, tope) = object [ "binder" .= binder, "tope" .= tope ]
entryToJSON (name, ty) = object [ "name" .= name, "type" .= ty ]
-- | A stable tag for a type error, used as its diagnostic code. Independent of
-- the variable type, so it survives the scoped-error unfolding.
typeErrorTag :: TypeError var -> String
typeErrorTag = \case
TypeErrorOther{} -> "TypeErrorOther"
TypeErrorUnify{} -> "TypeErrorUnify"
TypeErrorUnifyTerms{} -> "TypeErrorUnifyTerms"
TypeErrorNotPair{} -> "TypeErrorNotPair"
TypeErrorNotModal{} -> "TypeErrorNotModal"
TypeErrorModalityMismatch{} -> "TypeErrorModalityMismatch"
TypeErrorUnaccessibleVar{} -> "TypeErrorUnaccessibleVar"
TypeErrorNotTypeInModal{} -> "TypeErrorNotTypeInModal"
TypeErrorNotFunction{} -> "TypeErrorNotFunction"
TypeErrorUnexpectedLambda{} -> "TypeErrorUnexpectedLambda"
TypeErrorUnexpectedPair{} -> "TypeErrorUnexpectedPair"
TypeErrorUnexpectedRefl{} -> "TypeErrorUnexpectedRefl"
TypeErrorCannotInferBareLambda{} -> "TypeErrorCannotInferBareLambda"
TypeErrorCannotInferBareRefl{} -> "TypeErrorCannotInferBareRefl"
TypeErrorCannotInferHole{} -> "TypeErrorCannotInferHole"
TypeErrorUnsolvedHole{} -> "TypeErrorUnsolvedHole"
TypeErrorUndefined{} -> "TypeErrorUndefined"
TypeErrorTopeNotSatisfied{} -> "TypeErrorTopeNotSatisfied"
TypeErrorTopeContextDisjoint{} -> "TypeErrorTopeContextDisjoint"
TypeErrorTopesNotEquivalent{} -> "TypeErrorTopesNotEquivalent"
TypeErrorInvalidArgumentType{} -> "TypeErrorInvalidArgumentType"
TypeErrorDuplicateTopLevel{} -> "TypeErrorDuplicateTopLevel"
TypeErrorUnusedVariable{} -> "TypeErrorUnusedVariable"
TypeErrorUnusedUsedVariables{} -> "TypeErrorUnusedUsedVariables"
TypeErrorImplicitAssumption{} -> "TypeErrorImplicitAssumption"
-- | The tag of a scoped type error (peels the binder layers; the tag does not
-- depend on the variable type).
typeErrorTagInScopedContext :: TypeErrorInScopedContext var -> String
typeErrorTagInScopedContext = \case
PlainTypeError e -> typeErrorTag (typeErrorError e)
ScopedTypeError _ e -> typeErrorTagInScopedContext e
-- | The source location of a scoped type error (the enclosing command's line).
locationOfTypeError :: TypeErrorInScopedContext var -> Maybe LocationInfo
locationOfTypeError = \case
PlainTypeError e -> location (typeErrorContext e)
ScopedTypeError _ e -> locationOfTypeError e
-- | A structured diagnostic for a type error. The message is the usual
-- formatted error text; severity is always 'SeverityError'.
diagnoseTypeError :: OutputDirection -> TypeErrorInScopedContext VarIdent -> Diagnostic
diagnoseTypeError dir err = Diagnostic
{ diagnosticSeverity = SeverityError
, diagnosticCode = typeErrorTagInScopedContext err
, diagnosticLocation = locationOfTypeError err
, diagnosticMessage = ppTypeErrorInScopedContext' dir err
, diagnosticHole = Nothing
}
-- | A structured diagnostic for a hole, carrying the hole's goal and local
-- context. A hole is an unfilled obligation, so it is a 'SeverityWarning' —
-- mirroring Agda's yellow \"unsolved\" highlight, and visible in the editor's
-- problems panel (unlike 'SeverityHint', which editors render almost
-- invisibly). Finished work still rejects holes outright: the strict default
-- of @rzk typecheck@ reports them as errors (cf. Agda's @--safe@).
diagnoseHole :: HoleInfo -> Diagnostic
diagnoseHole hole = Diagnostic
{ diagnosticSeverity = SeverityWarning
, diagnosticCode = "hole"
, diagnosticLocation = holeLocation hole
, diagnosticMessage = ppHoleInfo hole
, diagnosticHole = Just (holeData hole)
}
-- | The structured payload of a hole: its goal and local context rendered to
-- display strings (the same rendering 'ppHoleInfo' uses, but kept as separate
-- fields rather than concatenated into prose). See 'HoleData'.
holeData :: HoleInfo -> HoleData
holeData HoleInfo{..} = HoleData
{ holeDataName = fmap show holeName
, holeDataGoal = show holeGoal
, holeDataShape = fmap (\(s, tope) -> (show s, show tope)) holeGoalShape
, holeDataTermVars = map entry holeTermVars
, holeDataCubeVars = map entry holeCubeVars
, holeDataTopes = map show holeTopes
}
where
entry e = (show (holeEntryName e), show (holeEntryType e))
-- | Render a hole's goal and local context (the structured query) for display,
-- separating term variables, cube variables, and tope assumptions.
ppHoleInfo :: HoleInfo -> String
ppHoleInfo HoleInfo{..} = unlines $
[ "Hole" <> maybe "" (\name -> " ?" <> show name) holeName
<> maybe "" (\loc -> " at " <> ppLocationInfo loc) holeLocation
, " goal:"
, " " <> goalStr
]
<> section "context" holeTermVars
<> section "cube variables" holeCubeVars
<> (if null holeTopes
then []
else " tope context:" : [ " " <> show t | t <- holeTopes ])
where
-- a shape goal reads (binder : cube | tope); otherwise just the type
goalStr = case holeGoalShape of
Nothing -> show holeGoal
Just (s, tope) -> "(" <> show s <> " : " <> show holeGoal <> " | " <> show tope <> ")"
section title entries
| null entries = []
| otherwise = (" " <> title <> ":")
: [ " " <> show (holeEntryName e) <> " : " <> show (holeEntryType e)
| e <- entries ]
ppLocationInfo :: LocationInfo -> String
ppLocationInfo (LocationInfo mpath mline) =
maybe "<input>" id mpath <> maybe "" ((":" <>) . show) mline