Skip to content

Commit 8fa49ab

Browse files
authored
Merge pull request #304 from hadley/custom-hint
Allow for custom hints/error messages
2 parents d1a054f + c642881 commit 8fa49ab

4 files changed

Lines changed: 217 additions & 6 deletions

File tree

crates/quarto-yaml-validation/src/diagnostic.rs

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ pub struct ValidationDiagnostic {
4545
/// Source location with filename and byte offsets/line numbers
4646
pub source_range: Option<SourceRange>,
4747

48+
/// Author-supplied hint override from the schema's `errorMessage`
49+
/// annotation. When present, it replaces the auto-generated hint.
50+
pub custom_hint: Option<String>,
51+
4852
/// Internal: DiagnosticMessage for text rendering
4953
diagnostic: DiagnosticMessage,
5054
}
@@ -90,9 +94,22 @@ impl ValidationDiagnostic {
9094
self.kind.message()
9195
}
9296

93-
/// Get hints (lazily generated from kind)
97+
/// Get hints. An author-supplied `errorMessage` override (if any) replaces
98+
/// the auto-generated hint; otherwise the hint is derived from the kind.
9499
pub fn hints(&self) -> Vec<String> {
95-
Self::suggest_fixes_from_kind(&self.kind)
100+
Self::effective_hints(&self.kind, self.custom_hint.as_deref())
101+
}
102+
103+
/// Compute the effective hints: the authored override if present, else the
104+
/// auto-generated hints for this error kind.
105+
fn effective_hints(
106+
kind: &crate::error::ValidationErrorKind,
107+
custom_hint: Option<&str>,
108+
) -> Vec<String> {
109+
match custom_hint {
110+
Some(hint) => vec![hint.to_string()],
111+
None => Self::suggest_fixes_from_kind(kind),
112+
}
96113
}
97114

98115
/// Create a new ValidationDiagnostic from a ValidationError
@@ -135,6 +152,7 @@ impl ValidationDiagnostic {
135152
instance_path,
136153
schema_path: error.schema_path.segments().to_vec(),
137154
source_range,
155+
custom_hint: error.custom_hint.clone(),
138156
diagnostic,
139157
}
140158
}
@@ -164,7 +182,7 @@ impl ValidationDiagnostic {
164182
// Include human-readable fields for convenience
165183
obj["message"] = json!(self.kind.message());
166184

167-
let hints = Self::suggest_fixes_from_kind(&self.kind);
185+
let hints = Self::effective_hints(&self.kind, self.custom_hint.as_deref());
168186
if !hints.is_empty() {
169187
obj["hints"] = json!(hints);
170188
}
@@ -209,8 +227,8 @@ impl ValidationDiagnostic {
209227
builder = builder.add_info(format!("Schema constraint: {}", error.schema_path));
210228
}
211229

212-
// Add hints
213-
for hint in Self::suggest_fixes_from_kind(&error.kind) {
230+
// Add hints (authored `errorMessage` override wins over generated ones)
231+
for hint in Self::effective_hints(&error.kind, error.custom_hint.as_deref()) {
214232
builder = builder.add_hint(hint);
215233
}
216234

crates/quarto-yaml-validation/src/error.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,11 @@ pub struct ValidationError {
311311
pub yaml_node: Option<YamlWithSourceInfo>,
312312
/// Source location (file, line, column) for error reporting
313313
pub location: Option<SourceLocation>,
314+
/// Author-supplied hint override, taken from the `errorMessage` annotation
315+
/// on the schema node where the failure occurred. When present, this
316+
/// replaces the auto-generated hint line; the factual primary message
317+
/// (from [`ValidationErrorKind::message`]) is left intact.
318+
pub custom_hint: Option<String>,
314319
}
315320

316321
impl fmt::Display for ValidationError {
@@ -337,6 +342,7 @@ impl ValidationError {
337342
schema_path: SchemaPath::new(),
338343
yaml_node: None,
339344
location: None,
345+
custom_hint: None,
340346
}
341347
}
342348

crates/quarto-yaml-validation/src/validator.rs

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ pub struct ValidationContext<'a> {
3131
instance_path: InstancePath,
3232
/// Current schema path (e.g., ["properties", "format"])
3333
schema_path: SchemaPath,
34+
/// Stack of `errorMessage` overrides, one frame per schema node currently
35+
/// being validated (innermost last). Each frame is the node's own
36+
/// `errorMessage` (or `None`). `add_error` copies the top frame into the
37+
/// produced error's `custom_hint`, so the override binds strictly to the
38+
/// schema node where the failure occurs — a child node without its own
39+
/// `errorMessage` masks (does not inherit) an enclosing one.
40+
custom_hint_stack: Vec<Option<String>>,
3441
/// Collected validation errors
3542
errors: Vec<ValidationError>,
3643
}
@@ -43,18 +50,36 @@ impl<'a> ValidationContext<'a> {
4350
source_ctx,
4451
instance_path: InstancePath::new(),
4552
schema_path: SchemaPath::new(),
53+
custom_hint_stack: Vec::new(),
4654
errors: Vec::new(),
4755
}
4856
}
4957

