Improve composition diagnostics

This commit is contained in:
Keisuke Hirata 2026-06-22 17:37:51 +09:00
parent 8b3df21cfa
commit cc5ab63922
No known key found for this signature in database
10 changed files with 518 additions and 175 deletions

View File

@ -148,6 +148,15 @@ fn print_diagnostic(error: &Diagnostic) {
end = error.span.end, end = error.span.end,
message = error.message, 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) { fn print_data(data: &Data, indent: usize) {

View File

@ -3,103 +3,154 @@ use alloc::{string::String, vec::Vec};
use crate::{ use crate::{
Diagnostic, DiagnosticKind, Span, Diagnostic, DiagnosticKind, Span,
ast::CompareOp, ast::CompareOp,
runtime::{Constraint, LiteralValue, PrimitiveType}, runtime::{Constraint, ConstraintEntry, LiteralValue, PrimitiveType},
}; };
pub fn normalize_constraints( pub fn normalize_constraints(
constraints: Vec<Constraint>, constraints: Vec<ConstraintEntry>,
span: Span, span: Span,
) -> crate::Result<Vec<Constraint>> { ) -> crate::Result<Vec<ConstraintEntry>> {
let mut primitive = None; let mut primitive: Option<(PrimitiveType, Span)> = None;
let mut lower: Option<Bound> = None; let mut lower: Option<(Bound, Span)> = None;
let mut upper: Option<Bound> = None; let mut upper: Option<(Bound, Span)> = None;
let mut rest = Vec::new(); let mut rest = Vec::new();
for constraint in constraints { for entry in constraints {
match constraint { match entry.constraint {
Constraint::Type(next) => match primitive { Constraint::Type(next) => match primitive {
Some(current) if current != next => { Some((current, current_span)) if current != next => {
return Err(Diagnostic::new( return Err(Diagnostic::new(
DiagnosticKind::Conflict, DiagnosticKind::Conflict,
span, span,
"primitive type constraints conflict", "primitive type constraints conflict",
)); )
.with_label(current_span, "first primitive constraint")
.with_label(entry.span, "conflicting primitive constraint"));
} }
Some(_) => {} Some(_) => {}
None => primitive = Some(next), None => primitive = Some((next, entry.span)),
}, },
Constraint::Compare(op, value) => { Constraint::Compare(op, value) => {
let number = Number::from_literal(&value).ok_or_else(|| { let number = Number::from_literal(&value).ok_or_else(|| {
Diagnostic::new( Diagnostic::new(
DiagnosticKind::TypeMismatch, DiagnosticKind::TypeMismatch,
span, entry.span,
"comparison constraints require numeric literals", "comparison constraints require numeric literals",
) )
})?; })?;
match op { match op {
CompareOp::Gt => merge_lower(&mut lower, Bound::new(number, false)), CompareOp::Gt => {
CompareOp::Gte => merge_lower(&mut lower, Bound::new(number, true)), merge_lower(&mut lower, (Bound::new(number, false), entry.span))
CompareOp::Lt => merge_upper(&mut upper, Bound::new(number, false)), }
CompareOp::Lte => merge_upper(&mut upper, Bound::new(number, true)), 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 => { CompareOp::Eq => {
merge_lower(&mut lower, Bound::new(number, true)); merge_lower(&mut lower, (Bound::new(number, true), entry.span));
merge_upper(&mut upper, Bound::new(number, true)); merge_upper(&mut upper, (Bound::new(number, true), entry.span));
} }
} }
} }
Constraint::Regex(pattern) => rest.push(Constraint::Regex(pattern)), Constraint::Regex(pattern) => rest.push(ConstraintEntry {
Constraint::BuiltinPredicate(name) => rest.push(Constraint::BuiltinPredicate(name)), 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)) if matches!(
&& (lower.is_some() || upper.is_some()) primitive,
Some((PrimitiveType::String | PrimitiveType::Bool, _))
) && (lower.is_some() || upper.is_some())
{ {
return Err(Diagnostic::new( let mut diagnostic = Diagnostic::new(
DiagnosticKind::Conflict, DiagnosticKind::Conflict,
span, span,
"numeric comparison constraints conflict with non-numeric primitive type", "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 && lower
.iter() .iter()
.chain(upper.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, DiagnosticKind::Conflict,
span, span,
"Int comparison constraints must use integer literals", "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)?; ensure_bounds_non_empty(primitive, lower, upper, span)?;
let mut normalized = Vec::new(); let mut normalized = Vec::new();
if let Some(primitive) = primitive { if let Some((primitive, primitive_span)) = primitive {
normalized.push(Constraint::Type(primitive)); normalized.push(ConstraintEntry {
constraint: Constraint::Type(primitive),
span: primitive_span,
});
} }
if let Some(lower) = lower { if let Some((lower, lower_span)) = lower {
normalized.push(Constraint::Compare( normalized.push(ConstraintEntry {
constraint: Constraint::Compare(
if lower.inclusive { if lower.inclusive {
CompareOp::Gte CompareOp::Gte
} else { } else {
CompareOp::Gt CompareOp::Gt
}, },
lower.number.into_literal(), lower.number.into_literal(),
)); ),
span: lower_span,
});
} }
if let Some(upper) = upper { if let Some((upper, upper_span)) = upper {
normalized.push(Constraint::Compare( normalized.push(ConstraintEntry {
constraint: Constraint::Compare(
if upper.inclusive { if upper.inclusive {
CompareOp::Lte CompareOp::Lte
} else { } else {
CompareOp::Lt CompareOp::Lt
}, },
upper.number.into_literal(), upper.number.into_literal(),
)); ),
span: upper_span,
});
} }
normalized.extend(rest); normalized.extend(rest);
Ok(normalized) Ok(normalized)
@ -147,18 +198,22 @@ impl Number {
} }
} }
fn merge_lower(current: &mut Option<Bound>, next: Bound) { fn merge_lower(current: &mut Option<(Bound, Span)>, next: (Bound, Span)) {
match current { match current {
None => *current = Some(next), 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(_) => {} Some(_) => {}
} }
} }
fn merge_upper(current: &mut Option<Bound>, next: Bound) { fn merge_upper(current: &mut Option<(Bound, Span)>, next: (Bound, Span)) {
match current { match current {
None => *current = Some(next), 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(_) => {} Some(_) => {}
} }
} }
@ -178,28 +233,32 @@ fn is_stricter_upper(next: Bound, current: Bound) -> bool {
} }
fn ensure_bounds_non_empty( fn ensure_bounds_non_empty(
primitive: Option<PrimitiveType>, primitive: Option<(PrimitiveType, Span)>,
lower: Option<Bound>, lower: Option<(Bound, Span)>,
upper: Option<Bound>, upper: Option<(Bound, Span)>,
span: Span, span: Span,
) -> crate::Result<()> { ) -> crate::Result<()> {
if primitive == Some(PrimitiveType::Int) { if matches!(primitive, Some((PrimitiveType::Int, _))) {
let min = lower.map(int_lower_bound).unwrap_or(i128::from(i64::MIN)); let min = lower
let max = upper.map(int_upper_bound).unwrap_or(i128::from(i64::MAX)); .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 { if min > max {
return Err(empty_numeric_bounds(span)); return Err(empty_numeric_bounds(span, lower, upper));
} }
return Ok(()); return Ok(());
} }
if let (Some(lower), Some(upper)) = (lower, upper) { if let (Some((lower_bound, _)), Some((upper_bound, _))) = (lower, upper) {
let lower_value = lower.number.as_f64(); let lower_value = lower_bound.number.as_f64();
let upper_value = upper.number.as_f64(); let upper_value = upper_bound.number.as_f64();
if lower_value > upper_value { 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) { if lower_value == upper_value && !(lower_bound.inclusive && upper_bound.inclusive) {
return Err(empty_numeric_bounds(span)); return Err(empty_numeric_bounds(span, lower, upper));
} }
} }
Ok(()) Ok(())
@ -227,12 +286,23 @@ fn int_upper_bound(bound: Bound) -> i128 {
} }
} }
fn empty_numeric_bounds(span: Span) -> Diagnostic { fn empty_numeric_bounds(
Diagnostic::new( span: Span,
lower: Option<(Bound, Span)>,
upper: Option<(Bound, Span)>,
) -> Diagnostic {
let mut diagnostic = Diagnostic::new(
DiagnosticKind::Conflict, DiagnosticKind::Conflict,
span, span,
String::from("numeric comparison constraints have an empty intersection"), 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)] #[cfg(test)]
@ -240,13 +310,20 @@ mod tests {
use super::*; use super::*;
use crate::runtime::LiteralValue; use crate::runtime::LiteralValue;
fn entry(constraint: Constraint) -> ConstraintEntry {
ConstraintEntry {
constraint,
span: Span::default(),
}
}
#[test] #[test]
fn detects_primitive_conflict() { fn detects_primitive_conflict() {
assert!( assert!(
normalize_constraints( normalize_constraints(
alloc::vec![ alloc::vec![
Constraint::Type(PrimitiveType::Int), entry(Constraint::Type(PrimitiveType::Int)),
Constraint::Type(PrimitiveType::String), entry(Constraint::Type(PrimitiveType::String)),
], ],
Span::default(), Span::default(),
) )
@ -259,9 +336,9 @@ mod tests {
assert!( assert!(
normalize_constraints( normalize_constraints(
alloc::vec![ alloc::vec![
Constraint::Type(PrimitiveType::Int), entry(Constraint::Type(PrimitiveType::Int)),
Constraint::Compare(CompareOp::Gt, LiteralValue::Int(10)), entry(Constraint::Compare(CompareOp::Gt, LiteralValue::Int(10))),
Constraint::Compare(CompareOp::Lt, LiteralValue::Int(11)), entry(Constraint::Compare(CompareOp::Lt, LiteralValue::Int(5))),
], ],
Span::default(), Span::default(),
) )
@ -270,15 +347,16 @@ mod tests {
} }
#[test] #[test]
fn keeps_regex_constraints_without_intersection_check() { fn detects_non_integer_int_bound() {
let constraints = normalize_constraints( assert!(
normalize_constraints(
alloc::vec![ alloc::vec![
Constraint::Regex(String::from("^a$")), entry(Constraint::Type(PrimitiveType::Int)),
Constraint::Regex(String::from("^b$")), entry(Constraint::Compare(CompareOp::Gt, LiteralValue::Float(1.5))),
], ],
Span::default(), Span::default(),
) )
.unwrap(); .is_err()
assert_eq!(constraints.len(), 2); );
} }
} }

View File

@ -1,4 +1,4 @@
use alloc::string::String; use alloc::{string::String, vec::Vec};
use crate::span::Span; use crate::span::Span;
@ -9,6 +9,13 @@ pub struct Diagnostic {
pub kind: DiagnosticKind, pub kind: DiagnosticKind,
pub span: Span, pub span: Span,
pub message: String, pub message: String,
pub labels: Vec<DiagnosticLabel>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiagnosticLabel {
pub span: Span,
pub message: String,
} }
impl Diagnostic { impl Diagnostic {
@ -17,12 +24,21 @@ impl Diagnostic {
kind, kind,
span, span,
message: message.into(), message: message.into(),
labels: Vec::new(),
} }
} }
pub fn syntax(span: Span, message: impl Into<String>) -> Self { pub fn syntax(span: Span, message: impl Into<String>) -> Self {
Self::new(DiagnosticKind::Syntax, span, message) Self::new(DiagnosticKind::Syntax, span, message)
} }
pub fn with_label(mut self, span: Span, message: impl Into<String>) -> Self {
self.labels.push(DiagnosticLabel {
span,
message: message.into(),
});
self
}
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]

View File

@ -9,9 +9,9 @@ use crate::{
module::{EmptyLoader, LoadedSource, Module, SourceLoader}, module::{EmptyLoader, LoadedSource, Module, SourceLoader},
parse_source_with_source_id, parse_source_with_source_id,
runtime::{ runtime::{
AbstractValue, Binding, ConcreteValue, Constraint, Data, DataField, Env, EnvId, ExprRef, AbstractValue, Binding, ConcreteValue, Constraint, ConstraintEntry, Data, DataField, Env,
FunctionParam, FunctionValue, LiteralValue, ModuleId, ObjectField, ObjectValue, EnvId, ExprRef, FunctionParam, FunctionValue, LiteralValue, ModuleId, ObjectField,
PrimitiveType, RuntimeValue, Thunk, ThunkId, ThunkKind, ThunkState, ObjectValue, PrimitiveType, RuntimeValue, Thunk, ThunkId, ThunkKind, ThunkState,
}, },
}; };
@ -84,6 +84,14 @@ impl<L: SourceLoader> Engine<L> {
} }
pub fn materialize(&mut self, value: &RuntimeValue) -> Result<Data> { pub fn materialize(&mut self, value: &RuntimeValue) -> Result<Data> {
self.materialize_with_path(value, &mut Vec::new())
}
fn materialize_with_path(
&mut self,
value: &RuntimeValue,
path: &mut Vec<String>,
) -> Result<Data> {
match value { match value {
RuntimeValue::Concrete(value) => match value { RuntimeValue::Concrete(value) => match value {
ConcreteValue::String(value) => Ok(Data::String(value.clone())), ConcreteValue::String(value) => Ok(Data::String(value.clone())),
@ -94,7 +102,7 @@ impl<L: SourceLoader> Engine<L> {
let mut data = Vec::new(); let mut data = Vec::new();
for item in items { for item in items {
let value = self.force(*item)?; let value = self.force(*item)?;
data.push(self.materialize(&value)?); data.push(self.materialize_with_path(&value, path)?);
} }
Ok(Data::Array(data)) Ok(Data::Array(data))
} }
@ -102,30 +110,51 @@ impl<L: SourceLoader> Engine<L> {
let mut fields = Vec::new(); let mut fields = Vec::new();
for field in &object.fields { for field in &object.fields {
let value = self.force(field.value)?; let value = self.force(field.value)?;
path.push(field.name.clone());
let value = self.materialize_with_path(&value, path);
path.pop();
fields.push(DataField { fields.push(DataField {
name: field.name.clone(), name: field.name.clone(),
value: self.materialize(&value)?, value: value?,
}); });
} }
Ok(Data::Object(fields)) Ok(Data::Object(fields))
} }
ConcreteValue::Function(_) => Err(Diagnostic::new( ConcreteValue::Function(_) => Err(self.with_path_context(
Diagnostic::new(
DiagnosticKind::Materialize, DiagnosticKind::Materialize,
Span::default(), Span::default(),
"cannot materialize function value", "cannot materialize function value",
),
"materializing",
path,
)), )),
}, },
RuntimeValue::Abstract(abstract_value) => { RuntimeValue::Abstract(abstract_value) => {
let Some(default) = abstract_value.default else { let Some(default) = abstract_value.default else {
return Err(Diagnostic::new( let mut diagnostic = Diagnostic::new(
DiagnosticKind::Materialize, DiagnosticKind::Materialize,
Span::default(), constraint_primary_span(&abstract_value.constraints),
"cannot materialize unresolved abstract value without default", "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)?; let value = self.force(default)?;
self.ensure_satisfies(&value, &abstract_value.constraints, Span::default())?; self.ensure_satisfies(
self.materialize(&value) &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<L: SourceLoader> Engine<L> {
) )
})?; })?;
Ok(RuntimeValue::Abstract(AbstractValue { Ok(RuntimeValue::Abstract(AbstractValue {
constraints: vec![Constraint::Compare(op, value)], constraints: vec![ConstraintEntry {
constraint: Constraint::Compare(op, value),
span,
}],
default: None, default: None,
})) }))
} }
Expr::RegexConstraint(pattern) => Ok(RuntimeValue::Abstract(AbstractValue { Expr::RegexConstraint(pattern) => Ok(RuntimeValue::Abstract(AbstractValue {
constraints: vec![Constraint::Regex(pattern)], constraints: vec![ConstraintEntry {
constraint: Constraint::Regex(pattern),
span,
}],
default: None, default: None,
})), })),
Expr::Wildcard => Err(Diagnostic::new( Expr::Wildcard => Err(Diagnostic::new(
@ -494,7 +529,10 @@ impl<L: SourceLoader> Engine<L> {
fn eval_ident(&mut self, name: &str, env: EnvId, span: Span) -> Result<RuntimeValue> { fn eval_ident(&mut self, name: &str, env: EnvId, span: Span) -> Result<RuntimeValue> {
if let Some(primitive) = primitive_type(name) { if let Some(primitive) = primitive_type(name) {
return Ok(RuntimeValue::Abstract(AbstractValue { return Ok(RuntimeValue::Abstract(AbstractValue {
constraints: vec![Constraint::Type(primitive)], constraints: vec![ConstraintEntry {
constraint: Constraint::Type(primitive),
span,
}],
default: None, default: None,
})); }));
} }
@ -503,7 +541,10 @@ impl<L: SourceLoader> Engine<L> {
} }
if name.chars().next().is_some_and(char::is_uppercase) { if name.chars().next().is_some_and(char::is_uppercase) {
return Ok(RuntimeValue::Abstract(AbstractValue { return Ok(RuntimeValue::Abstract(AbstractValue {
constraints: vec![Constraint::BuiltinPredicate(String::from(name))], constraints: vec![ConstraintEntry {
constraint: Constraint::BuiltinPredicate(String::from(name)),
span,
}],
default: None, default: None,
})); }));
} }
@ -551,7 +592,7 @@ impl<L: SourceLoader> Engine<L> {
)); ));
} }
if path.len() == 1 { 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]) { if object.fields.iter().any(|field| field.name == path[0]) {
return Err(Diagnostic::new( return Err(Diagnostic::new(
DiagnosticKind::Conflict, DiagnosticKind::Conflict,
@ -562,6 +603,7 @@ impl<L: SourceLoader> Engine<L> {
object.fields.push(ObjectField { object.fields.push(ObjectField {
name: path[0].clone(), name: path[0].clone(),
value, value,
span,
}); });
return Ok(()); return Ok(());
} }
@ -577,8 +619,10 @@ impl<L: SourceLoader> Engine<L> {
)); ));
}; };
self.insert_field(&mut nested, module, &path[1..], expr, env, span)?; self.insert_field(&mut nested, module, &path[1..], expr, env, span)?;
object.fields[index].value = object.fields[index].value = self.add_value_thunk_with_span(
self.add_value_thunk(RuntimeValue::Concrete(ConcreteValue::Object(nested))); RuntimeValue::Concrete(ConcreteValue::Object(nested)),
span,
);
return Ok(()); return Ok(());
} }
@ -586,7 +630,11 @@ impl<L: SourceLoader> Engine<L> {
self.insert_field(&mut nested, module, &path[1..], expr, env, span)?; self.insert_field(&mut nested, module, &path[1..], expr, env, span)?;
object.fields.push(ObjectField { object.fields.push(ObjectField {
name: name.clone(), 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(()) Ok(())
} }
@ -646,6 +694,7 @@ impl<L: SourceLoader> Engine<L> {
value, value,
&abstract_value.constraints, &abstract_value.constraints,
self.expr_span(pattern), self.expr_span(pattern),
None,
) )
.map(|_| true) .map(|_| true)
.or_else(|diag| match diag.kind { .or_else(|diag| match diag.kind {
@ -673,12 +722,17 @@ impl<L: SourceLoader> Engine<L> {
(RuntimeValue::Abstract(mut lhs), RuntimeValue::Abstract(rhs)) => { (RuntimeValue::Abstract(mut lhs), RuntimeValue::Abstract(rhs)) => {
lhs.constraints.extend(rhs.constraints); lhs.constraints.extend(rhs.constraints);
lhs.constraints = normalize_constraints(lhs.constraints, span)?; 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)) Ok(RuntimeValue::Abstract(lhs))
} }
(RuntimeValue::Abstract(abstract_value), concrete @ RuntimeValue::Concrete(_)) (RuntimeValue::Abstract(abstract_value), concrete @ RuntimeValue::Concrete(_))
| (concrete @ RuntimeValue::Concrete(_), RuntimeValue::Abstract(abstract_value)) => { | (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) Ok(concrete)
} }
( (
@ -711,10 +765,17 @@ impl<L: SourceLoader> Engine<L> {
.iter() .iter()
.position(|lhs_field| lhs_field.name == rhs_field.name) .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 lhs_value = self.force(lhs.fields[index].value)?;
let rhs_value = self.force(rhs_field.value)?; let rhs_value = self.force(rhs_field.value)?;
let value = self.compose_and(lhs_value, rhs_value, span)?; let value = self
lhs.fields[index].value = self.add_value_thunk(value); .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 { } else {
lhs.fields.push(rhs_field); lhs.fields.push(rhs_field);
} }
@ -742,7 +803,8 @@ impl<L: SourceLoader> Engine<L> {
let lhs_value = self.force(lhs.fields[index].value)?; let lhs_value = self.force(lhs.fields[index].value)?;
let rhs_value = self.force(rhs_field.value)?; let rhs_value = self.force(rhs_field.value)?;
let value = self.patch(lhs_value, rhs_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 { } else {
lhs.fields.push(rhs_field); lhs.fields.push(rhs_field);
} }
@ -753,11 +815,12 @@ impl<L: SourceLoader> Engine<L> {
fn ensure_satisfies( fn ensure_satisfies(
&mut self, &mut self,
value: &RuntimeValue, value: &RuntimeValue,
constraints: &[Constraint], constraints: &[ConstraintEntry],
span: Span, span: Span,
value_label: Option<(Span, &'static str)>,
) -> Result<()> { ) -> Result<()> {
for constraint in constraints { for constraint in constraints {
self.satisfies(value, constraint, span)?; self.satisfies(value, constraint, span, value_label)?;
} }
Ok(()) Ok(())
} }
@ -765,17 +828,20 @@ impl<L: SourceLoader> Engine<L> {
fn satisfies( fn satisfies(
&mut self, &mut self,
value: &RuntimeValue, value: &RuntimeValue,
constraint: &Constraint, constraint: &ConstraintEntry,
span: Span, span: Span,
value_label: Option<(Span, &'static str)>,
) -> Result<()> { ) -> Result<()> {
match constraint { let constraint_value = &constraint.constraint;
match constraint_value {
Constraint::Type(primitive) => { Constraint::Type(primitive) => {
if value_matches_primitive(value, *primitive) { if value_matches_primitive(value, *primitive) {
Ok(()) Ok(())
} else { } else {
Err(Diagnostic::new( Err(self.constraint_violation(
DiagnosticKind::ConstraintViolation, constraint,
span, span,
value_label,
"value does not satisfy primitive type constraint", "value does not satisfy primitive type constraint",
)) ))
} }
@ -783,18 +849,69 @@ impl<L: SourceLoader> Engine<L> {
Constraint::Compare(op, expected) => compare_value(value, *op, expected) Constraint::Compare(op, expected) => compare_value(value, *op, expected)
.then_some(()) .then_some(())
.ok_or_else(|| { .ok_or_else(|| {
Diagnostic::new( self.constraint_violation(
DiagnosticKind::ConstraintViolation, constraint,
span, span,
value_label,
"value does not satisfy comparison constraint", "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( Constraint::BuiltinPredicate(name) => Err(Diagnostic::new(
DiagnosticKind::UnsupportedFeature, DiagnosticKind::UnsupportedFeature,
span, constraint.span,
format!("builtin predicate `{name}` is not implemented"), 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<ThunkId>,
rhs: Option<ThunkId>,
span: Span,
) -> Result<Option<ThunkId>> {
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<L: SourceLoader> Engine<L> {
object.fields.push(ObjectField { object.fields.push(ObjectField {
name: field.name, name: field.name,
value, value,
span: Span::default(),
}); });
} }
Ok(RuntimeValue::Concrete(ConcreteValue::Object(object))) Ok(RuntimeValue::Concrete(ConcreteValue::Object(object)))
@ -845,6 +963,13 @@ impl<L: SourceLoader> Engine<L> {
} else { } else {
None None
}; };
let constraints = constraints
.into_iter()
.map(|constraint| ConstraintEntry {
constraint,
span: Span::default(),
})
.collect();
let constraints = normalize_constraints(constraints, Span::default())?; let constraints = normalize_constraints(constraints, Span::default())?;
Ok(RuntimeValue::Abstract(AbstractValue { Ok(RuntimeValue::Abstract(AbstractValue {
constraints, constraints,
@ -906,7 +1031,12 @@ impl<L: SourceLoader> Engine<L> {
} }
fn add_expr_thunk(&mut self, expr: ExprRef, env: EnvId) -> ThunkId { 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( fn add_constrained_thunk(
@ -916,23 +1046,32 @@ impl<L: SourceLoader> Engine<L> {
value: ExprRef, value: ExprRef,
value_env: EnvId, value_env: EnvId,
) -> ThunkId { ) -> ThunkId {
self.add_thunk(ThunkKind::Constrained { let span = self.expr_span(value);
self.add_thunk(
ThunkKind::Constrained {
constraint, constraint,
constraint_env, constraint_env,
value, value,
value_env, value_env,
}) },
span,
)
} }
fn add_value_thunk(&mut self, value: RuntimeValue) -> ThunkId { 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); let id = ThunkId(self.thunks.len() as u32);
self.thunks.push(Thunk { self.thunks.push(Thunk {
kind, kind,
state: ThunkState::Unevaluated, state: ThunkState::Unevaluated,
span,
}); });
id id
} }
@ -977,9 +1116,28 @@ impl<L: SourceLoader> Engine<L> {
} }
fn expr_span(&self, reference: ExprRef) -> Span { fn expr_span(&self, reference: ExprRef) -> Span {
self.modules[reference.module.0 as usize] self.modules
.ast .get(reference.module.0 as usize)
.span(reference.expr) .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<PrimitiveType> {
} }
} }
fn constraint_primary_span(constraints: &[ConstraintEntry]) -> Span {
constraints
.first()
.map(|constraint| constraint.span)
.unwrap_or_default()
}
fn literal_to_concrete(literal: Literal) -> ConcreteValue { fn literal_to_concrete(literal: Literal) -> ConcreteValue {
match literal { match literal {
Literal::String(value) => ConcreteValue::String(value), Literal::String(value) => ConcreteValue::String(value),
@ -1343,23 +1508,6 @@ fn concrete_scalar_eq(lhs: &ConcreteValue, rhs: &ConcreteValue) -> bool {
} }
} }
fn merge_default(
lhs: Option<ThunkId>,
rhs: Option<ThunkId>,
span: Span,
) -> Result<Option<ThunkId>> {
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -1372,6 +1520,62 @@ mod tests {
engine.materialize(&value).unwrap() 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] #[test]
fn materializes_default() { fn materializes_default() {
let data = eval_data("port = Int & >= 1 default 8080;"); let data = eval_data("port = Int & >= 1 default 8080;");

View File

@ -1,6 +1,6 @@
use alloc::{string::String, vec::Vec}; use alloc::{string::String, vec::Vec};
use crate::{ExprId, ast::CompareOp}; use crate::{ExprId, Span, ast::CompareOp};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ModuleId(pub u32); pub struct ModuleId(pub u32);
@ -37,6 +37,7 @@ pub struct ObjectValue {
pub struct ObjectField { pub struct ObjectField {
pub name: String, pub name: String,
pub value: ThunkId, pub value: ThunkId,
pub span: Span,
} }
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
@ -54,10 +55,16 @@ pub struct FunctionParam {
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct AbstractValue { pub struct AbstractValue {
pub constraints: Vec<Constraint>, pub constraints: Vec<ConstraintEntry>,
pub default: Option<ThunkId>, pub default: Option<ThunkId>,
} }
#[derive(Debug, Clone, PartialEq)]
pub struct ConstraintEntry {
pub constraint: Constraint,
pub span: Span,
}
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum Constraint { pub enum Constraint {
Type(PrimitiveType), Type(PrimitiveType),
@ -108,6 +115,7 @@ pub struct EnvId(pub u32);
pub struct Thunk { pub struct Thunk {
pub kind: ThunkKind, pub kind: ThunkKind,
pub state: ThunkState, pub state: ThunkState,
pub span: Span,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]

View File

@ -125,14 +125,21 @@ fn normalize_path(path: &str) -> Option<String> {
} }
fn format_diagnostic(diagnostic: decodal_core::Diagnostic) -> String { fn format_diagnostic(diagnostic: decodal_core::Diagnostic) -> String {
format!( let mut out = format!(
"{:?} at {}:{}..{}: {}", "{:?} at {}:{}..{}: {}",
diagnostic.kind, diagnostic.kind,
diagnostic.span.source.0, diagnostic.span.source.0,
diagnostic.span.start, diagnostic.span.start,
diagnostic.span.end, diagnostic.span.end,
diagnostic.message, 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 { fn format_data(data: &Data, indent: usize) -> String {

View File

@ -117,3 +117,9 @@ Abstract { constraints, default: None }
materialize は default を採用する唯一の段階である。 materialize は default を採用する唯一の段階である。
通常評価中に明示値が得られた場合、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.

View File

@ -10,10 +10,19 @@ Diagnostic {
kind: DiagnosticKind kind: DiagnosticKind
span: Span span: Span
message: String message: String
notes: Vec<Note> labels: Vec<DiagnosticLabel>
}
DiagnosticLabel {
span: Span
message: String
} }
``` ```
`span` は primary location を示す。
`labels` は同じ error に関係する追加 location を示す。
合成や materialize の失敗では、衝突した constraint、value、default、または処理中 field path を label に含める。
代表的な diagnostic kind: 代表的な diagnostic kind:
- syntax error - syntax error
@ -38,6 +47,18 @@ Result<RuntimeValue, Diagnostic>
これにより、制約違反、未定義識別子、循環依存、import 失敗などが通常値として流れることを避ける。 これにより、制約違反、未定義識別子、循環依存、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 に入れない
汎用 `try / catch` は core に入れない。 汎用 `try / catch` は core に入れない。
@ -47,21 +68,6 @@ fallback は有限で明示的な仕組みに限定する。
- `default`: 未指定値の fallback。 - `default`: 未指定値の fallback。
- `match`: 有限 pattern に基づく分岐。 - `match`: 有限 pattern に基づく分岐。
- optional import: ファイル不存在など、限定された失敗だけを fallback 可能にする候補。
- optional field access: field 不在だけを fallback 可能にする候補。
- union / tagged schema: 複数 schema の選択を明示的に表す将来候補。
## optional fallback の扱い Decodal は `unknown` / `any` を持たないため、field 不在や未解決 identifier を fallback 可能な通常値として扱わない。
それらは diagnostic として報告する。
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 は通常の値選択として扱い、エラー内容に依存した実行時分岐は避ける。

View File

@ -36,7 +36,11 @@ object は concrete structure として扱う。
```text ```text
ObjectValue: ObjectValue:
fields: Map<Symbol, ThunkId> fields: Map<Symbol, ObjectField>
ObjectField:
value: ThunkId
span: Span
``` ```
例えば以下の schema object は、object 自体は concrete だが、field の値は abstract value になる。 例えば以下の schema object は、object 自体は concrete だが、field の値は abstract value になる。
@ -61,9 +65,14 @@ Concrete(Object {
```text ```text
AbstractValue { AbstractValue {
constraints: Vec<Constraint> constraints: Vec<ConstraintEntry>
default: Option<ThunkId> default: Option<ThunkId>
} }
ConstraintEntry {
constraint: Constraint
span: Span
}
``` ```
`default``AbstractValue` にだけ存在する。 `default``AbstractValue` にだけ存在する。