//! Structured diagnostics for `ail check --json`. //! //! A [`Diagnostic`] is the machine-readable representation of a problem //! reported by the typechecker (or an upstream load step). //! [`Severity`] serializes as a lowercase string //! (`"error"` / `"warning"`); the `code` field is a stable kebab-case identifier //! that tooling can consume without parsing the `message` text. //! //! Convention: each call to [`crate::check_module`] reports at most one //! diagnostic (single-shot). Multiple diagnostics per run are a future //! feature; the current format already allows them. //! //! Stable codes (as of iteration 5b): //! - `schema-mismatch` //! - `unknown-type` //! - `unbound-var` //! - `type-mismatch` — `ctx`: `{"expected": "...", "actual": "..."}` //! - `arity-mismatch` — `ctx`: `{"expected": N, "actual": M}` //! - `unknown-ctor` //! - `unknown-effect-op` //! - `non-exhaustive-match` — `ctx`: `{"missing": ["..."]}` //! - `unknown-ctor-in-pattern` //! - `nested-ctor-pattern-not-allowed` //! - `duplicate-def` //! - `not-a-function` //! - `undeclared-effect` //! - `fn-type-required` //! - `param-count-mismatch` //! - `polymorphic-not-supported` //! - `const-has-effects` //! - `pattern-type-mismatch` //! - `primitive-needs-wildcard` //! - `duplicate-type` //! - `duplicate-ctor` //! - `unknown-module` — `ctx`: `{"module": ""}` (Iter 5b) //! - `unknown-import` — `ctx`: `{"module": "", "name": ""}` (Iter 5b) //! - `invalid-def-name` — `ctx`: `{"name": "", "reason": "contains-dot"}` (Iter 5b) //! - `module-not-found` — workspace loader (Iter 5b, in the CLI path) //! - `module-cycle` — workspace loader (Iter 5b, in the CLI path) //! - `module-name-mismatch` — workspace loader (Iter 5b, in the CLI path) //! - `module-hash-mismatch` — workspace loader (Iter 5b, in the CLI path) //! - `tail-call-not-in-tail-position` (Iter 14e, see Decision 8) //! - `ambiguous-ctor` — `ctx`: `{"ctor": "", "candidates": ["m1.T", "m2.T"]}` (Iter 15a) //! - `use-after-consume` — `ctx`: `{"binder": ""}` (Iter 18c.2); //! carries non-empty [`Diagnostic::suggested_rewrites`] showing how to //! spell the fix in form-A AILang. //! - `consume-while-borrowed` — `ctx`: `{"binder": ""}` (Iter 18c.2); //! ditto on `suggested_rewrites`. use serde::Serialize; /// Severity of a [`Diagnostic`]. /// /// Serializes as a lowercase string (`"error"` / `"warning"`) so that /// `ail check --json` consumers can branch on it without parsing prose. /// The MVP only emits [`Severity::Error`]; [`Severity::Warning`] is /// reserved for future lints. #[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum Severity { /// Hard failure. The module did not typecheck. Error, /// Non-fatal observation. Reserved; the typechecker does not emit /// warnings yet. Warning, } /// Machine-readable problem report from the typechecker. /// /// Stable JSON shape: every field is always emitted (no `skip_serializing_if`), /// so a tool can decode into a fixed schema without conditional handling. /// See the module-level doc for the list of `code` values. #[derive(Serialize, Debug, Clone)] pub struct Diagnostic { /// Severity of the diagnostic; see [`Severity`]. pub severity: Severity, /// Stable kebab-case identifier (e.g. `"unbound-var"`, /// `"type-mismatch"`). Tool consumers should branch on this rather /// than on `message`. The full enumeration is listed in the module /// doc. pub code: String, /// Human-readable description. Free-form; do not parse. The /// `code` + `ctx` pair carries the structured equivalent. pub message: String, /// Which top-level definition is affected (if known). Always emitted in /// the JSON — `null` when unknown — so consumers don't have to handle /// the field conditionally. pub def: Option, /// Free structured context. Empty = `{}`. pub ctx: serde_json::Value, /// Iter 18c.2: machine-applicable fix suggestions, each a snippet of /// form-A AILang the author can paste back at the offending site. /// Empty = no rewrite suggested. The list is always emitted (so JSON /// consumers see a stable shape; they can branch on `.is_empty()`). /// Currently populated by the linearity check (`use-after-consume` / /// `consume-while-borrowed` codes); other diagnostics emit `[]`. pub suggested_rewrites: Vec, } /// One machine-applicable rewrite suggestion attached to a [`Diagnostic`]. /// /// Iter 18c.2: emitted by the linearity check to point the author (or an /// LLM consumer of `ail check --json`) at the spelled fix. `replacement` /// is form-A AILang text — parseable by `ailang_surface::parse_term`. #[derive(Serialize, Debug, Clone)] pub struct SuggestedRewrite { /// One-line free-form description of what the rewrite does (e.g. /// `"wrap the offending use in (clone X)"`). Not parsed by tooling. pub description: String, /// Form-A AILang snippet to substitute at the offending site. For /// the linearity-check diagnostics this is typically `(clone )` /// or a small enclosing rewrite. The string MUST parse via the /// surface `parse_term` entrypoint; [`crate::linearity`] tests /// guard the round-trip. pub replacement: String, } impl Diagnostic { /// Builds an [`Severity::Error`] diagnostic with the given `code` and /// `message`. `def` is left unset and `ctx` defaults to `{}` — chain /// [`Diagnostic::with_def`] / [`Diagnostic::with_ctx`] to fill them /// in. pub fn error(code: impl Into, message: impl Into) -> Self { Self { severity: Severity::Error, code: code.into(), message: message.into(), def: None, ctx: serde_json::Value::Object(serde_json::Map::new()), suggested_rewrites: Vec::new(), } } /// Iter 18c.2: append a [`SuggestedRewrite`]. Builder-style. Used by /// the linearity check to attach the form-A fix it computed. Other /// diagnostics leave the field empty. pub fn with_suggested_rewrite( mut self, description: impl Into, replacement: impl Into, ) -> Self { self.suggested_rewrites.push(SuggestedRewrite { description: description.into(), replacement: replacement.into(), }); self } /// Sets the affected top-level def name. Builder-style: returns /// `self`. Used by [`crate::CheckError::to_diagnostic`] when the /// error was wrapped in [`crate::CheckError::Def`]. pub fn with_def(mut self, def: impl Into) -> Self { self.def = Some(def.into()); self } /// Replaces the `ctx` payload. Builder-style: returns `self`. Pass a /// `serde_json::Value` whose shape matches the `code`-specific /// schema documented at the module level (e.g. /// `{"expected": "...", "actual": "..."}` for `type-mismatch`). pub fn with_ctx(mut self, ctx: serde_json::Value) -> Self { self.ctx = ctx; self } } #[cfg(test)] mod tests { use super::*; #[test] fn serializes_with_stable_shape() { let d = Diagnostic::error("unbound-var", "unknown identifier: `x`") .with_def("main"); let s = serde_json::to_string(&d).unwrap(); // All fields must be present, severity lowercase, ctx is an // empty object (not null, not omitted), suggested_rewrites is // always-emitted as `[]` for non-linearity diagnostics. assert!(s.contains("\"severity\":\"error\""), "{s}"); assert!(s.contains("\"code\":\"unbound-var\""), "{s}"); assert!(s.contains("\"def\":\"main\""), "{s}"); assert!(s.contains("\"ctx\":{}"), "{s}"); assert!(s.contains("\"suggested_rewrites\":[]"), "{s}"); } #[test] fn ctx_can_carry_structured_data() { let d = Diagnostic::error("type-mismatch", "type mismatch") .with_ctx(serde_json::json!({"expected": "Int", "actual": "Bool"})); let s = serde_json::to_string(&d).unwrap(); assert!(s.contains("\"expected\":\"Int\""), "{s}"); assert!(s.contains("\"actual\":\"Bool\""), "{s}"); } /// Iter 18c.2: a diagnostic carrying a `SuggestedRewrite` serialises /// the rewrite under `suggested_rewrites` with the description and /// the form-A replacement. #[test] fn suggested_rewrite_serializes() { let d = Diagnostic::error("use-after-consume", "use of `xs` after consume") .with_suggested_rewrite("wrap earlier use in (clone)", "(clone xs)"); let s = serde_json::to_string(&d).unwrap(); assert!(s.contains("\"suggested_rewrites\":["), "{s}"); assert!(s.contains("\"description\":\"wrap earlier use in (clone)\""), "{s}"); assert!(s.contains("\"replacement\":\"(clone xs)\""), "{s}"); } }