Skip to content

Commit 9a4245b

Browse files
committed
Add a compact output mode (for LLMs)
1 parent 6b553b9 commit 9a4245b

6 files changed

Lines changed: 346 additions & 20 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/quarto-yaml-validation/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,8 @@ quarto-error-reporting.workspace = true
2828
# Additional dependencies for validation
2929
regex = "1.12"
3030

31+
[dev-dependencies]
32+
insta.workspace = true
33+
3134
[lints]
3235
workspace = true

crates/quarto-yaml-validation/README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ match validate(&doc, &schema, &registry, &source_ctx) {
101101
// Or get machine-readable JSON
102102
let json = diagnostic.to_json();
103103
println!("{}", serde_json::to_string_pretty(&json)?);
104+
105+
// Or get a single compact line, optimized for token-efficient
106+
// consumption (e.g. feeding errors to an LLM):
107+
eprintln!("{}", diagnostic.to_compact());
104108
}
105109
}
106110
```
@@ -150,6 +154,16 @@ Error: [Q-1-16] YAML Validation Failed
150154
}
151155
```
152156

157+
**Compact Output (`diagnostic.to_compact()`):**
158+
159+
A single line per error — `file:line:col [CODE] path: message (hint: ...)`
160+
with no box drawing or redundant structural fields. Designed for
161+
token-efficient consumption for LLMs.
162+
163+
```
164+
user.yaml:1:6 [Q-1-16] age: Number 200 is out of range (max: 150) (Hint: Check the allowed value range in the schema?)
165+
```
166+
153167
## Documentation
154168

155169
- **[SCHEMA-FROM-YAML.md](./SCHEMA-FROM-YAML.md)**: Complete YAML syntax reference with examples

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

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,77 @@ impl ValidationDiagnostic {
184184
self.diagnostic.to_text(Some(source_ctx))
185185
}
186186

