Iter 4a: ail check --json mit strukturierten Diagnostics
Neue Top-Level-API check_module(&Module) -> Vec<Diagnostic> in ailang-check, plus stabile Codes (unbound-var, type-mismatch, arity-mismatch, non-exhaustive-match, unknown-ctor-in-pattern, duplicate-def, …). CLI bekommt --json-Flag für maschinenlesbares Output, Exit 1 bei Errors. Text-Modus unverändert. E2E-Test check_json_unbound_var sichert das Format ab.
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
//! Strukturierte Diagnostics für `ail check --json`.
|
||||
//!
|
||||
//! Eine [`Diagnostic`] ist die maschinenlesbare Repräsentation eines vom
|
||||
//! Typchecker (oder vorgelagertem Lade-Schritt) gemeldeten Problems.
|
||||
//! Die [`Severity`] serialisiert sich als kleingeschriebener String
|
||||
//! (`"error"` / `"warning"`), [`code`] ist ein stabiler Kebab-Case-Identifier,
|
||||
//! der von Tooling konsumiert werden kann, ohne den `message`-Text zu parsen.
|
||||
//!
|
||||
//! Konvention: pro Aufruf von [`super::check_module`] wird höchstens ein
|
||||
//! Diagnostic gemeldet (single-shot). Mehrere Diagnostics pro Lauf sind ein
|
||||
//! späteres Feature; das aktuelle Format erlaubt sie aber bereits.
|
||||
//!
|
||||
//! Stabile Codes (Stand Iteration 4):
|
||||
//! - `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`
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Severity {
|
||||
Error,
|
||||
Warning,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
pub struct Diagnostic {
|
||||
pub severity: Severity,
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
/// Welche Top-Level-Definition betroffen ist (falls bekannt). Wird im
|
||||
/// JSON immer ausgegeben — `null`, wenn unbekannt — damit Konsumenten
|
||||
/// das Feld nicht konditionell behandeln müssen.
|
||||
pub def: Option<String>,
|
||||
/// Freier strukturierter Kontext. Leer = `{}`.
|
||||
pub ctx: serde_json::Value,
|
||||
}
|
||||
|
||||
impl Diagnostic {
|
||||
pub fn error(code: impl Into<String>, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
severity: Severity::Error,
|
||||
code: code.into(),
|
||||
message: message.into(),
|
||||
def: None,
|
||||
ctx: serde_json::Value::Object(serde_json::Map::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_def(mut self, def: impl Into<String>) -> Self {
|
||||
self.def = Some(def.into());
|
||||
self
|
||||
}
|
||||
|
||||
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();
|
||||
// Felder müssen alle vorhanden sein, Severity klein, ctx ist ein
|
||||
// leeres Objekt (nicht null, nicht weggelassen).
|
||||
assert!(s.contains("\"severity\":\"error\""), "{s}");
|
||||
assert!(s.contains("\"code\":\"unbound-var\""), "{s}");
|
||||
assert!(s.contains("\"def\":\"main\""), "{s}");
|
||||
assert!(s.contains("\"ctx\":{}"), "{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}");
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@ use indexmap::IndexMap;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
pub mod builtins;
|
||||
pub mod diagnostic;
|
||||
|
||||
pub use diagnostic::{Diagnostic, Severity};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CheckError {
|
||||
@@ -61,6 +64,9 @@ pub enum CheckError {
|
||||
#[error("type `{ty}` has no constructor `{ctor}`")]
|
||||
UnknownCtor { ty: String, ctor: String },
|
||||
|
||||
#[error("unknown constructor `{0}` in pattern")]
|
||||
UnknownCtorInPattern(String),
|
||||
|
||||
#[error("constructor `{ty}/{ctor}` arity: expected {expected} fields, got {got}")]
|
||||
CtorArity {
|
||||
ty: String,
|
||||
@@ -83,10 +89,116 @@ pub enum CheckError {
|
||||
|
||||
#[error("duplicate constructor: `{ctor}` (in types `{a}` and `{b}`)")]
|
||||
DuplicateCtor { ctor: String, a: String, b: String },
|
||||
|
||||
#[error("duplicate definition: `{0}`")]
|
||||
DuplicateDef(String),
|
||||
|
||||
#[error("nested constructor pattern not allowed in MVP: `{0}`")]
|
||||
NestedCtorPatternNotAllowed(String),
|
||||
}
|
||||
|
||||
type Result<T> = std::result::Result<T, CheckError>;
|
||||
|
||||
impl CheckError {
|
||||
/// Stabiler Kebab-Case-Code für maschinelle Konsumption (`ail check --json`).
|
||||
/// Wird über das `Def`-Wrapping rekursiv durchgereicht — der innere Fehler
|
||||
/// trägt den eigentlichen Code, das Wrapping nur den Def-Kontext.
|
||||
pub fn code(&self) -> &'static str {
|
||||
match self {
|
||||
CheckError::Def(_, inner) => inner.code(),
|
||||
CheckError::TypeMismatch { .. } => "type-mismatch",
|
||||
CheckError::UnknownIdent(_) => "unbound-var",
|
||||
CheckError::UnknownEffectOp(_) => "unknown-effect-op",
|
||||
CheckError::NotAFunction(..) => "not-a-function",
|
||||
CheckError::ArityMismatch { .. } => "arity-mismatch",
|
||||
CheckError::UndeclaredEffect(_) => "undeclared-effect",
|
||||
CheckError::FnTypeRequired(..) => "fn-type-required",
|
||||
CheckError::ParamCountMismatch { .. } => "param-count-mismatch",
|
||||
CheckError::PolymorphicNotSupported(_) => "polymorphic-not-supported",
|
||||
CheckError::ConstHasEffects(..) => "const-has-effects",
|
||||
CheckError::UnknownType(_) => "unknown-type",
|
||||
CheckError::UnknownCtor { .. } => "unknown-ctor",
|
||||
CheckError::UnknownCtorInPattern(_) => "unknown-ctor-in-pattern",
|
||||
CheckError::CtorArity { .. } => "arity-mismatch",
|
||||
CheckError::NonExhaustive { .. } => "non-exhaustive-match",
|
||||
CheckError::PrimitiveNeedsWildcard(_) => "primitive-needs-wildcard",
|
||||
CheckError::PatternTypeMismatch { .. } => "pattern-type-mismatch",
|
||||
CheckError::DuplicateType(_) => "duplicate-type",
|
||||
CheckError::DuplicateCtor { .. } => "duplicate-ctor",
|
||||
CheckError::DuplicateDef(_) => "duplicate-def",
|
||||
CheckError::NestedCtorPatternNotAllowed(_) => "nested-ctor-pattern-not-allowed",
|
||||
}
|
||||
}
|
||||
|
||||
/// Strukturierter Kontext für ein Diagnostic. Kommt direkt im JSON unter
|
||||
/// dem Schlüssel `ctx` an. Leeres Objekt, wenn kein Kontext vorhanden.
|
||||
pub fn ctx(&self) -> serde_json::Value {
|
||||
match self {
|
||||
CheckError::Def(_, inner) => inner.ctx(),
|
||||
CheckError::TypeMismatch { expected, got } => {
|
||||
serde_json::json!({"expected": expected, "actual": got})
|
||||
}
|
||||
CheckError::ArityMismatch { expected, got, .. } => {
|
||||
serde_json::json!({"expected": expected, "actual": got})
|
||||
}
|
||||
CheckError::CtorArity {
|
||||
expected, got, ..
|
||||
} => serde_json::json!({"expected": expected, "actual": got}),
|
||||
CheckError::ParamCountMismatch {
|
||||
ty_count,
|
||||
param_count,
|
||||
..
|
||||
} => serde_json::json!({"expected": ty_count, "actual": param_count}),
|
||||
CheckError::NonExhaustive { missing, .. } => {
|
||||
serde_json::json!({"missing": missing})
|
||||
}
|
||||
_ => serde_json::Value::Object(serde_json::Map::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Falls dieser Fehler durch [`CheckError::Def`] gewrappt ist, liefert
|
||||
/// diese Methode den Namen der betroffenen Def. Sonst `None`.
|
||||
pub fn def(&self) -> Option<&str> {
|
||||
match self {
|
||||
CheckError::Def(n, _) => Some(n.as_str()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Auspacken des potenziell durch [`CheckError::Def`] gewrappten Fehlers.
|
||||
pub fn inner(&self) -> &CheckError {
|
||||
match self {
|
||||
CheckError::Def(_, inner) => inner.inner(),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Nicht-`Def`-gewrapptes Message. Ohne `def: ...`-Präfix.
|
||||
pub fn message(&self) -> String {
|
||||
format!("{}", self.inner())
|
||||
}
|
||||
|
||||
pub fn to_diagnostic(&self) -> Diagnostic {
|
||||
let mut d = Diagnostic::error(self.code(), self.message()).with_ctx(self.ctx());
|
||||
if let Some(name) = self.def() {
|
||||
d = d.with_def(name);
|
||||
}
|
||||
d
|
||||
}
|
||||
}
|
||||
|
||||
/// Top-Level-API für strukturierte Diagnostics.
|
||||
///
|
||||
/// Leerer Vec = grün. Im aktuellen Stand wird beim ersten Fehler abgebrochen,
|
||||
/// daher enthält der Vec entweder 0 oder 1 Element. Mehrere Diagnostics pro
|
||||
/// Lauf sind ein späteres Feature; das Format erlaubt sie bereits.
|
||||
pub fn check_module(m: &Module) -> Vec<Diagnostic> {
|
||||
match check(m) {
|
||||
Ok(_) => Vec::new(),
|
||||
Err(e) => vec![e.to_diagnostic()],
|
||||
}
|
||||
}
|
||||
|
||||
/// Ergebnis der Typprüfung eines Moduls: Mapping vom Symbolnamen zum
|
||||
/// (Typ, Hash) — bereit für `manifest`-Ausgabe.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -123,13 +235,21 @@ pub fn check(m: &Module) -> Result<CheckedModule> {
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 1b: alle Top-Level-Werte-Symbole registrieren.
|
||||
// Pass 1b: alle Top-Level-Werte-Symbole registrieren. Doppelte Namen
|
||||
// (zwischen fn/const) sind unzulässig — das schützt davor, dass sich
|
||||
// zwei Defs einen Namensraum teilen.
|
||||
for def in &m.defs {
|
||||
match def {
|
||||
Def::Fn(f) => {
|
||||
if env.globals.contains_key(&f.name) {
|
||||
return Err(CheckError::DuplicateDef(f.name.clone()));
|
||||
}
|
||||
env.globals.insert(f.name.clone(), f.ty.clone());
|
||||
}
|
||||
Def::Const(c) => {
|
||||
if env.globals.contains_key(&c.name) {
|
||||
return Err(CheckError::DuplicateDef(c.name.clone()));
|
||||
}
|
||||
env.globals.insert(c.name.clone(), c.ty.clone());
|
||||
}
|
||||
Def::Type(_) => {}
|
||||
@@ -485,12 +605,18 @@ fn type_check_pattern(
|
||||
Ok(vec![])
|
||||
}
|
||||
Pattern::Ctor { ctor, fields } => {
|
||||
let cref = env.ctor_index.get(ctor).ok_or_else(|| {
|
||||
CheckError::UnknownCtor {
|
||||
ty: "<unknown>".into(),
|
||||
ctor: ctor.clone(),
|
||||
// MVP: Sub-Patterns von Ctor-Patterns dürfen nur `Var` oder `Wild`
|
||||
// sein. Nested Ctor- oder Lit-Patterns brauchen ein
|
||||
// Decision-Tree-Lowering, das wir im Codegen noch nicht haben.
|
||||
for sub in fields {
|
||||
if !matches!(sub, Pattern::Var { .. } | Pattern::Wild) {
|
||||
return Err(CheckError::NestedCtorPatternNotAllowed(ctor.clone()));
|
||||
}
|
||||
})?;
|
||||
}
|
||||
let cref = env
|
||||
.ctor_index
|
||||
.get(ctor)
|
||||
.ok_or_else(|| CheckError::UnknownCtorInPattern(ctor.clone()))?;
|
||||
// expected muss diese ADT sein.
|
||||
match expected {
|
||||
Type::Con { name } if name == &cref.type_name => {}
|
||||
|
||||
Reference in New Issue
Block a user