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 span: Span,
pub message: String, pub message: String,
pub labels: Vec<DiagnosticLabel>, pub labels: Vec<DiagnosticLabel>,
pub notes: Vec<String>,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -25,6 +26,7 @@ impl Diagnostic {
span, span,
message: message.into(), message: message.into(),
labels: Vec::new(), labels: Vec::new(),
notes: Vec::new(),
} }
} }
@@ -39,6 +41,11 @@ impl Diagnostic {
}); });
self self
} }
pub fn with_note(mut self, message: impl Into<String>) -> Self {
self.notes.push(message.into());
self
}
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
+322 -29
View File
@@ -21,6 +21,7 @@ pub struct Engine<L = EmptyLoader> {
modules: Vec<Module>, modules: Vec<Module>,
imported_values: Vec<ImportedValue>, imported_values: Vec<ImportedValue>,
thunks: Vec<Thunk>, thunks: Vec<Thunk>,
thunk_import_origins: Vec<Option<ImportedValueOrigin>>,
envs: Vec<Env>, envs: Vec<Env>,
} }
@@ -29,6 +30,39 @@ struct ImportedValue {
root: ThunkId, 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> { impl Engine<EmptyLoader> {
pub fn from_parse(ast: Ast, root: ExprId) -> Self { pub fn from_parse(ast: Ast, root: ExprId) -> Self {
let mut this = Self::new(EmptyLoader); let mut this = Self::new(EmptyLoader);
@@ -52,6 +86,7 @@ impl<L: ImportLoader> Engine<L> {
modules: Vec::new(), modules: Vec::new(),
imported_values: Vec::new(), imported_values: Vec::new(),
thunks: Vec::new(), thunks: Vec::new(),
thunk_import_origins: Vec::new(),
envs: vec![Env { envs: vec![Env {
parent: None, parent: None,
bindings: Vec::new(), bindings: Vec::new(),
@@ -261,8 +296,9 @@ impl<L: ImportLoader> Engine<L> {
if let Some(value) = self.find_imported_value(&key) { if let Some(value) = self.find_imported_value(&key) {
return self.force(value); return self.force(value);
} }
let value = self.internalize_host_value(value)?; let origin = ImportedValueOrigin::root(key.clone());
let root = self.add_value_thunk(value); 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.imported_values.push(ImportedValue { key, root });
self.force(root) self.force(root)
} }
@@ -836,17 +872,59 @@ impl<L: ImportLoader> Engine<L> {
.iter() .iter()
.position(|lhs_field| lhs_field.name == rhs_field.name) .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_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 rhs_value = self.force(rhs_field.value)?;
let value = self let value = self
.compose_and(lhs_value, rhs_value, span) .compose_and(lhs_value.clone(), rhs_value.clone(), span)
.map_err(|diag| { .map_err(|diag| {
diag.with_label(span, format!("while composing field `{}`", rhs_field.name)) let mut diagnostic = diag.with_label(
.with_label(lhs_field_span, format!("left field `{}`", rhs_field.name)) span,
.with_label(rhs_field.span, format!("right field `{}`", rhs_field.name)) 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 { } else {
lhs.fields.push(rhs_field); lhs.fields.push(rhs_field);
} }
@@ -872,9 +950,14 @@ impl<L: ImportLoader> Engine<L> {
.position(|lhs_field| lhs_field.name == rhs_field.name) .position(|lhs_field| lhs_field.name == rhs_field.name)
{ {
let lhs_value = self.force(lhs.fields[index].value)?; 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 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_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; lhs.fields[index].span = rhs_field.span;
} else { } else {
lhs.fields.push(rhs_field); lhs.fields.push(rhs_field);
@@ -924,38 +1007,68 @@ impl<L: ImportLoader> Engine<L> {
constraint, constraint,
span, span,
value_label, value_label,
"value does not satisfy primitive type constraint", format!(
"expected {primitive:?}, found {}",
runtime_value_kind(&value)
),
)) ))
} }
} }
Constraint::ArrayItems(item_constraint) => { Constraint::ArrayItems(item_constraint) => {
let value_kind = runtime_value_kind(&value);
let RuntimeValue::Concrete(ConcreteValue::Array(items)) = value else { let RuntimeValue::Concrete(ConcreteValue::Array(items)) = value else {
return Err(self.constraint_violation( return Err(self.constraint_violation(
constraint, constraint,
span, span,
value_label, value_label,
"value does not satisfy array element constraint", format!("expected Array, found {value_kind}"),
)); ));
}; };
let item_constraint_value = self.force(*item_constraint)?; let item_constraint_value = self.force(*item_constraint)?;
let mut constrained_items = Vec::with_capacity(items.len()); let mut constrained_items = Vec::with_capacity(items.len());
for (index, item) in items.into_iter().enumerate() { for (index, item) in items.into_iter().enumerate() {
let item_span = self.thunk_span(item); let item_span = self.thunk_span(item);
let item_origin = self.thunk_import_origin(item).cloned();
let item_value = self.force(item)?; let item_value = self.force(item)?;
let composition_span = if item_origin.is_some() {
constraint.span
} else {
item_span
};
let constrained = self 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| { .map_err(|diagnostic| {
diagnostic let diagnostic = diagnostic.with_label(
.with_label(
constraint.span, constraint.span,
"array element constraint declared here", "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, item_span,
format!("array element [{index}] checked here"), 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( Ok(RuntimeValue::Concrete(ConcreteValue::Array(
constrained_items, constrained_items,
@@ -996,7 +1109,7 @@ impl<L: ImportLoader> Engine<L> {
constraint: &ConstraintEntry, constraint: &ConstraintEntry,
operation_span: Span, operation_span: Span,
value_label: Option<(Span, &'static str)>, value_label: Option<(Span, &'static str)>,
message: &'static str, message: impl Into<String>,
) -> Diagnostic { ) -> Diagnostic {
let mut diagnostic = Diagnostic::new( let mut diagnostic = Diagnostic::new(
DiagnosticKind::ConstraintViolation, DiagnosticKind::ConstraintViolation,
@@ -1032,6 +1145,14 @@ impl<L: ImportLoader> Engine<L> {
} }
fn internalize_host_value(&mut self, value: HostValue) -> Result<RuntimeValue> { 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 { match value {
HostValue::String(value) => Ok(RuntimeValue::Concrete(ConcreteValue::String(value))), HostValue::String(value) => Ok(RuntimeValue::Concrete(ConcreteValue::String(value))),
HostValue::Int(value) => Ok(RuntimeValue::Concrete(ConcreteValue::Int(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::Bool(value) => Ok(RuntimeValue::Concrete(ConcreteValue::Bool(value))),
HostValue::Array(items) => { HostValue::Array(items) => {
let mut thunks = Vec::new(); let mut thunks = Vec::new();
for item in items { for (index, item) in items.into_iter().enumerate() {
let value = self.internalize_host_value(item)?; let item_origin = origin.map(|origin| origin.index(index));
thunks.push(self.add_value_thunk(value)); 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))) Ok(RuntimeValue::Concrete(ConcreteValue::Array(thunks)))
} }
@@ -1050,11 +1174,11 @@ impl<L: ImportLoader> Engine<L> {
mut constraints, mut constraints,
default, default,
} => { } => {
let item = self.internalize_host_value(*item)?; let item = self.internalize_host_value_with_origin(*item, origin)?;
let item = self.add_value_thunk(item); let item = self.add_value_thunk_with_optional_import_origin(item, origin.cloned());
let default = if let Some(default) = default { let default = if let Some(default) = default {
let value = self.internalize_host_value(*default)?; let value = self.internalize_host_value_with_origin(*default, origin)?;
Some(self.add_value_thunk(value)) Some(self.add_value_thunk_with_optional_import_origin(value, origin.cloned()))
} else { } else {
None None
}; };
@@ -1075,19 +1199,27 @@ impl<L: ImportLoader> Engine<L> {
HostValue::Object(fields) => { HostValue::Object(fields) => {
let mut object = ObjectValue { fields: Vec::new() }; let mut object = ObjectValue { fields: Vec::new() };
for field in fields { for field in fields {
let field_origin = origin.map(|origin| origin.field(field.name.clone()));
if object if object
.fields .fields
.iter() .iter()
.any(|existing| existing.name == field.name) .any(|existing| existing.name == field.name)
{ {
return Err(Diagnostic::new( let diagnostic = Diagnostic::new(
DiagnosticKind::Conflict, DiagnosticKind::Conflict,
Span::default(), Span::default(),
format!("duplicate host object field `{}`", field.name), 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
let value = self.add_value_thunk(value); .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 { object.fields.push(ObjectField {
name: field.name, name: field.name,
value, value,
@@ -1101,8 +1233,8 @@ impl<L: ImportLoader> Engine<L> {
default, default,
} => { } => {
let default = if let Some(default) = default { let default = if let Some(default) = default {
let value = self.internalize_host_value(*default)?; let value = self.internalize_host_value_with_origin(*default, origin)?;
Some(self.add_value_thunk(value)) Some(self.add_value_thunk_with_optional_import_origin(value, origin.cloned()))
} else { } else {
None None
}; };
@@ -1209,13 +1341,48 @@ impl<L: ImportLoader> Engine<L> {
self.add_thunk(ThunkKind::Value(value), span) 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 { 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); 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, span,
}); });
self.thunk_import_origins.push(origin);
id id
} }
@@ -1269,6 +1436,10 @@ impl<L: ImportLoader> Engine<L> {
self.thunks[thunk.0 as usize].span 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( fn with_path_context(
&self, &self,
diagnostic: Diagnostic, diagnostic: Diagnostic,
@@ -1327,6 +1498,9 @@ pub fn format_diagnostic_with<'a>(
label.message, label.message,
)); ));
} }
for note in &diagnostic.notes {
out.push_str(&format!("\nnote: {note}"));
}
out out
} }
@@ -1401,6 +1575,45 @@ fn format_path(path: &[String]) -> String {
output 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")] #[cfg(feature = "regex")]
fn satisfies_regex(value: &RuntimeValue, pattern: &str, span: Span) -> Result<()> { fn satisfies_regex(value: &RuntimeValue, pattern: &str, span: Span) -> Result<()> {
let RuntimeValue::Concrete(ConcreteValue::String(value)) = value else { 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; struct StructuredAliasLoader;
impl ImportLoader for StructuredAliasLoader { impl ImportLoader for StructuredAliasLoader {
@@ -11,6 +11,7 @@ Diagnostic {
span: Span span: Span
message: String message: String
labels: Vec<DiagnosticLabel> labels: Vec<DiagnosticLabel>
notes: Vec<String>
} }
DiagnosticLabel { DiagnosticLabel {
@@ -22,6 +23,7 @@ DiagnosticLabel {
`span` は primary location を示す。 `span` は primary location を示す。
表示時には `Span.source` を source id のまま出すのではなく、可能な限り file path や virtual file name に解決する。 表示時には `Span.source` を source id のまま出すのではなく、可能な限り file path や virtual file name に解決する。
`labels` は同じ error に関係する追加 location を示す。 `labels` は同じ error に関係する追加 location を示す。
`notes` は source location を持たない semantic context を示す。
合成や materialize の失敗では、衝突した constraint、value、default、または処理中 field path を label に含める。 合成や materialize の失敗では、衝突した constraint、value、default、または処理中 field path を label に含める。
代表的な diagnostic kind: 代表的な diagnostic kind:
@@ -37,6 +39,9 @@ DiagnosticLabel {
- match failure - match failure
- materialization failure - materialization failure
Structured imports use semantic provenance rather than synthetic source spans.
Constraint failures report the imported value's stable key and logical field or array path alongside the source span of the Decodal constraint that rejected it.
## エラーは値ではない ## エラーは値ではない
評価失敗は `RuntimeValue` ではなく `Diagnostic` を返す。 評価失敗は `RuntimeValue` ではなく `Diagnostic` を返す。
+3
View File
@@ -159,4 +159,7 @@ The core does not select content types or bundle Markdown/frontmatter parsers.
The loader owns path resolution, media or extension dispatch, parsing rules, and parse diagnostics. The loader owns path resolution, media or extension dispatch, parsing rules, and parse diagnostics.
The stable loader key is also used to cache structured imports. The stable loader key is also used to cache structured imports.
When a structured value fails a Decodal constraint, the diagnostic keeps the Decodal constraint span and identifies the host value by its stable import key and logical value path, such as `content/post.md` and `frontmatter.draft`.
`HostValue` does not need source spans: syntax diagnostics for the external format remain the loader's responsibility, while cross-value validation reports semantic provenance.
`load` is the single import hook: loaders dispatch by extension, media type, or another host-defined rule and return the appropriate variant directly. `load` is the single import hook: loaders dispatch by extension, media type, or another host-defined rule and return the appropriate variant directly.
Binary file not shown.