diff --git a/crates/decodal-cli/src/main.rs b/crates/decodal-cli/src/main.rs index 7aa0626..024e946 100644 --- a/crates/decodal-cli/src/main.rs +++ b/crates/decodal-cli/src/main.rs @@ -148,6 +148,15 @@ fn print_diagnostic(error: &Diagnostic) { end = error.span.end, message = error.message, ); + for label in &error.labels { + eprintln!( + " note {source}:{start}..{end}: {message}", + source = label.span.source.0, + start = label.span.start, + end = label.span.end, + message = label.message, + ); + } } fn print_data(data: &Data, indent: usize) { diff --git a/crates/decodal-core/src/constraints.rs b/crates/decodal-core/src/constraints.rs index 8f7c748..3ba6098 100644 --- a/crates/decodal-core/src/constraints.rs +++ b/crates/decodal-core/src/constraints.rs @@ -3,103 +3,154 @@ use alloc::{string::String, vec::Vec}; use crate::{ Diagnostic, DiagnosticKind, Span, ast::CompareOp, - runtime::{Constraint, LiteralValue, PrimitiveType}, + runtime::{Constraint, ConstraintEntry, LiteralValue, PrimitiveType}, }; pub fn normalize_constraints( - constraints: Vec, + constraints: Vec, span: Span, -) -> crate::Result> { - let mut primitive = None; - let mut lower: Option = None; - let mut upper: Option = None; +) -> crate::Result> { + let mut primitive: Option<(PrimitiveType, Span)> = None; + let mut lower: Option<(Bound, Span)> = None; + let mut upper: Option<(Bound, Span)> = None; let mut rest = Vec::new(); - for constraint in constraints { - match constraint { + for entry in constraints { + match entry.constraint { Constraint::Type(next) => match primitive { - Some(current) if current != next => { + Some((current, current_span)) if current != next => { return Err(Diagnostic::new( DiagnosticKind::Conflict, span, "primitive type constraints conflict", - )); + ) + .with_label(current_span, "first primitive constraint") + .with_label(entry.span, "conflicting primitive constraint")); } Some(_) => {} - None => primitive = Some(next), + None => primitive = Some((next, entry.span)), }, Constraint::Compare(op, value) => { let number = Number::from_literal(&value).ok_or_else(|| { Diagnostic::new( DiagnosticKind::TypeMismatch, - span, + entry.span, "comparison constraints require numeric literals", ) })?; match op { - CompareOp::Gt => merge_lower(&mut lower, Bound::new(number, false)), - CompareOp::Gte => merge_lower(&mut lower, Bound::new(number, true)), - CompareOp::Lt => merge_upper(&mut upper, Bound::new(number, false)), - CompareOp::Lte => merge_upper(&mut upper, Bound::new(number, true)), + CompareOp::Gt => { + merge_lower(&mut lower, (Bound::new(number, false), entry.span)) + } + CompareOp::Gte => { + merge_lower(&mut lower, (Bound::new(number, true), entry.span)) + } + CompareOp::Lt => { + merge_upper(&mut upper, (Bound::new(number, false), entry.span)) + } + CompareOp::Lte => { + merge_upper(&mut upper, (Bound::new(number, true), entry.span)) + } CompareOp::Eq => { - merge_lower(&mut lower, Bound::new(number, true)); - merge_upper(&mut upper, Bound::new(number, true)); + merge_lower(&mut lower, (Bound::new(number, true), entry.span)); + merge_upper(&mut upper, (Bound::new(number, true), entry.span)); } } } - Constraint::Regex(pattern) => rest.push(Constraint::Regex(pattern)), - Constraint::BuiltinPredicate(name) => rest.push(Constraint::BuiltinPredicate(name)), + Constraint::Regex(pattern) => rest.push(ConstraintEntry { + constraint: Constraint::Regex(pattern), + span: entry.span, + }), + Constraint::BuiltinPredicate(name) => rest.push(ConstraintEntry { + constraint: Constraint::BuiltinPredicate(name), + span: entry.span, + }), } } - if matches!(primitive, Some(PrimitiveType::String | PrimitiveType::Bool)) - && (lower.is_some() || upper.is_some()) + if matches!( + primitive, + Some((PrimitiveType::String | PrimitiveType::Bool, _)) + ) && (lower.is_some() || upper.is_some()) { - return Err(Diagnostic::new( + let mut diagnostic = Diagnostic::new( DiagnosticKind::Conflict, span, "numeric comparison constraints conflict with non-numeric primitive type", - )); + ); + if let Some((_, primitive_span)) = primitive { + diagnostic = diagnostic.with_label(primitive_span, "non-numeric primitive constraint"); + } + if let Some((_, lower_span)) = lower { + diagnostic = diagnostic.with_label(lower_span, "numeric comparison constraint"); + } + if let Some((_, upper_span)) = upper { + diagnostic = diagnostic.with_label(upper_span, "numeric comparison constraint"); + } + return Err(diagnostic); } - if primitive == Some(PrimitiveType::Int) + if matches!(primitive, Some((PrimitiveType::Int, _))) && lower .iter() .chain(upper.iter()) - .any(|bound| !matches!(bound.number, Number::Int(_))) + .any(|(bound, _)| !matches!(bound.number, Number::Int(_))) { - return Err(Diagnostic::new( + let mut diagnostic = Diagnostic::new( DiagnosticKind::Conflict, span, "Int comparison constraints must use integer literals", - )); + ); + if let Some((_, primitive_span)) = primitive { + diagnostic = diagnostic.with_label(primitive_span, "Int primitive constraint"); + } + if let Some((bound, bound_span)) = lower { + if !matches!(bound.number, Number::Int(_)) { + diagnostic = diagnostic.with_label(bound_span, "non-integer comparison constraint"); + } + } + if let Some((bound, bound_span)) = upper { + if !matches!(bound.number, Number::Int(_)) { + diagnostic = diagnostic.with_label(bound_span, "non-integer comparison constraint"); + } + } + return Err(diagnostic); } ensure_bounds_non_empty(primitive, lower, upper, span)?; let mut normalized = Vec::new(); - if let Some(primitive) = primitive { - normalized.push(Constraint::Type(primitive)); + if let Some((primitive, primitive_span)) = primitive { + normalized.push(ConstraintEntry { + constraint: Constraint::Type(primitive), + span: primitive_span, + }); } - if let Some(lower) = lower { - normalized.push(Constraint::Compare( - if lower.inclusive { - CompareOp::Gte - } else { - CompareOp::Gt - }, - lower.number.into_literal(), - )); + if let Some((lower, lower_span)) = lower { + normalized.push(ConstraintEntry { + constraint: Constraint::Compare( + if lower.inclusive { + CompareOp::Gte + } else { + CompareOp::Gt + }, + lower.number.into_literal(), + ), + span: lower_span, + }); } - if let Some(upper) = upper { - normalized.push(Constraint::Compare( - if upper.inclusive { - CompareOp::Lte - } else { - CompareOp::Lt - }, - upper.number.into_literal(), - )); + if let Some((upper, upper_span)) = upper { + normalized.push(ConstraintEntry { + constraint: Constraint::Compare( + if upper.inclusive { + CompareOp::Lte + } else { + CompareOp::Lt + }, + upper.number.into_literal(), + ), + span: upper_span, + }); } normalized.extend(rest); Ok(normalized) @@ -147,18 +198,22 @@ impl Number { } } -fn merge_lower(current: &mut Option, next: Bound) { +fn merge_lower(current: &mut Option<(Bound, Span)>, next: (Bound, Span)) { match current { None => *current = Some(next), - Some(current_bound) if is_stricter_lower(next, *current_bound) => *current_bound = next, + Some((current_bound, _)) if is_stricter_lower(next.0, *current_bound) => { + *current = Some(next) + } Some(_) => {} } } -fn merge_upper(current: &mut Option, next: Bound) { +fn merge_upper(current: &mut Option<(Bound, Span)>, next: (Bound, Span)) { match current { None => *current = Some(next), - Some(current_bound) if is_stricter_upper(next, *current_bound) => *current_bound = next, + Some((current_bound, _)) if is_stricter_upper(next.0, *current_bound) => { + *current = Some(next) + } Some(_) => {} } } @@ -178,28 +233,32 @@ fn is_stricter_upper(next: Bound, current: Bound) -> bool { } fn ensure_bounds_non_empty( - primitive: Option, - lower: Option, - upper: Option, + primitive: Option<(PrimitiveType, Span)>, + lower: Option<(Bound, Span)>, + upper: Option<(Bound, Span)>, span: Span, ) -> crate::Result<()> { - if primitive == Some(PrimitiveType::Int) { - let min = lower.map(int_lower_bound).unwrap_or(i128::from(i64::MIN)); - let max = upper.map(int_upper_bound).unwrap_or(i128::from(i64::MAX)); + if matches!(primitive, Some((PrimitiveType::Int, _))) { + let min = lower + .map(|(bound, _)| int_lower_bound(bound)) + .unwrap_or(i128::from(i64::MIN)); + let max = upper + .map(|(bound, _)| int_upper_bound(bound)) + .unwrap_or(i128::from(i64::MAX)); if min > max { - return Err(empty_numeric_bounds(span)); + return Err(empty_numeric_bounds(span, lower, upper)); } return Ok(()); } - if let (Some(lower), Some(upper)) = (lower, upper) { - let lower_value = lower.number.as_f64(); - let upper_value = upper.number.as_f64(); + if let (Some((lower_bound, _)), Some((upper_bound, _))) = (lower, upper) { + let lower_value = lower_bound.number.as_f64(); + let upper_value = upper_bound.number.as_f64(); if lower_value > upper_value { - return Err(empty_numeric_bounds(span)); + return Err(empty_numeric_bounds(span, lower, upper)); } - if lower_value == upper_value && !(lower.inclusive && upper.inclusive) { - return Err(empty_numeric_bounds(span)); + if lower_value == upper_value && !(lower_bound.inclusive && upper_bound.inclusive) { + return Err(empty_numeric_bounds(span, lower, upper)); } } Ok(()) @@ -227,12 +286,23 @@ fn int_upper_bound(bound: Bound) -> i128 { } } -fn empty_numeric_bounds(span: Span) -> Diagnostic { - Diagnostic::new( +fn empty_numeric_bounds( + span: Span, + lower: Option<(Bound, Span)>, + upper: Option<(Bound, Span)>, +) -> Diagnostic { + let mut diagnostic = Diagnostic::new( DiagnosticKind::Conflict, span, String::from("numeric comparison constraints have an empty intersection"), - ) + ); + if let Some((_, lower_span)) = lower { + diagnostic = diagnostic.with_label(lower_span, "lower bound constraint"); + } + if let Some((_, upper_span)) = upper { + diagnostic = diagnostic.with_label(upper_span, "upper bound constraint"); + } + diagnostic } #[cfg(test)] @@ -240,13 +310,20 @@ mod tests { use super::*; use crate::runtime::LiteralValue; + fn entry(constraint: Constraint) -> ConstraintEntry { + ConstraintEntry { + constraint, + span: Span::default(), + } + } + #[test] fn detects_primitive_conflict() { assert!( normalize_constraints( alloc::vec![ - Constraint::Type(PrimitiveType::Int), - Constraint::Type(PrimitiveType::String), + entry(Constraint::Type(PrimitiveType::Int)), + entry(Constraint::Type(PrimitiveType::String)), ], Span::default(), ) @@ -259,9 +336,9 @@ mod tests { assert!( normalize_constraints( alloc::vec![ - Constraint::Type(PrimitiveType::Int), - Constraint::Compare(CompareOp::Gt, LiteralValue::Int(10)), - Constraint::Compare(CompareOp::Lt, LiteralValue::Int(11)), + entry(Constraint::Type(PrimitiveType::Int)), + entry(Constraint::Compare(CompareOp::Gt, LiteralValue::Int(10))), + entry(Constraint::Compare(CompareOp::Lt, LiteralValue::Int(5))), ], Span::default(), ) @@ -270,15 +347,16 @@ mod tests { } #[test] - fn keeps_regex_constraints_without_intersection_check() { - let constraints = normalize_constraints( - alloc::vec![ - Constraint::Regex(String::from("^a$")), - Constraint::Regex(String::from("^b$")), - ], - Span::default(), - ) - .unwrap(); - assert_eq!(constraints.len(), 2); + fn detects_non_integer_int_bound() { + assert!( + normalize_constraints( + alloc::vec![ + entry(Constraint::Type(PrimitiveType::Int)), + entry(Constraint::Compare(CompareOp::Gt, LiteralValue::Float(1.5))), + ], + Span::default(), + ) + .is_err() + ); } } diff --git a/crates/decodal-core/src/diagnostic.rs b/crates/decodal-core/src/diagnostic.rs index 033bb81..2caf4c2 100644 --- a/crates/decodal-core/src/diagnostic.rs +++ b/crates/decodal-core/src/diagnostic.rs @@ -1,4 +1,4 @@ -use alloc::string::String; +use alloc::{string::String, vec::Vec}; use crate::span::Span; @@ -9,6 +9,13 @@ pub struct Diagnostic { pub kind: DiagnosticKind, pub span: Span, pub message: String, + pub labels: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiagnosticLabel { + pub span: Span, + pub message: String, } impl Diagnostic { @@ -17,12 +24,21 @@ impl Diagnostic { kind, span, message: message.into(), + labels: Vec::new(), } } pub fn syntax(span: Span, message: impl Into) -> Self { Self::new(DiagnosticKind::Syntax, span, message) } + + pub fn with_label(mut self, span: Span, message: impl Into) -> Self { + self.labels.push(DiagnosticLabel { + span, + message: message.into(), + }); + self + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/decodal-core/src/eval.rs b/crates/decodal-core/src/eval.rs index d376ff2..6220ad6 100644 --- a/crates/decodal-core/src/eval.rs +++ b/crates/decodal-core/src/eval.rs @@ -9,9 +9,9 @@ use crate::{ module::{EmptyLoader, LoadedSource, Module, SourceLoader}, parse_source_with_source_id, runtime::{ - AbstractValue, Binding, ConcreteValue, Constraint, Data, DataField, Env, EnvId, ExprRef, - FunctionParam, FunctionValue, LiteralValue, ModuleId, ObjectField, ObjectValue, - PrimitiveType, RuntimeValue, Thunk, ThunkId, ThunkKind, ThunkState, + AbstractValue, Binding, ConcreteValue, Constraint, ConstraintEntry, Data, DataField, Env, + EnvId, ExprRef, FunctionParam, FunctionValue, LiteralValue, ModuleId, ObjectField, + ObjectValue, PrimitiveType, RuntimeValue, Thunk, ThunkId, ThunkKind, ThunkState, }, }; @@ -84,6 +84,14 @@ impl Engine { } pub fn materialize(&mut self, value: &RuntimeValue) -> Result { + self.materialize_with_path(value, &mut Vec::new()) + } + + fn materialize_with_path( + &mut self, + value: &RuntimeValue, + path: &mut Vec, + ) -> Result { match value { RuntimeValue::Concrete(value) => match value { ConcreteValue::String(value) => Ok(Data::String(value.clone())), @@ -94,7 +102,7 @@ impl Engine { let mut data = Vec::new(); for item in items { let value = self.force(*item)?; - data.push(self.materialize(&value)?); + data.push(self.materialize_with_path(&value, path)?); } Ok(Data::Array(data)) } @@ -102,30 +110,51 @@ impl Engine { let mut fields = Vec::new(); for field in &object.fields { let value = self.force(field.value)?; + path.push(field.name.clone()); + let value = self.materialize_with_path(&value, path); + path.pop(); fields.push(DataField { name: field.name.clone(), - value: self.materialize(&value)?, + value: value?, }); } Ok(Data::Object(fields)) } - ConcreteValue::Function(_) => Err(Diagnostic::new( - DiagnosticKind::Materialize, - Span::default(), - "cannot materialize function value", + ConcreteValue::Function(_) => Err(self.with_path_context( + Diagnostic::new( + DiagnosticKind::Materialize, + Span::default(), + "cannot materialize function value", + ), + "materializing", + path, )), }, RuntimeValue::Abstract(abstract_value) => { let Some(default) = abstract_value.default else { - return Err(Diagnostic::new( + let mut diagnostic = Diagnostic::new( DiagnosticKind::Materialize, - Span::default(), + constraint_primary_span(&abstract_value.constraints), "cannot materialize unresolved abstract value without default", - )); + ); + for constraint in &abstract_value.constraints { + diagnostic = diagnostic.with_label( + constraint.span, + "constraint still requires a concrete value or default", + ); + } + return Err(self.with_path_context(diagnostic, "materializing", path)); }; + let default_span = self.thunk_span(default); let value = self.force(default)?; - self.ensure_satisfies(&value, &abstract_value.constraints, Span::default())?; - self.materialize(&value) + self.ensure_satisfies( + &value, + &abstract_value.constraints, + default_span, + Some((default_span, "default value checked here")), + ) + .map_err(|diag| self.with_path_context(diag, "materializing", path))?; + self.materialize_with_path(&value, path) } } } @@ -475,12 +504,18 @@ impl Engine { ) })?; Ok(RuntimeValue::Abstract(AbstractValue { - constraints: vec![Constraint::Compare(op, value)], + constraints: vec![ConstraintEntry { + constraint: Constraint::Compare(op, value), + span, + }], default: None, })) } Expr::RegexConstraint(pattern) => Ok(RuntimeValue::Abstract(AbstractValue { - constraints: vec![Constraint::Regex(pattern)], + constraints: vec![ConstraintEntry { + constraint: Constraint::Regex(pattern), + span, + }], default: None, })), Expr::Wildcard => Err(Diagnostic::new( @@ -494,7 +529,10 @@ impl Engine { fn eval_ident(&mut self, name: &str, env: EnvId, span: Span) -> Result { if let Some(primitive) = primitive_type(name) { return Ok(RuntimeValue::Abstract(AbstractValue { - constraints: vec![Constraint::Type(primitive)], + constraints: vec![ConstraintEntry { + constraint: Constraint::Type(primitive), + span, + }], default: None, })); } @@ -503,7 +541,10 @@ impl Engine { } if name.chars().next().is_some_and(char::is_uppercase) { return Ok(RuntimeValue::Abstract(AbstractValue { - constraints: vec![Constraint::BuiltinPredicate(String::from(name))], + constraints: vec![ConstraintEntry { + constraint: Constraint::BuiltinPredicate(String::from(name)), + span, + }], default: None, })); } @@ -551,7 +592,7 @@ impl Engine { )); } if path.len() == 1 { - let value = self.add_expr_thunk(ExprRef { module, expr }, env); + let value = self.add_expr_thunk_with_span(ExprRef { module, expr }, env, span); if object.fields.iter().any(|field| field.name == path[0]) { return Err(Diagnostic::new( DiagnosticKind::Conflict, @@ -562,6 +603,7 @@ impl Engine { object.fields.push(ObjectField { name: path[0].clone(), value, + span, }); return Ok(()); } @@ -577,8 +619,10 @@ impl Engine { )); }; self.insert_field(&mut nested, module, &path[1..], expr, env, span)?; - object.fields[index].value = - self.add_value_thunk(RuntimeValue::Concrete(ConcreteValue::Object(nested))); + object.fields[index].value = self.add_value_thunk_with_span( + RuntimeValue::Concrete(ConcreteValue::Object(nested)), + span, + ); return Ok(()); } @@ -586,7 +630,11 @@ impl Engine { self.insert_field(&mut nested, module, &path[1..], expr, env, span)?; object.fields.push(ObjectField { name: name.clone(), - value: self.add_value_thunk(RuntimeValue::Concrete(ConcreteValue::Object(nested))), + value: self.add_value_thunk_with_span( + RuntimeValue::Concrete(ConcreteValue::Object(nested)), + span, + ), + span, }); Ok(()) } @@ -646,6 +694,7 @@ impl Engine { value, &abstract_value.constraints, self.expr_span(pattern), + None, ) .map(|_| true) .or_else(|diag| match diag.kind { @@ -673,12 +722,17 @@ impl Engine { (RuntimeValue::Abstract(mut lhs), RuntimeValue::Abstract(rhs)) => { lhs.constraints.extend(rhs.constraints); lhs.constraints = normalize_constraints(lhs.constraints, span)?; - lhs.default = merge_default(lhs.default, rhs.default, span)?; + lhs.default = self.merge_default(lhs.default, rhs.default, span)?; Ok(RuntimeValue::Abstract(lhs)) } (RuntimeValue::Abstract(abstract_value), concrete @ RuntimeValue::Concrete(_)) | (concrete @ RuntimeValue::Concrete(_), RuntimeValue::Abstract(abstract_value)) => { - self.ensure_satisfies(&concrete, &abstract_value.constraints, span)?; + self.ensure_satisfies( + &concrete, + &abstract_value.constraints, + span, + Some((span, "concrete value being composed here")), + )?; Ok(concrete) } ( @@ -711,10 +765,17 @@ impl Engine { .iter() .position(|lhs_field| lhs_field.name == rhs_field.name) { + let lhs_field_span = lhs.fields[index].span; let lhs_value = self.force(lhs.fields[index].value)?; let rhs_value = self.force(rhs_field.value)?; - let value = self.compose_and(lhs_value, rhs_value, span)?; - lhs.fields[index].value = self.add_value_thunk(value); + let value = self + .compose_and(lhs_value, rhs_value, span) + .map_err(|diag| { + diag.with_label(span, format!("while composing field `{}`", rhs_field.name)) + .with_label(lhs_field_span, format!("left field `{}`", rhs_field.name)) + .with_label(rhs_field.span, format!("right field `{}`", rhs_field.name)) + })?; + lhs.fields[index].value = self.add_value_thunk_with_span(value, rhs_field.span); } else { lhs.fields.push(rhs_field); } @@ -742,7 +803,8 @@ impl Engine { let lhs_value = self.force(lhs.fields[index].value)?; let rhs_value = self.force(rhs_field.value)?; let value = self.patch(lhs_value, rhs_value)?; - lhs.fields[index].value = self.add_value_thunk(value); + lhs.fields[index].value = self.add_value_thunk_with_span(value, rhs_field.span); + lhs.fields[index].span = rhs_field.span; } else { lhs.fields.push(rhs_field); } @@ -753,11 +815,12 @@ impl Engine { fn ensure_satisfies( &mut self, value: &RuntimeValue, - constraints: &[Constraint], + constraints: &[ConstraintEntry], span: Span, + value_label: Option<(Span, &'static str)>, ) -> Result<()> { for constraint in constraints { - self.satisfies(value, constraint, span)?; + self.satisfies(value, constraint, span, value_label)?; } Ok(()) } @@ -765,17 +828,20 @@ impl Engine { fn satisfies( &mut self, value: &RuntimeValue, - constraint: &Constraint, + constraint: &ConstraintEntry, span: Span, + value_label: Option<(Span, &'static str)>, ) -> Result<()> { - match constraint { + let constraint_value = &constraint.constraint; + match constraint_value { Constraint::Type(primitive) => { if value_matches_primitive(value, *primitive) { Ok(()) } else { - Err(Diagnostic::new( - DiagnosticKind::ConstraintViolation, + Err(self.constraint_violation( + constraint, span, + value_label, "value does not satisfy primitive type constraint", )) } @@ -783,18 +849,69 @@ impl Engine { Constraint::Compare(op, expected) => compare_value(value, *op, expected) .then_some(()) .ok_or_else(|| { - Diagnostic::new( - DiagnosticKind::ConstraintViolation, + self.constraint_violation( + constraint, span, + value_label, "value does not satisfy comparison constraint", ) }), - Constraint::Regex(pattern) => satisfies_regex(value, pattern, span), + Constraint::Regex(pattern) => { + satisfies_regex(value, pattern, constraint.span).map_err(|diag| { + let diag = diag.with_label(constraint.span, "regex constraint declared here"); + if let Some((span, message)) = value_label { + diag.with_label(span, message) + } else { + diag + } + }) + } Constraint::BuiltinPredicate(name) => Err(Diagnostic::new( DiagnosticKind::UnsupportedFeature, - span, + constraint.span, format!("builtin predicate `{name}` is not implemented"), - )), + ) + .with_label(span, "constraint checked during this operation")), + } + } + + fn constraint_violation( + &self, + constraint: &ConstraintEntry, + operation_span: Span, + value_label: Option<(Span, &'static str)>, + message: &'static str, + ) -> Diagnostic { + let mut diagnostic = Diagnostic::new( + DiagnosticKind::ConstraintViolation, + constraint.span, + message, + ) + .with_label(constraint.span, "constraint declared here") + .with_label(operation_span, "constraint checked during this operation"); + if let Some((span, message)) = value_label { + diagnostic = diagnostic.with_label(span, message); + } + diagnostic + } + + fn merge_default( + &self, + lhs: Option, + rhs: Option, + span: Span, + ) -> Result> { + match (lhs, rhs) { + (None, None) => Ok(None), + (Some(value), None) | (None, Some(value)) => Ok(Some(value)), + (Some(lhs), Some(rhs)) if lhs == rhs => Ok(Some(lhs)), + (Some(lhs), Some(rhs)) => Err(Diagnostic::new( + DiagnosticKind::DefaultConflict, + span, + "conflicting defaults", + ) + .with_label(self.thunk_span(lhs), "left default value") + .with_label(self.thunk_span(rhs), "right default value")), } } @@ -831,6 +948,7 @@ impl Engine { object.fields.push(ObjectField { name: field.name, value, + span: Span::default(), }); } Ok(RuntimeValue::Concrete(ConcreteValue::Object(object))) @@ -845,6 +963,13 @@ impl Engine { } else { None }; + let constraints = constraints + .into_iter() + .map(|constraint| ConstraintEntry { + constraint, + span: Span::default(), + }) + .collect(); let constraints = normalize_constraints(constraints, Span::default())?; Ok(RuntimeValue::Abstract(AbstractValue { constraints, @@ -906,7 +1031,12 @@ impl Engine { } fn add_expr_thunk(&mut self, expr: ExprRef, env: EnvId) -> ThunkId { - self.add_thunk(ThunkKind::Expr { expr, env }) + let span = self.expr_span(expr); + self.add_expr_thunk_with_span(expr, env, span) + } + + fn add_expr_thunk_with_span(&mut self, expr: ExprRef, env: EnvId, span: Span) -> ThunkId { + self.add_thunk(ThunkKind::Expr { expr, env }, span) } fn add_constrained_thunk( @@ -916,23 +1046,32 @@ impl Engine { value: ExprRef, value_env: EnvId, ) -> ThunkId { - self.add_thunk(ThunkKind::Constrained { - constraint, - constraint_env, - value, - value_env, - }) + let span = self.expr_span(value); + self.add_thunk( + ThunkKind::Constrained { + constraint, + constraint_env, + value, + value_env, + }, + span, + ) } fn add_value_thunk(&mut self, value: RuntimeValue) -> ThunkId { - self.add_thunk(ThunkKind::Value(value)) + self.add_value_thunk_with_span(value, Span::default()) } - fn add_thunk(&mut self, kind: ThunkKind) -> ThunkId { + fn add_value_thunk_with_span(&mut self, value: RuntimeValue, span: Span) -> ThunkId { + self.add_thunk(ThunkKind::Value(value), span) + } + + fn add_thunk(&mut self, kind: ThunkKind, span: Span) -> ThunkId { let id = ThunkId(self.thunks.len() as u32); self.thunks.push(Thunk { kind, state: ThunkState::Unevaluated, + span, }); id } @@ -977,9 +1116,28 @@ impl Engine { } fn expr_span(&self, reference: ExprRef) -> Span { - self.modules[reference.module.0 as usize] - .ast - .span(reference.expr) + self.modules + .get(reference.module.0 as usize) + .map(|module| module.ast.span(reference.expr)) + .unwrap_or_default() + } + + fn thunk_span(&self, thunk: ThunkId) -> Span { + self.thunks[thunk.0 as usize].span + } + + fn with_path_context( + &self, + diagnostic: Diagnostic, + phase: &'static str, + path: &[String], + ) -> Diagnostic { + if path.is_empty() { + diagnostic + } else { + let span = diagnostic.span; + diagnostic.with_label(span, format!("while {phase} `{}`", path.join("."))) + } } } @@ -1005,6 +1163,13 @@ fn primitive_type(name: &str) -> Option { } } +fn constraint_primary_span(constraints: &[ConstraintEntry]) -> Span { + constraints + .first() + .map(|constraint| constraint.span) + .unwrap_or_default() +} + fn literal_to_concrete(literal: Literal) -> ConcreteValue { match literal { Literal::String(value) => ConcreteValue::String(value), @@ -1343,23 +1508,6 @@ fn concrete_scalar_eq(lhs: &ConcreteValue, rhs: &ConcreteValue) -> bool { } } -fn merge_default( - lhs: Option, - rhs: Option, - span: Span, -) -> Result> { - match (lhs, rhs) { - (None, None) => Ok(None), - (Some(value), None) | (None, Some(value)) => Ok(Some(value)), - (Some(lhs), Some(rhs)) if lhs == rhs => Ok(Some(lhs)), - (Some(_), Some(_)) => Err(Diagnostic::new( - DiagnosticKind::DefaultConflict, - span, - "conflicting defaults", - )), - } -} - #[cfg(test)] mod tests { use super::*; @@ -1372,6 +1520,62 @@ mod tests { engine.materialize(&value).unwrap() } + #[test] + fn constraint_violation_reports_related_spans() { + let parsed = parse_source("port = Int & > 443 default 80;").unwrap(); + let mut engine = Engine::from_parse(parsed.ast, parsed.root); + let value = engine.eval_root().unwrap(); + let error = engine.materialize(&value).unwrap_err(); + assert_eq!(error.kind, DiagnosticKind::ConstraintViolation); + assert!( + error + .labels + .iter() + .any(|label| label.message.contains("constraint declared here")) + ); + assert!( + error + .labels + .iter() + .any(|label| label.message.contains("default value checked here")) + ); + } + + #[test] + fn object_composition_conflict_reports_both_fields() { + let parsed = parse_source("{ port = 80; } & { port = 443; }").unwrap(); + let mut engine = Engine::from_parse(parsed.ast, parsed.root); + let error = engine.eval_root().unwrap_err(); + assert_eq!(error.kind, DiagnosticKind::Conflict); + assert!( + error + .labels + .iter() + .any(|label| label.message == "left field `port`") + ); + assert!( + error + .labels + .iter() + .any(|label| label.message == "right field `port`") + ); + } + + #[test] + fn materialize_error_reports_field_path() { + let parsed = parse_source("service.port = Int;").unwrap(); + let mut engine = Engine::from_parse(parsed.ast, parsed.root); + let value = engine.eval_root().unwrap(); + let error = engine.materialize(&value).unwrap_err(); + assert_eq!(error.kind, DiagnosticKind::Materialize); + assert!( + error + .labels + .iter() + .any(|label| label.message.contains("while materializing `service.port`")) + ); + } + #[test] fn materializes_default() { let data = eval_data("port = Int & >= 1 default 8080;"); diff --git a/crates/decodal-core/src/runtime.rs b/crates/decodal-core/src/runtime.rs index efd9c9b..2366e8f 100644 --- a/crates/decodal-core/src/runtime.rs +++ b/crates/decodal-core/src/runtime.rs @@ -1,6 +1,6 @@ use alloc::{string::String, vec::Vec}; -use crate::{ExprId, ast::CompareOp}; +use crate::{ExprId, Span, ast::CompareOp}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ModuleId(pub u32); @@ -37,6 +37,7 @@ pub struct ObjectValue { pub struct ObjectField { pub name: String, pub value: ThunkId, + pub span: Span, } #[derive(Debug, Clone, PartialEq)] @@ -54,10 +55,16 @@ pub struct FunctionParam { #[derive(Debug, Clone, PartialEq)] pub struct AbstractValue { - pub constraints: Vec, + pub constraints: Vec, pub default: Option, } +#[derive(Debug, Clone, PartialEq)] +pub struct ConstraintEntry { + pub constraint: Constraint, + pub span: Span, +} + #[derive(Debug, Clone, PartialEq)] pub enum Constraint { Type(PrimitiveType), @@ -108,6 +115,7 @@ pub struct EnvId(pub u32); pub struct Thunk { pub kind: ThunkKind, pub state: ThunkState, + pub span: Span, } #[derive(Debug, Clone)] diff --git a/crates/decodal-wasm/src/lib.rs b/crates/decodal-wasm/src/lib.rs index 4a8090f..a67d12f 100644 --- a/crates/decodal-wasm/src/lib.rs +++ b/crates/decodal-wasm/src/lib.rs @@ -125,14 +125,21 @@ fn normalize_path(path: &str) -> Option { } fn format_diagnostic(diagnostic: decodal_core::Diagnostic) -> String { - format!( + let mut out = format!( "{:?} at {}:{}..{}: {}", diagnostic.kind, diagnostic.span.source.0, diagnostic.span.start, diagnostic.span.end, diagnostic.message, - ) + ); + for label in diagnostic.labels { + out.push_str(&format!( + "\nnote at {}:{}..{}: {}", + label.span.source.0, label.span.start, label.span.end, label.message, + )); + } + out } fn format_data(data: &Data, indent: usize) -> String { diff --git a/doc/manual/souce/design/composition-and-materialization.md b/doc/manual/souce/design/composition-and-materialization.md index 686c0f2..6792737 100644 --- a/doc/manual/souce/design/composition-and-materialization.md +++ b/doc/manual/souce/design/composition-and-materialization.md @@ -117,3 +117,9 @@ Abstract { constraints, default: None } materialize は default を採用する唯一の段階である。 通常評価中に明示値が得られた場合、default は採用されない。 + +## Diagnostic context + +Composition and materialization keep source spans for object fields, constraints, defaults, and thunks where possible. +When a conflict occurs, the diagnostic should identify the operation span and related participant spans, such as the left field, right field, constraint, or default value. +For object materialization errors, diagnostics also include the field path being processed when known. diff --git a/doc/manual/souce/design/diagnostics-and-fallback.md b/doc/manual/souce/design/diagnostics-and-fallback.md index 99a259a..40323b4 100644 --- a/doc/manual/souce/design/diagnostics-and-fallback.md +++ b/doc/manual/souce/design/diagnostics-and-fallback.md @@ -10,10 +10,19 @@ Diagnostic { kind: DiagnosticKind span: Span message: String - notes: Vec + labels: Vec +} + +DiagnosticLabel { + span: Span + message: String } ``` +`span` は primary location を示す。 +`labels` は同じ error に関係する追加 location を示す。 +合成や materialize の失敗では、衝突した constraint、value、default、または処理中 field path を label に含める。 + 代表的な diagnostic kind: - syntax error @@ -38,6 +47,18 @@ Result これにより、制約違反、未定義識別子、循環依存、import 失敗などが通常値として流れることを避ける。 +## 合成と materialize の diagnostic + +合成や materialize の失敗は、以下を示す。 + +1. どの段階で失敗したか: composition、patch、materialization +2. どの field path を処理中だったか +3. どの constraint、value、default が衝突したか +4. なぜ合成または materialize できないか + +例えば default が constraint を満たさない場合は、constraint の位置と default value の位置の両方を label として持つ。 +object field の合成で concrete value が衝突する場合は、左辺 field と右辺 field の位置を label として持つ。 + ## `try / catch` は core に入れない 汎用 `try / catch` は core に入れない。 @@ -47,21 +68,6 @@ fallback は有限で明示的な仕組みに限定する。 - `default`: 未指定値の fallback。 - `match`: 有限 pattern に基づく分岐。 -- optional import: ファイル不存在など、限定された失敗だけを fallback 可能にする候補。 -- optional field access: field 不在だけを fallback 可能にする候補。 -- union / tagged schema: 複数 schema の選択を明示的に表す将来候補。 -## optional fallback の扱い - -optional import や optional field access を導入する場合も、捕捉できる失敗は限定する。 - -例として optional import は、ファイル不存在だけを fallback 可能にし、parse error や import 先の制約違反は diagnostic として報告する方がよい。 - -```text -optional import: - file not found -> fallback - parse error -> diagnostic - eval error -> diagnostic -``` - -この方針により、fallback は通常の値選択として扱い、エラー内容に依存した実行時分岐は避ける。 +Decodal は `unknown` / `any` を持たないため、field 不在や未解決 identifier を fallback 可能な通常値として扱わない。 +それらは diagnostic として報告する。 diff --git a/doc/manual/souce/design/runtime-model.md b/doc/manual/souce/design/runtime-model.md index b097b7d..a52bfbe 100644 --- a/doc/manual/souce/design/runtime-model.md +++ b/doc/manual/souce/design/runtime-model.md @@ -36,7 +36,11 @@ object は concrete structure として扱う。 ```text ObjectValue: - fields: Map + fields: Map + +ObjectField: + value: ThunkId + span: Span ``` 例えば以下の schema object は、object 自体は concrete だが、field の値は abstract value になる。 @@ -61,9 +65,14 @@ Concrete(Object { ```text AbstractValue { - constraints: Vec + constraints: Vec default: Option } + +ConstraintEntry { + constraint: Constraint + span: Span +} ``` `default` は `AbstractValue` にだけ存在する。 diff --git a/site/decodal-site/src/wasm/decodal_wasm_bg.wasm b/site/decodal-site/src/wasm/decodal_wasm_bg.wasm index c613ab3..25635b8 100644 Binary files a/site/decodal-site/src/wasm/decodal_wasm_bg.wasm and b/site/decodal-site/src/wasm/decodal_wasm_bg.wasm differ