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,
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) {

View File

@ -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<Constraint>,
constraints: Vec<ConstraintEntry>,
span: Span,
) -> crate::Result<Vec<Constraint>> {
let mut primitive = None;
let mut lower: Option<Bound> = None;
let mut upper: Option<Bound> = None;
) -> crate::Result<Vec<ConstraintEntry>> {
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<Bound>, 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<Bound>, 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<PrimitiveType>,
lower: Option<Bound>,
upper: Option<Bound>,
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()
);
}
}

View File

@ -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<DiagnosticLabel>,
}
#[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<String>) -> Self {
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)]

View File

@ -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<L: SourceLoader> Engine<L> {
}
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 {
RuntimeValue::Concrete(value) => match value {
ConcreteValue::String(value) => Ok(Data::String(value.clone())),
@ -94,7 +102,7 @@ impl<L: SourceLoader> Engine<L> {
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<L: SourceLoader> Engine<L> {
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<L: SourceLoader> Engine<L> {
)
})?;
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<L: SourceLoader> Engine<L> {
fn eval_ident(&mut self, name: &str, env: EnvId, span: Span) -> Result<RuntimeValue> {
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<L: SourceLoader> Engine<L> {
}
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<L: SourceLoader> Engine<L> {
));
}
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<L: SourceLoader> Engine<L> {
object.fields.push(ObjectField {
name: path[0].clone(),
value,
span,
});
return Ok(());
}
@ -577,8 +619,10 @@ impl<L: SourceLoader> Engine<L> {
));
};
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<L: SourceLoader> Engine<L> {
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<L: SourceLoader> Engine<L> {
value,
&abstract_value.constraints,
self.expr_span(pattern),
None,
)
.map(|_| true)
.or_else(|diag| match diag.kind {
@ -673,12 +722,17 @@ impl<L: SourceLoader> Engine<L> {
(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<L: SourceLoader> Engine<L> {
.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<L: SourceLoader> Engine<L> {
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<L: SourceLoader> Engine<L> {
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<L: SourceLoader> Engine<L> {
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<L: SourceLoader> Engine<L> {
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<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 {
name: field.name,
value,
span: Span::default(),
});
}
Ok(RuntimeValue::Concrete(ConcreteValue::Object(object)))
@ -845,6 +963,13 @@ impl<L: SourceLoader> Engine<L> {
} 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<L: SourceLoader> Engine<L> {
}
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<L: SourceLoader> Engine<L> {
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<L: SourceLoader> Engine<L> {
}
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<PrimitiveType> {
}
}
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<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)]
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;");

View File

@ -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<Constraint>,
pub constraints: Vec<ConstraintEntry>,
pub default: Option<ThunkId>,
}
#[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)]

View File

@ -125,14 +125,21 @@ fn normalize_path(path: &str) -> Option<String> {
}
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 {

View File

@ -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.

View File

@ -10,10 +10,19 @@ Diagnostic {
kind: DiagnosticKind
span: Span
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:
- syntax error
@ -38,6 +47,18 @@ Result<RuntimeValue, Diagnostic>
これにより、制約違反、未定義識別子、循環依存、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 として報告する。

View File

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