5058
/// Add an error to the context
5159
pub fn add_error(&mut self, kind: ValidationErrorKind, node: &YamlWithSourceInfo) {
52-
let error = ValidationError::new(kind, self.instance_path.clone())
60+
let mut error = ValidationError::new(kind, self.instance_path.clone())
5361
.with_schema_path(self.schema_path.clone())
5462
.with_yaml_node(node.clone(), self.source_ctx);
63+
// Bind the override of the schema node currently being validated (if
64+
// any) to this error.
65+
error.custom_hint = self.custom_hint_stack.last().cloned().flatten();
5566
self.errors.push(error);
5667
}
5768

69+
/// Execute a function with the current schema node's `errorMessage` pushed
70+
/// as a frame. A `None` frame is pushed when the node has no `errorMessage`,
71+
/// which masks any enclosing override — overrides bind strictly to the node
72+
/// that declares them.
73+
pub fn with_custom_hint<F, R>(&mut self, hint: Option<&str>, f: F) -> R
74+
where
75+
F: FnOnce(&mut Self) -> R,
76+
{
77+
self.custom_hint_stack.push(hint.map(str::to_string));
78+
let result = f(self);
79+
self.custom_hint_stack.pop();
80+
result
81+
}
82+
5883
/// Execute a function with a new instance path segment
5984
pub fn with_instance_path<F, R>(&mut self, segment: PathSegment, f: F) -> R
6085
where
@@ -163,6 +188,17 @@ fn validate_generic(
163188
value: &YamlWithSourceInfo,
164189
schema: &Schema,
165190
context: &mut ValidationContext,
191+
) -> ValidationResult<()> {
192+
// Bind this schema node's `errorMessage` (if any) for the duration of its
193+
// validation, so any failure raised here picks it up as a custom hint.
194+
let hint = schema.annotations().error_message.as_deref();
195+
context.with_custom_hint(hint, |context| validate_generic_inner(value, schema, context))
196+
}
197+
198+
fn validate_generic_inner(
199+
value: &YamlWithSourceInfo,
200+
schema: &Schema,
201+
context: &mut ValidationContext,
166202
) -> ValidationResult<()> {
167203
match schema {
168204
Schema::True => Ok(()),

crates/quarto-yaml-validation/tests/integration/validation_diagnostic.rs

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,3 +326,154 @@ object:
326326
// Verify the error has proper source_range pointing to same file
327327
assert_eq!(json["source_range"]["filename"], "user.yaml");
328328
}
329+
330+
#[test]
331+
fn test_custom_error_message_overrides_pattern_hint() {
332+
// A string schema with a non-obvious pattern plus an authored errorMessage.
333+
let custom_hint = r#"Must be "naive" or a standard time zone in the form Area/Location"#;
334+
let schema_yaml = quarto_yaml::parse(&format!(
335+
r#"
336+
string:
337+
pattern: "^(naive|UTC)$"
338+
errorMessage: '{custom_hint}'
339+
"#
340+
))
341+
.unwrap();
342+
let schema = Schema::from_yaml(&schema_yaml).unwrap();
343+
344+
let doc_content = r#"PST"#;
345+
let doc = quarto_yaml::parse_file(doc_content, "tz.yaml").unwrap();
346+
let source_ctx = create_test_context("tz.yaml", doc_content);
347+
let registry = quarto_yaml_validation::SchemaRegistry::new();
348+
349+
let result = validate(&doc, &schema, &registry, &source_ctx);
350+
assert!(result.is_err(), "Validation should fail for pattern mismatch");
351+
352+
let error = result.unwrap_err();
353+
assert_eq!(
354+
error.custom_hint.as_deref(),
355+
Some(custom_hint),
356+
"custom_hint should be populated from the schema's errorMessage"
357+
);
358+
359+
let diagnostic = ValidationDiagnostic::from_validation_error(&error, &source_ctx);
360+
361+
// The authored message replaces the generic pattern hint.
362+
let hints = diagnostic.hints();
363+
assert_eq!(
364+
hints,
365+
vec![custom_hint.to_string()],
366+
"the authored errorMessage should be the only hint"
367+
);
368+
assert!(
369+
!hints.iter().any(|h| h.contains("matches the expected format")),
370+
"the generic pattern hint must not appear"
371+
);
372+
373+
// The factual primary message is left intact.
374+
assert!(
375+
diagnostic.message().contains("does not match pattern"),
376+
"primary message should still report the factual failure, got: {}",
377+
diagnostic.message()
378+
);
379+
380+
// JSON output carries the authored hint too.
381+
let json = diagnostic.to_json();
382+
assert_eq!(json["hints"][0], custom_hint);
383+
384+
// Text output includes the authored message.
385+
let text = diagnostic.to_text(&source_ctx);
386+
assert!(
387+
text.contains(custom_hint),
388+
"text output should include the authored errorMessage, got:\n{}",
389+
text
390+
);
391+
}
392+
393+
#[test]
394+
fn test_custom_error_message_applies_to_any_failure_at_node() {
395+
// The override should apply to whatever failure occurs at the annotated
396+
// node, not just pattern mismatches. Here a type mismatch (number, not
397+
// string) trips the same authored message.
398+
let schema_yaml = quarto_yaml::parse(
399+
r#"
400+
string:
401+
pattern: "^[a-z]+$"
402+
errorMessage: 'must be a lowercase identifier'
403+
"#,
404+
)
405+
.unwrap();
406+
let schema = Schema::from_yaml(&schema_yaml).unwrap();
407+
408+
let doc_content = r#"42"#;
409+
let doc = quarto_yaml::parse_file(doc_content, "id.yaml").unwrap();
410+
let source_ctx = create_test_context("id.yaml", doc_content);
411+
let registry = quarto_yaml_validation::SchemaRegistry::new();
412+
413+
let result = validate(&doc, &schema, &registry, &source_ctx);
414+
assert!(result.is_err());
415+
416+
let error = result.unwrap_err();
417+
assert_eq!(error.custom_hint.as_deref(), Some("must be a lowercase identifier"));
418+
419+
let diagnostic = ValidationDiagnostic::from_validation_error(&error, &source_ctx);
420+
assert_eq!(
421+
diagnostic.hints(),
422+
vec!["must be a lowercase identifier".to_string()]
423+
);
424+
}
425+
426+
#[test]
427+
fn test_no_custom_error_message_uses_generic_hint() {
428+
// Without errorMessage, the generic hint is still produced.
429+
let schema_yaml = quarto_yaml::parse(
430+
r#"
431+
string:
432+
pattern: "^[0-9]+$"
433+
"#,
434+
)
435+
.unwrap();
436+
let schema = Schema::from_yaml(&schema_yaml).unwrap();
437+
438+
let doc_content = r#"abc"#;
439+
let doc = quarto_yaml::parse_file(doc_content, "p.yaml").unwrap();
440+
let source_ctx = create_test_context("p.yaml", doc_content);
441+
let registry = quarto_yaml_validation::SchemaRegistry::new();
442+
443+
let error = validate(&doc, &schema, &registry, &source_ctx).unwrap_err();
444+
assert_eq!(error.custom_hint, None);
445+
446+
let diagnostic = ValidationDiagnostic::from_validation_error(&error, &source_ctx);
447+
assert_eq!(
448+
diagnostic.hints(),
449+
vec!["Check that the string matches the expected format?".to_string()]
450+
);
451+
}
452+
453+
#[test]
454+
fn test_custom_error_message_innermost_node_wins() {
455+
// An object property's own errorMessage should win over the outer
456+
// object's — the override binds to the schema node where the failure
457+
// occurs.
458+
let schema_yaml = quarto_yaml::parse(
459+
r#"
460+
object:
461+
errorMessage: 'outer object message'
462+
properties:
463+
tz:
464+
string:
465+
pattern: "^UTC$"
466+
errorMessage: 'inner tz message'
467+
"#,
468+
)
469+
.unwrap();
470+
let schema = Schema::from_yaml(&schema_yaml).unwrap();
471+
472+
let doc_content = "tz: PST";
473+
let doc = quarto_yaml::parse_file(doc_content, "doc.yaml").unwrap();
474+
let source_ctx = create_test_context("doc.yaml", doc_content);
475+
let registry = quarto_yaml_validation::SchemaRegistry::new();
476+
477+
let error = validate(&doc, &schema, &registry, &source_ctx).unwrap_err();
478+
assert_eq!(error.custom_hint.as_deref(), Some("inner tz message"));
479+
}

0 commit comments

Comments
 (0)