diff --git a/Cargo.lock b/Cargo.lock index 42f059f..03d1bff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,6 +20,8 @@ version = "0.0.1" dependencies = [ "ailang-core", "indexmap", + "serde", + "serde_json", "thiserror", ] diff --git a/crates/ail/src/main.rs b/crates/ail/src/main.rs index e0579c1..08d7a5e 100644 --- a/crates/ail/src/main.rs +++ b/crates/ail/src/main.rs @@ -42,7 +42,13 @@ enum Cmd { json: bool, }, /// 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. EmitIr { path: PathBuf, @@ -163,10 +169,41 @@ fn main() -> Result<()> { print!("{}", ailang_core::pretty::module(&one)); } } - Cmd::Check { path } => { - let m = ailang_core::load_module(&path)?; - let r = ailang_check::check(&m)?; - println!("ok ({} symbols)", r.symbols.len()); + Cmd::Check { path, json } => { + if json { + // JSON-Modus: stdout enthält ausschließlich das Diagnostics- + // 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 } => { let m = ailang_core::load_module(&path)?; diff --git a/crates/ail/tests/e2e.rs b/crates/ail/tests/e2e.rs index 63e0dc0..cbe19aa 100644 --- a/crates/ail/tests/e2e.rs +++ b/crates/ail/tests/e2e.rs @@ -63,3 +63,34 @@ fn list_sum_via_match() { let stdout = build_and_run("list.ail.json"); 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}" + ); +} diff --git a/crates/ailang-check/Cargo.toml b/crates/ailang-check/Cargo.toml index 9872bc2..f5f93dd 100644 --- a/crates/ailang-check/Cargo.toml +++ b/crates/ailang-check/Cargo.toml @@ -8,3 +8,5 @@ license.workspace = true ailang-core.workspace = true thiserror.workspace = true indexmap.workspace = true +serde.workspace = true +serde_json.workspace = true diff --git a/crates/ailang-check/src/diagnostic.rs b/crates/ailang-check/src/diagnostic.rs new file mode 100644 index 0000000..20a9a13 --- /dev/null +++ b/crates/ailang-check/src/diagnostic.rs @@ -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, + /// Freier strukturierter Kontext. Leer = `{}`. + pub ctx: serde_json::Value, +} + +impl Diagnostic { + 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()), + } + } + + pub fn with_def(mut self, def: impl Into) -> 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}"); + } +} diff --git a/crates/ailang-check/src/lib.rs b/crates/ailang-check/src/lib.rs index 6ffdd0e..131612f 100644 --- a/crates/ailang-check/src/lib.rs +++ b/crates/ailang-check/src/lib.rs @@ -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 = std::result::Result; +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 { + 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 { } } - // 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: "".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 => {} diff --git a/examples/broken_unbound.ail.json b/examples/broken_unbound.ail.json new file mode 100644 index 0000000..7cb6ec2 --- /dev/null +++ b/examples/broken_unbound.ail.json @@ -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" } + } + ] +}