187+
/// Render as a single compact line, optimized for token-efficient
188+
/// consumption (e.g. feeding validation errors to an LLM).
189+
///
190+
/// Format: `file:line:col [CODE] path: message (hint: ...)`
191+
///
192+
/// The location prefix is omitted when no source range is available, and
193+
/// the path renders as `(root)` for top-level errors. Unlike [`to_text`],
194+
/// this drops the ariadne box drawing, source snippet, and the redundant
195+
/// schema-constraint line; hints are kept because they materially help a
196+
/// model propose a correct fix. Multiple hints are joined with `; `.
197+
///
198+
/// [`to_text`]: Self::to_text
199+
///
200+
/// # Example
201+
///
202+
/// ```ignore
203+
/// for d in diagnostics {
204+
/// println!("{}", d.to_compact());
205+
/// }
206+
/// ```
207+
pub fn to_compact(&self) -> String {
208+
let mut out = String::new();
209+
210+
if let Some(range) = &self.source_range {
211+
out.push_str(&format!(
212+
"{}:{}:{} ",
213+
range.filename, range.start_line, range.start_column
214+
));
215+
}
216+
217+
out.push_str(&format!("[{}] ", self.code));
218+
219+
let path = Self::instance_path_string(&self.instance_path);
220+
if path.is_empty() {
221+
out.push_str("(root): ");
222+
} else {
223+
out.push_str(&path);
224+
out.push_str(": ");
225+
}
226+
227+
out.push_str(&self.message());
228+
229+
let hints = self.hints();
230+
if !hints.is_empty() {
231+
out.push_str(&format!(" (Hint: {})", hints.join("; ")));
232+
}
233+
234+
out
235+
}
236+
237+
/// Render an instance path as a compact dotted/indexed string.
238+
///
239+
/// e.g. `[Key("authors"), Index(0), Key("name")]` -> `authors[0].name`.
240+
fn instance_path_string(segments: &[PathSegment]) -> String {
241+
let mut out = String::new();
242+
for seg in segments {
243+
match seg {
244+
PathSegment::Key(k) => {
245+
if !out.is_empty() {
246+
out.push('.');
247+
}
248+
out.push_str(k);
249+
}
250+
PathSegment::Index(i) => {
251+
out.push_str(&format!("[{}]", i));
252+
}
253+
}
254+
}
255+
out
256+
}
257+
187258
/// Helper: Build DiagnosticMessage for text rendering
188259
fn build_diagnostic_message(
189260
error: &ValidationError,
@@ -389,6 +460,159 @@ mod tests {
389460
assert_eq!(error.error_code(), "Q-1-18");
390461
}
391462

463+
#[test]
464+
fn test_instance_path_string() {
465+
assert_eq!(ValidationDiagnostic::instance_path_string(&[]), "");
466+
assert_eq!(
467+
ValidationDiagnostic::instance_path_string(&[PathSegment::Key("format".to_string())]),
468+
"format"
469+
);
470+
assert_eq!(
471+
ValidationDiagnostic::instance_path_string(&[
472+
PathSegment::Key("authors".to_string()),
473+
PathSegment::Index(0),
474+
PathSegment::Key("name".to_string()),
475+
]),
476+
"authors[0].name"
477+
);
478+
}
479+
480+
/// Strip ANSI SGR color codes and OSC-8 hyperlink sequences so the human
481+
/// (ariadne) rendering can be snapshotted in a clean, machine-independent
482+
/// form. The OSC-8 hyperlink embeds an absolute `file://` path, which is
483+
/// not portable across machines; stripping it leaves the visible
484+
/// `filename:line:col` text untouched.
485+
fn strip_ansi(s: &str) -> String {
486+
let mut out = String::new();
487+
let mut chars = s.chars().peekable();
488+
while let Some(c) = chars.next() {
489+
if c != '\u{1b}' {
490+
out.push(c);
491+
continue;
492+
}
493+
match chars.peek() {
494+
// CSI (e.g. color): ESC [ ... <final byte in @..~>
495+
Some('[') => {
496+
chars.next();
497+
while let Some(&nc) = chars.peek() {
498+
chars.next();
499+
if ('@'..='~').contains(&nc) {
500+
break;
501+
}
502+
}
503+
}
504+
// OSC (e.g. hyperlink): ESC ] ... <BEL or ST (ESC \)>
505+
Some(']') => {
506+
chars.next();
507+
while let Some(nc) = chars.next() {
508+
if nc == '\u{07}' {
509+
break;
510+
}
511+
if nc == '\u{1b}' {
512+
if let Some('\\') = chars.peek() {
513+
chars.next();
514+
}
515+
break;
516+
}
517+
}
518+
}
519+
_ => {}
520+
}
521+
}
522+
out
523+
}
524+
525+
fn test_source_context(filename: &str, content: &str) -> SourceContext {
526+
use std::collections::hash_map::DefaultHasher;
527+
use std::hash::{Hash, Hasher};
528+
529+
let mut ctx = SourceContext::new();
530+
let mut hasher = DefaultHasher::new();
531+
filename.hash(&mut hasher);
532+
let file_id = quarto_source_map::FileId(hasher.finish() as usize);
533+
ctx.add_file_with_id(file_id, filename.to_string(), Some(content.to_string()));
534+
ctx
535+
}
536+
537+
/// Snapshot the three rendering variants (compact, JSON, human) for a
538+
/// single validation error, so changes to any format's wording/structure
539+
/// are caught in one place. The error is produced end-to-end through
540+
/// `validate()` so the source range is real.
541+
#[test]
542+
fn test_all_three_formats_snapshot() {
543+
use crate::{Schema, SchemaRegistry, validate};
544+
545+
let schema_yaml = quarto_yaml::parse(
546+
r#"
547+
object:
548+
properties:
549+
age:
550+
number:
551+
minimum: 0
552+
maximum: 100
553+
"#,
554+
)
555+
.unwrap();
556+
let schema = Schema::from_yaml(&schema_yaml).unwrap();
557+
558+
let doc_content = r#"age: "not a number""#;
559+
let doc = quarto_yaml::parse_file(doc_content, "test.yaml").unwrap();
560+
let source_ctx = test_source_context("test.yaml", doc_content);
561+
562+
let registry = SchemaRegistry::new();
563+
let error = validate(&doc, &schema, &registry, &source_ctx)
564+
.expect_err("validation should fail for type mismatch");
565+
let diagnostic = ValidationDiagnostic::from_validation_error(&error, &source_ctx);
566+
567+
let combined = format!(
568+
"=== compact ===\n{}\n\n=== json ===\n{}\n\n=== human (ANSI stripped) ===\n{}",
569+
diagnostic.to_compact(),
570+
serde_json::to_string_pretty(&diagnostic.to_json()).unwrap(),
571+
strip_ansi(&diagnostic.to_text(&source_ctx)),
572+
);
573+
574+
insta::assert_snapshot!(combined);
575+
}
576+
577+
#[test]
578+
fn test_to_compact() {
579+
use crate::error::ValidationErrorKind;
580+
581+
let source_ctx = SourceContext::new();
582+
583+
// Error at a nested path: location omitted (no yaml_node), code + path + message + hint.
584+
let mut path = InstancePath::new();
585+
path.push_key("format");
586+
path.push_key("html");
587+
let error = ValidationError::new(
588+
ValidationErrorKind::TypeMismatch {
589+
expected: "boolean".to_string(),
590+
got: "string".to_string(),
591+
},
592+
path,
593+
);
594+
let vd = ValidationDiagnostic::from_validation_error(&error, &source_ctx);
595+
let compact = vd.to_compact();
596+
assert_eq!(
597+
compact,
598+
"[Q-1-11] format.html: Expected boolean, got string (Hint: Use `true` or `false` (YAML 1.2 standard)?)"
599+
);
600+
601+
// Root-level error renders `(root)` rather than an empty path.
602+
let error = ValidationError::new(
603+
ValidationErrorKind::MissingRequiredProperty {
604+
property: "version".to_string(),
605+
allowed: None,
606+
expected_type: None,
607+
},
608+
InstancePath::new(),
609+
);
610+
let vd = ValidationDiagnostic::from_validation_error(&error, &source_ctx);
611+
let compact = vd.to_compact();
612+
assert!(compact.starts_with("[Q-1-10] (root): Missing required property 'version'"));
613+
assert!(!compact.contains('\n'), "compact output must be a single line");
614+
}
615+
392616
#[test]
393617
fn test_suggest_fixes() {
394618
use crate::error::ValidationErrorKind;
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
source: crates/quarto-yaml-validation/src/diagnostic.rs
3+
expression: combined
4+
---
5+
=== compact ===
6+
test.yaml:1:6 [Q-1-11] age: Expected number, got string (Hint: Use a numeric value without quotes?)
7+
8+
=== json ===
9+
{
10+
"code": "Q-1-11",
11+
"error_kind": {
12+
"data": {
13+
"expected": "number",
14+
"got": "string"
15+
},
16+
"type": "TypeMismatch"
17+
},
18+
"hints": [
19+
"Use a numeric value without quotes?"
20+
],
21+
"instance_path": [
22+
{
23+
"type": "Key",
24+
"value": "age"
25+
}
26+
],
27+
"message": "Expected number, got string",
28+
"schema_path": [
29+
"object",
30+
"number"
31+
],
32+
"source_range": {
33+
"end_column": 18,
34+
"end_line": 1,
35+
"end_offset": 17,
36+
"filename": "test.yaml",
37+
"start_column": 6,
38+
"start_line": 1,
39+
"start_offset": 5
40+
}
41+
}
42+
43+
=== human (ANSI stripped) ===
44+
Error: [Q-1-11] YAML Validation Failed
45+
╭─[ test.yaml:1:6 ]
46+
47+
1age: "not a number"
48+
│ ──────┬─────
49+
│ ╰─────── Expected number, got string
50+
───╯
51+
At document path: `age`
52+
Schema constraint: object > number
53+
Use a numeric value without quotes?

0 commit comments

Comments
 (0)