Report structured import origins in diagnostics

This commit is contained in:
2026-08-11 19:20:11 +09:00
parent 165daada23
commit 08603dc4b5
6 changed files with 339 additions and 31 deletions
+7
View File
@@ -10,6 +10,7 @@ pub struct Diagnostic {
pub span: Span,
pub message: String,
pub labels: Vec<DiagnosticLabel>,
pub notes: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -25,6 +26,7 @@ impl Diagnostic {
span,
message: message.into(),
labels: Vec::new(),
notes: Vec::new(),
}
}
@@ -39,6 +41,11 @@ impl Diagnostic {
});
self
}
pub fn with_note(mut self, message: impl Into<String>) -> Self {
self.notes.push(message.into());
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+324 -31
View File
@@ -21,6 +21,7 @@ pub struct Engine<L = EmptyLoader> {
modules: Vec<Module>,
imported_values: Vec<ImportedValue>,
thunks: Vec<Thunk>,
thunk_import_origins: Vec<Option<ImportedValueOrigin>>,
envs: Vec<Env>,
}
@@ -29,6 +30,39 @@ struct ImportedValue {
root: ThunkId,
}
#[derive(Debug, Clone)]
struct ImportedValueOrigin {
key: String,
path: Vec<String>,
}
impl ImportedValueOrigin {
fn root(key: String) -> Self {
Self {
key,
path: Vec::new(),
}
}
fn field(&self, name: String) -> Self {
let mut path = self.path.clone();
path.push(name);
Self {
key: self.key.clone(),
path,
}
}
fn index(&self, index: usize) -> Self {
let mut path = self.path.clone();
path.push(format!("[{index}]"));
Self {
key: self.key.clone(),
path,
}
}
}
impl Engine<EmptyLoader> {
pub fn from_parse(ast: Ast, root: ExprId) -> Self {
let mut this = Self::new(EmptyLoader);
@@ -52,6 +86,7 @@ impl<L: ImportLoader> Engine<L> {
modules: Vec::new(),
imported_values: Vec::new(),
thunks: Vec::new(),
thunk_import_origins: Vec::new(),
envs: vec![Env {
parent: None,
bindings: Vec::new(),
@@ -261,8 +296,9 @@ impl<L: ImportLoader> Engine<L> {
if let Some(value) = self.find_imported_value(&key) {
return self.force(value);
}
let value = self.internalize_host_value(value)?;
let root = self.add_value_thunk(value);
let origin = ImportedValueOrigin::root(key.clone());
let value = self.internalize_host_value_with_origin(value, Some(&origin))?;
let root = self.add_value_thunk_with_import_origin(value, origin);
self.imported_values.push(ImportedValue { key, root });
self.force(root)
}
@@ -836,17 +872,59 @@ impl<L: ImportLoader> Engine<L> {
.iter()
.position(|lhs_field| lhs_field.name == rhs_field.name)
{
let lhs_thunk = lhs.fields[index].value;
let lhs_field_span = lhs.fields[index].span;
let lhs_value = self.force(lhs.fields[index].value)?;
let lhs_origin = self.thunk_import_origin(lhs_thunk).cloned();
let rhs_origin = self.thunk_import_origin(rhs_field.value).cloned();
let lhs_value = self.force(lhs_thunk)?;
let rhs_value = self.force(rhs_field.value)?;
let value = self
.compose_and(lhs_value, rhs_value, span)
.compose_and(lhs_value.clone(), rhs_value.clone(), 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))
let mut diagnostic = diag.with_label(
span,
format!("while composing field `{}`", rhs_field.name),
);
if lhs_origin.is_some() {
diagnostic = annotate_imported_value(
diagnostic,
lhs_origin.as_ref(),
Some(&lhs_value),
);
} else {
diagnostic = diagnostic.with_label(
lhs_field_span,
format!("left field `{}`", rhs_field.name),
);
}
if rhs_origin.is_some() {
diagnostic = annotate_imported_value(
diagnostic,
rhs_origin.as_ref(),
Some(&rhs_value),
);
} else {
diagnostic = diagnostic.with_label(
rhs_field.span,
format!("right field `{}`", rhs_field.name),
);
}
diagnostic
})?;
lhs.fields[index].value = self.add_value_thunk_with_span(value, rhs_field.span);
let result_span = match (lhs_origin.is_some(), rhs_origin.is_some()) {
(false, true) => lhs_field_span,
(true, false) => rhs_field.span,
_ => rhs_field.span,
};
let result_origin = rhs_origin.clone().or(lhs_origin.clone());
lhs.fields[index].value = self.add_value_thunk_with_span_and_import_origin(
value,
result_span,
result_origin,
);
if lhs_origin.is_some() && rhs_origin.is_none() {
lhs.fields[index].span = rhs_field.span;
}
} else {
lhs.fields.push(rhs_field);
}
@@ -872,9 +950,14 @@ impl<L: ImportLoader> Engine<L> {
.position(|lhs_field| lhs_field.name == rhs_field.name)
{
let lhs_value = self.force(lhs.fields[index].value)?;
let rhs_origin = self.thunk_import_origin(rhs_field.value).cloned();
let rhs_value = self.force(rhs_field.value)?;
let value = self.patch(lhs_value, rhs_value)?;
lhs.fields[index].value = self.add_value_thunk_with_span(value, rhs_field.span);
lhs.fields[index].value = self.add_value_thunk_with_span_and_import_origin(
value,
rhs_field.span,
rhs_origin,
);
lhs.fields[index].span = rhs_field.span;
} else {
lhs.fields.push(rhs_field);
@@ -924,38 +1007,68 @@ impl<L: ImportLoader> Engine<L> {
constraint,
span,
value_label,
"value does not satisfy primitive type constraint",
format!(
"expected {primitive:?}, found {}",
runtime_value_kind(&value)
),
))
}
}
Constraint::ArrayItems(item_constraint) => {
let value_kind = runtime_value_kind(&value);
let RuntimeValue::Concrete(ConcreteValue::Array(items)) = value else {
return Err(self.constraint_violation(
constraint,
span,
value_label,
"value does not satisfy array element constraint",
format!("expected Array, found {value_kind}"),
));
};
let item_constraint_value = self.force(*item_constraint)?;
let mut constrained_items = Vec::with_capacity(items.len());
for (index, item) in items.into_iter().enumerate() {
let item_span = self.thunk_span(item);
let item_origin = self.thunk_import_origin(item).cloned();
let item_value = self.force(item)?;
let composition_span = if item_origin.is_some() {
constraint.span
} else {
item_span
};
let constrained = self
.compose_and(item_constraint_value.clone(), item_value, item_span)
.compose_and(
item_constraint_value.clone(),
item_value.clone(),
composition_span,
)
.map_err(|diagnostic| {
diagnostic
.with_label(
constraint.span,
"array element constraint declared here",
let diagnostic = diagnostic.with_label(
constraint.span,
"array element constraint declared here",
);
if item_origin.is_some() {
annotate_imported_value(
diagnostic,
item_origin.as_ref(),
Some(&item_value),
)
.with_label(
} else {
diagnostic.with_label(
item_span,
format!("array element [{index}] checked here"),
)
}
})?;
constrained_items.push(self.add_value_thunk_with_span(constrained, item_span));
let constrained_span = if item_origin.is_some() {
constraint.span
} else {
item_span
};
constrained_items.push(self.add_value_thunk_with_span_and_import_origin(
constrained,
constrained_span,
item_origin,
));
}
Ok(RuntimeValue::Concrete(ConcreteValue::Array(
constrained_items,
@@ -996,7 +1109,7 @@ impl<L: ImportLoader> Engine<L> {
constraint: &ConstraintEntry,
operation_span: Span,
value_label: Option<(Span, &'static str)>,
message: &'static str,
message: impl Into<String>,
) -> Diagnostic {
let mut diagnostic = Diagnostic::new(
DiagnosticKind::ConstraintViolation,
@@ -1032,6 +1145,14 @@ impl<L: ImportLoader> Engine<L> {
}
fn internalize_host_value(&mut self, value: HostValue) -> Result<RuntimeValue> {
self.internalize_host_value_with_origin(value, None)
}
fn internalize_host_value_with_origin(
&mut self,
value: HostValue,
origin: Option<&ImportedValueOrigin>,
) -> Result<RuntimeValue> {
match value {
HostValue::String(value) => Ok(RuntimeValue::Concrete(ConcreteValue::String(value))),
HostValue::Int(value) => Ok(RuntimeValue::Concrete(ConcreteValue::Int(value))),
@@ -1039,9 +1160,12 @@ impl<L: ImportLoader> Engine<L> {
HostValue::Bool(value) => Ok(RuntimeValue::Concrete(ConcreteValue::Bool(value))),
HostValue::Array(items) => {
let mut thunks = Vec::new();
for item in items {
let value = self.internalize_host_value(item)?;
thunks.push(self.add_value_thunk(value));
for (index, item) in items.into_iter().enumerate() {
let item_origin = origin.map(|origin| origin.index(index));
let value =
self.internalize_host_value_with_origin(item, item_origin.as_ref())?;
thunks
.push(self.add_value_thunk_with_optional_import_origin(value, item_origin));
}
Ok(RuntimeValue::Concrete(ConcreteValue::Array(thunks)))
}
@@ -1050,11 +1174,11 @@ impl<L: ImportLoader> Engine<L> {
mut constraints,
default,
} => {
let item = self.internalize_host_value(*item)?;
let item = self.add_value_thunk(item);
let item = self.internalize_host_value_with_origin(*item, origin)?;
let item = self.add_value_thunk_with_optional_import_origin(item, origin.cloned());
let default = if let Some(default) = default {
let value = self.internalize_host_value(*default)?;
Some(self.add_value_thunk(value))
let value = self.internalize_host_value_with_origin(*default, origin)?;
Some(self.add_value_thunk_with_optional_import_origin(value, origin.cloned()))
} else {
None
};
@@ -1075,19 +1199,27 @@ impl<L: ImportLoader> Engine<L> {
HostValue::Object(fields) => {
let mut object = ObjectValue { fields: Vec::new() };
for field in fields {
let field_origin = origin.map(|origin| origin.field(field.name.clone()));
if object
.fields
.iter()
.any(|existing| existing.name == field.name)
{
return Err(Diagnostic::new(
let diagnostic = Diagnostic::new(
DiagnosticKind::Conflict,
Span::default(),
format!("duplicate host object field `{}`", field.name),
);
return Err(annotate_imported_value(
diagnostic,
field_origin.as_ref(),
None,
));
}
let value = self.internalize_host_value(field.value)?;
let value = self.add_value_thunk(value);
let value = self
.internalize_host_value_with_origin(field.value, field_origin.as_ref())?;
let value =
self.add_value_thunk_with_optional_import_origin(value, field_origin);
object.fields.push(ObjectField {
name: field.name,
value,
@@ -1101,8 +1233,8 @@ impl<L: ImportLoader> Engine<L> {
default,
} => {
let default = if let Some(default) = default {
let value = self.internalize_host_value(*default)?;
Some(self.add_value_thunk(value))
let value = self.internalize_host_value_with_origin(*default, origin)?;
Some(self.add_value_thunk_with_optional_import_origin(value, origin.cloned()))
} else {
None
};
@@ -1209,13 +1341,48 @@ impl<L: ImportLoader> Engine<L> {
self.add_thunk(ThunkKind::Value(value), span)
}
fn add_value_thunk_with_import_origin(
&mut self,
value: RuntimeValue,
origin: ImportedValueOrigin,
) -> ThunkId {
self.add_value_thunk_with_optional_import_origin(value, Some(origin))
}
fn add_value_thunk_with_optional_import_origin(
&mut self,
value: RuntimeValue,
origin: Option<ImportedValueOrigin>,
) -> ThunkId {
self.add_value_thunk_with_span_and_import_origin(value, Span::default(), origin)
}
fn add_value_thunk_with_span_and_import_origin(
&mut self,
value: RuntimeValue,
span: Span,
origin: Option<ImportedValueOrigin>,
) -> ThunkId {
self.add_thunk_with_import_origin(ThunkKind::Value(value), span, origin)
}
fn add_thunk(&mut self, kind: ThunkKind, span: Span) -> ThunkId {
self.add_thunk_with_import_origin(kind, span, None)
}
fn add_thunk_with_import_origin(
&mut self,
kind: ThunkKind,
span: Span,
origin: Option<ImportedValueOrigin>,
) -> ThunkId {
let id = ThunkId(self.thunks.len() as u32);
self.thunks.push(Thunk {
kind,
state: ThunkState::Unevaluated,
span,
});
self.thunk_import_origins.push(origin);
id
}
@@ -1269,6 +1436,10 @@ impl<L: ImportLoader> Engine<L> {
self.thunks[thunk.0 as usize].span
}
fn thunk_import_origin(&self, thunk: ThunkId) -> Option<&ImportedValueOrigin> {
self.thunk_import_origins[thunk.0 as usize].as_ref()
}
fn with_path_context(
&self,
diagnostic: Diagnostic,
@@ -1327,6 +1498,9 @@ pub fn format_diagnostic_with<'a>(
label.message,
));
}
for note in &diagnostic.notes {
out.push_str(&format!("\nnote: {note}"));
}
out
}
@@ -1401,6 +1575,45 @@ fn format_path(path: &[String]) -> String {
output
}
fn annotate_imported_value(
diagnostic: Diagnostic,
origin: Option<&ImportedValueOrigin>,
value: Option<&RuntimeValue>,
) -> Diagnostic {
let Some(origin) = origin else {
return diagnostic;
};
if diagnostic
.notes
.iter()
.any(|note| note.starts_with("imported `"))
{
return diagnostic;
}
let path = if origin.path.is_empty() {
String::from("<root>")
} else {
format_path(&origin.path)
};
let value = value
.map(|value| format!(" supplied a value of type {}", runtime_value_kind(value)))
.unwrap_or_default();
diagnostic.with_note(format!("imported `{}` at `{path}`{value}", origin.key))
}
fn runtime_value_kind(value: &RuntimeValue) -> &'static str {
match value {
RuntimeValue::Concrete(ConcreteValue::String(_)) => "String",
RuntimeValue::Concrete(ConcreteValue::Int(_)) => "Int",
RuntimeValue::Concrete(ConcreteValue::Float(_)) => "Float",
RuntimeValue::Concrete(ConcreteValue::Bool(_)) => "Bool",
RuntimeValue::Concrete(ConcreteValue::Array(_)) => "Array",
RuntimeValue::Concrete(ConcreteValue::Object(_)) => "Object",
RuntimeValue::Concrete(ConcreteValue::Function(_)) => "Function",
RuntimeValue::Abstract(_) => "abstract",
}
}
#[cfg(feature = "regex")]
fn satisfies_regex(value: &RuntimeValue, pattern: &str, span: Span) -> Result<()> {
let RuntimeValue::Concrete(ConcreteValue::String(value)) = value else {
@@ -2127,6 +2340,86 @@ mod tests {
);
}
#[test]
fn structured_import_violation_reports_key_path_and_schema_span() {
let mut engine = Engine::new(MarkdownLoader {
source: String::from("---\ntitle: \"Hello\"\ndraft: maybe\n---\n# Hello"),
});
let module = engine
.add_root_source(
"main.dcdl",
"main.dcdl",
r#"
let
Post = {
frontmatter = {
title = String;
draft = Bool;
};
body = String;
};
in Post & import "./post.md"
"#,
)
.unwrap();
let error = engine.eval_module(module).unwrap_err();
assert_eq!(error.kind, DiagnosticKind::ConstraintViolation);
assert_eq!(error.message, "expected Bool, found String");
assert!(
error
.labels
.iter()
.all(|label| label.span != Span::default())
);
assert!(
error
.labels
.iter()
.any(|label| label.message == "constraint declared here")
);
assert_eq!(
error.notes,
["imported `content/post.md` at `frontmatter.draft` supplied a value of type String"]
);
assert!(engine.format_diagnostic(&error).contains(
"note: imported `content/post.md` at `frontmatter.draft` supplied a value of type String"
));
}
struct StructuredArrayLoader;
impl ImportLoader for StructuredArrayLoader {
fn load(&mut self, _current_key: Option<&str>, _specifier: &str) -> Result<LoadedImport> {
Ok(LoadedImport::value(
"content/navigation.json",
HostValue::object([(
"items",
HostValue::array([HostValue::string("home"), HostValue::int(2)]),
)]),
))
}
}
#[test]
fn structured_array_violation_reports_indexed_import_path() {
let mut engine = Engine::new(StructuredArrayLoader);
let module = engine
.add_root_source(
"main.dcdl",
"main.dcdl",
r#"{ items = [...String]; } & import "./navigation.json""#,
)
.unwrap();
let error = engine.eval_module(module).unwrap_err();
assert_eq!(error.message, "expected String, found Int");
assert_eq!(
error.notes,
["imported `content/navigation.json` at `items[1]` supplied a value of type Int"]
);
}
struct StructuredAliasLoader;
impl ImportLoader for StructuredAliasLoader {