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:
2026-05-07 11:07:36 +02:00
parent 44243a515e
commit 93fe7237e3
7 changed files with 334 additions and 11 deletions
Generated
+2
View File
@@ -20,6 +20,8 @@ version = "0.0.1"
dependencies = [ dependencies = [
"ailang-core", "ailang-core",
"indexmap", "indexmap",
"serde",
"serde_json",
"thiserror", "thiserror",
] ]
+42 -5
View File
@@ -42,7 +42,13 @@ enum Cmd {
json: bool, json: bool,
}, },
/// Typprüft ein Modul. /// Typprüft ein Modul.
Check { path: PathBuf }, Check {
path: PathBuf,
/// Strukturierte Diagnostics als JSON-Array auf stdout.
/// Exit-Code 1, wenn mindestens ein Error gemeldet wird.
#[arg(long)]
json: bool,
},
/// Schreibt LLVM IR (.ll) für das Modul. /// Schreibt LLVM IR (.ll) für das Modul.
EmitIr { EmitIr {
path: PathBuf, path: PathBuf,
@@ -163,10 +169,41 @@ fn main() -> Result<()> {
print!("{}", ailang_core::pretty::module(&one)); print!("{}", ailang_core::pretty::module(&one));
} }
} }
Cmd::Check { path } => { Cmd::Check { path, json } => {
let m = ailang_core::load_module(&path)?; if json {
let r = ailang_check::check(&m)?; // JSON-Modus: stdout enthält ausschließlich das Diagnostics-
println!("ok ({} symbols)", r.symbols.len()); // Array. Schema-Mismatch wird als strukturiertes Diagnostic
// emittiert (mit Code `schema-mismatch`); echte I/O-Fehler
// propagieren als fatal, weil sie keinen Modul-bezogenen
// Diagnostic-Kontext haben.
let diags = match ailang_core::load_module(&path) {
Ok(m) => ailang_check::check_module(&m),
Err(ailang_core::Error::SchemaMismatch { expected, got }) => {
vec![ailang_check::Diagnostic::error(
"schema-mismatch",
format!(
"schema mismatch: expected {expected:?}, got {got:?}"
),
)
.with_ctx(serde_json::json!({
"expected": expected,
"actual": got,
}))]
}
Err(e) => return Err(anyhow::anyhow!(e)),
};
println!("{}", serde_json::to_string(&diags)?);
if diags
.iter()
.any(|d| matches!(d.severity, ailang_check::Severity::Error))
{
std::process::exit(1);
}
} else {
let m = ailang_core::load_module(&path)?;
let r = ailang_check::check(&m)?;
println!("ok ({} symbols)", r.symbols.len());
}
} }
Cmd::EmitIr { path, out } => { Cmd::EmitIr { path, out } => {
let m = ailang_core::load_module(&path)?; let m = ailang_core::load_module(&path)?;
+31
View File
@@ -63,3 +63,34 @@ fn list_sum_via_match() {
let stdout = build_and_run("list.ail.json"); let stdout = build_and_run("list.ail.json");
assert_eq!(stdout.trim(), "42"); assert_eq!(stdout.trim(), "42");
} }
/// Schützt das `--json`-Diagnostic-Format für Tooling-Konsumenten.
/// `broken_unbound.ail.json` referenziert eine nicht-existente Variable;
/// erwartet wird Exit-Code 1 und mindestens ein Diagnostic mit
/// `severity == "error"` und `code == "unbound-var"`.
#[test]
fn check_json_unbound_var() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let src = workspace.join("examples").join("broken_unbound.ail.json");
let output = Command::new(ail_bin())
.args(["check", src.to_str().unwrap(), "--json"])
.output()
.expect("ail check --json failed to run");
let code = output.status.code().expect("process terminated by signal");
assert_eq!(code, 1, "expected exit code 1, stderr: {}", String::from_utf8_lossy(&output.stderr));
let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
let diags: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON");
let arr = diags.as_array().expect("diagnostics must be a JSON array");
assert!(
arr.iter().any(|d| {
d.get("severity").and_then(|v| v.as_str()) == Some("error")
&& d.get("code").and_then(|v| v.as_str()) == Some("unbound-var")
}),
"expected at least one error diagnostic with code unbound-var; got: {stdout}"
);
}
+2
View File
@@ -8,3 +8,5 @@ license.workspace = true
ailang-core.workspace = true ailang-core.workspace = true
thiserror.workspace = true thiserror.workspace = true
indexmap.workspace = true indexmap.workspace = true
serde.workspace = true
serde_json.workspace = true
+105
View File
@@ -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}");
}
}
+132 -6
View File
@@ -11,6 +11,9 @@ use indexmap::IndexMap;
use std::collections::BTreeSet; use std::collections::BTreeSet;
pub mod builtins; pub mod builtins;
pub mod diagnostic;
pub use diagnostic::{Diagnostic, Severity};
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum CheckError { pub enum CheckError {
@@ -61,6 +64,9 @@ pub enum CheckError {
#[error("type `{ty}` has no constructor `{ctor}`")] #[error("type `{ty}` has no constructor `{ctor}`")]
UnknownCtor { ty: String, ctor: String }, UnknownCtor { ty: String, ctor: String },
#[error("unknown constructor `{0}` in pattern")]
UnknownCtorInPattern(String),
#[error("constructor `{ty}/{ctor}` arity: expected {expected} fields, got {got}")] #[error("constructor `{ty}/{ctor}` arity: expected {expected} fields, got {got}")]
CtorArity { CtorArity {
ty: String, ty: String,
@@ -83,10 +89,116 @@ pub enum CheckError {
#[error("duplicate constructor: `{ctor}` (in types `{a}` and `{b}`)")] #[error("duplicate constructor: `{ctor}` (in types `{a}` and `{b}`)")]
DuplicateCtor { ctor: String, a: String, b: String }, 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>; 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 /// Ergebnis der Typprüfung eines Moduls: Mapping vom Symbolnamen zum
/// (Typ, Hash) — bereit für `manifest`-Ausgabe. /// (Typ, Hash) — bereit für `manifest`-Ausgabe.
#[derive(Debug, Clone)] #[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 { for def in &m.defs {
match def { match def {
Def::Fn(f) => { 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()); env.globals.insert(f.name.clone(), f.ty.clone());
} }
Def::Const(c) => { 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()); env.globals.insert(c.name.clone(), c.ty.clone());
} }
Def::Type(_) => {} Def::Type(_) => {}
@@ -485,12 +605,18 @@ fn type_check_pattern(
Ok(vec![]) Ok(vec![])
} }
Pattern::Ctor { ctor, fields } => { Pattern::Ctor { ctor, fields } => {
let cref = env.ctor_index.get(ctor).ok_or_else(|| { // MVP: Sub-Patterns von Ctor-Patterns dürfen nur `Var` oder `Wild`
CheckError::UnknownCtor { // sein. Nested Ctor- oder Lit-Patterns brauchen ein
ty: "<unknown>".into(), // Decision-Tree-Lowering, das wir im Codegen noch nicht haben.
ctor: ctor.clone(), 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. // expected muss diese ADT sein.
match expected { match expected {
Type::Con { name } if name == &cref.type_name => {} Type::Con { name } if name == &cref.type_name => {}
+20
View File
@@ -0,0 +1,20 @@
{
"schema": "ailang/v0",
"name": "broken_unbound",
"imports": [],
"defs": [
{
"kind": "fn",
"name": "main",
"type": {
"k": "fn",
"params": [],
"ret": { "k": "con", "name": "Int" },
"effects": []
},
"params": [],
"doc": "Referenziert eine nicht-existente Variable -> unbound-var.",
"body": { "t": "var", "name": "does_not_exist" }
}
]
}