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:
Generated
+2
@@ -20,6 +20,8 @@ version = "0.0.1"
|
||||
dependencies = [
|
||||
"ailang-core",
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
|
||||
+42
-5
@@ -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)?;
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,3 +8,5 @@ license.workspace = true
|
||||
ailang-core.workspace = true
|
||||
thiserror.workspace = true
|
||||
indexmap.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -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 => {}
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user