diff --git a/crates/decodal-core/src/diagnostic.rs b/crates/decodal-core/src/diagnostic.rs index 2caf4c2..ef95599 100644 --- a/crates/decodal-core/src/diagnostic.rs +++ b/crates/decodal-core/src/diagnostic.rs @@ -10,6 +10,7 @@ pub struct Diagnostic { pub span: Span, pub message: String, pub labels: Vec, + pub notes: Vec, } #[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) -> Self { + self.notes.push(message.into()); + self + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/decodal-core/src/eval.rs b/crates/decodal-core/src/eval.rs index 13dc5d5..aadd4b1 100644 --- a/crates/decodal-core/src/eval.rs +++ b/crates/decodal-core/src/eval.rs @@ -21,6 +21,7 @@ pub struct Engine { modules: Vec, imported_values: Vec, thunks: Vec, + thunk_import_origins: Vec>, envs: Vec, } @@ -29,6 +30,39 @@ struct ImportedValue { root: ThunkId, } +#[derive(Debug, Clone)] +struct ImportedValueOrigin { + key: String, + path: Vec, +} + +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 { pub fn from_parse(ast: Ast, root: ExprId) -> Self { let mut this = Self::new(EmptyLoader); @@ -52,6 +86,7 @@ impl Engine { 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 Engine { 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 Engine { .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 Engine { .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 Engine { 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 Engine { constraint: &ConstraintEntry, operation_span: Span, value_label: Option<(Span, &'static str)>, - message: &'static str, + message: impl Into, ) -> Diagnostic { let mut diagnostic = Diagnostic::new( DiagnosticKind::ConstraintViolation, @@ -1032,6 +1145,14 @@ impl Engine { } fn internalize_host_value(&mut self, value: HostValue) -> Result { + self.internalize_host_value_with_origin(value, None) + } + + fn internalize_host_value_with_origin( + &mut self, + value: HostValue, + origin: Option<&ImportedValueOrigin>, + ) -> Result { 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 Engine { 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 Engine { 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 Engine { 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 Engine { 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 Engine { 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, + ) -> 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, + ) -> 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, + ) -> 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 Engine { 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("") + } 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 { + 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 { diff --git a/doc/manual/souce/design/diagnostics-and-fallback.md b/doc/manual/souce/design/diagnostics-and-fallback.md index 871bbf1..5cb0f45 100644 --- a/doc/manual/souce/design/diagnostics-and-fallback.md +++ b/doc/manual/souce/design/diagnostics-and-fallback.md @@ -11,6 +11,7 @@ Diagnostic { span: Span message: String labels: Vec + notes: Vec } 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` を返す。 diff --git a/doc/manual/souce/design/embedding-api.md b/doc/manual/souce/design/embedding-api.md index 5028ceb..15a17c6 100644 --- a/doc/manual/souce/design/embedding-api.md +++ b/doc/manual/souce/design/embedding-api.md @@ -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. diff --git a/packages/decodal-codemirror/wasm/decodal_language_tools_bg.wasm b/packages/decodal-codemirror/wasm/decodal_language_tools_bg.wasm index d178fda..26fdfc2 100644 Binary files a/packages/decodal-codemirror/wasm/decodal_language_tools_bg.wasm and b/packages/decodal-codemirror/wasm/decodal_language_tools_bg.wasm differ diff --git a/packages/decodal-wasm/decodal_wasm_bg.wasm b/packages/decodal-wasm/decodal_wasm_bg.wasm index 8139ed9..b49e380 100644 Binary files a/packages/decodal-wasm/decodal_wasm_bg.wasm and b/packages/decodal-wasm/decodal_wasm_bg.wasm differ