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 {
@@ -11,6 +11,7 @@ Diagnostic {
span: Span
message: String
labels: Vec<DiagnosticLabel>
notes: Vec<String>
}
DiagnosticLabel {
@@ -22,6 +23,7 @@ DiagnosticLabel {
`span` は primary location を示す。
表示時には `Span.source` を source id のまま出すのではなく、可能な限り file path や virtual file name に解決する。
`labels` は同じ error に関係する追加 location を示す。
`notes` は source location を持たない semantic context を示す。
合成や materialize の失敗では、衝突した constraint、value、default、または処理中 field path を label に含める。
代表的な diagnostic kind:
@@ -37,6 +39,9 @@ DiagnosticLabel {
- match 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` を返す。
+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 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.
Binary file not shown.