Skip to content

Commit 16cb41b

Browse files
authored
Merge pull request #298 from hadley/compact-mode
Add a compact output mode (for LLMs)
2 parents 8fa49ab + 9a4245b commit 16cb41b

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
@@ -202,6 +202,77 @@ impl ValidationDiagnostic {
202202
self.diagnostic.to_text(Some(source_ctx))
203203
}
204204

205+
/// Render as a single compact line, optimized for token-efficient
206+
/// consumption (e.g. feeding validation errors to an LLM).
207+
///
208+
/// Format: `file:line:col [CODE] path: message (hint: ...)`
209+
///
210+
/// The location prefix is omitted when no source range is available, and
211+
/// the path renders as `(root)` for top-level errors. Unlike [`to_text`],
212+
/// this drops the ariadne box drawing, source snippet, and the redundant
213+
/// schema-constraint line; hints are kept because they materially help a
214+
/// model propose a correct fix. Multiple hints are joined with `; `.
215+
///
216+
/// [`to_text`]: Self::to_text
217+
///
218+
/// # Example
219+
///
220+
/// ```ignore
221+
/// for d in diagnostics {
222+
/// println!("{}", d.to_compact());
223+
/// }
224+
/// ```
225+
pub fn to_compact(&self) -> String {
226+
let mut out = String::new();
227+
228+
if let Some(range) = &self.source_range {
229+
out.push_str(&format!(
230+
"{}:{}:{} ",
231+
range.filename, range.start_line, range.start_column
232+
));
233+
}
234+
235+
out.push_str(&format!("[{}] ", self.code));
236+
237+
let path = Self::instance_path_string(&self.instance_path);
238+
if path.is_empty() {
239+
out.push_str("(root): ");
240+
} else {
241+
out.push_str(&path);
242+
out.push_str(": ");
243+
}
244+
245+
out.push_str(&self.message());
246+
247+
let hints = self.hints();
248+
if !hints.is_empty() {
249+
out.push_str(&format!(" (Hint: {})", hints.join("; ")));
250+
}
251+
252+
out
253+
}
254+
255+
/// Render an instance path as a compact dotted/indexed string.
256+
///
257+
/// e.g. `[Key("authors"), Index(0), Key("name")]` -> `authors[0].name`.
258+
fn instance_path_string(segments: &[PathSegment]) -> String {
259+
let mut out = String::new();
260+
for seg in segments {
261+
match seg {
262+
PathSegment::Key(k) => {
263+
if !out.is_empty() {
264+
out.push('.');
265+
}
266+
out.push_str(k);
267+
}
268+
PathSegment::Index(i) => {
269+
out.push_str(&format!("[{}]", i));
270+
}
271+
}
272+
}
273+
out
274+
}
275+
205276
/// Helper: Build DiagnosticMessage for text rendering
206277
fn build_diagnostic_message(
207278
error: &ValidationError,
@@ -407,6 +478,159 @@ mod tests {
407478
assert_eq!(error.error_code(), "Q-1-18");
408479
}
409480

481+
#[test]
482+
fn test_instance_path_string() {
483+
assert_eq!(ValidationDiagnostic::instance_path_string(&[]), "");
484+
assert_eq!(
485+
ValidationDiagnostic::instance_path_string(&[PathSegment::Key("format".to_string())]),
486+
"format"
487+
);
488+
assert_eq!(
489+
ValidationDiagnostic::instance_path_string(&[
490+
PathSegment::Key("authors".to_string()),
491+
PathSegment::Index(0),
492+
PathSegment::Key("name".to_string()),
493+
]),
494+
"authors[0].name"
495+
);
496+
}
497+
498+
/// Strip ANSI SGR color codes and OSC-8 hyperlink sequences so the human
499+
/// (ariadne) rendering can be snapshotted in a clean, machine-independent
500+
/// form. The OSC-8 hyperlink embeds an absolute `file://` path, which is
501+
/// not portable across machines; stripping it leaves the visible
502+
/// `filename:line:col` text untouched.
503+
fn strip_ansi(s: &str) -> String {
504+
let mut out = String::new();
505+
let mut chars = s.chars().peekable();
506+
while let Some(c) = chars.next() {
507+
if c != '\u{1b}' {
508+
out.push(c);
509+
continue;
510+
}
511+
match chars.peek() {
512+
// CSI (e.g. color): ESC [ ... <final byte in @..~>
513+
Some('[') => {
514+
chars.next();
515+
while let Some(&nc) = chars.peek() {
516+
chars.next();
517+
if ('@'..='~').contains(&nc) {
518+
break;
519+
}
520+
}
521+
}
522+
// OSC (e.g. hyperlink): ESC ] ... <BEL or ST (ESC \)>
523+
Some(']') => {
524+
chars.next();
525+
while let Some(nc) = chars.next() {
526+
if nc == '\u{07}' {
527+
break;
528+
}
529+
if nc == '\u{1b}' {
530+
if let Some('\\') = chars.peek() {
531+
chars.next();
532+
}
533+
break;
534+
}
535+
}
536+
}
537+
_ => {}
538+
}
539+
}
540+
out
541+
}
542+
543+
fn test_source_context(filename: &str, content: &str) -> SourceContext {
544+
use std::collections::hash_map::DefaultHasher;
545+
use std::hash::{Hash, Hasher};
546+
547+
let mut ctx = SourceContext::new();
548+
let mut hasher = DefaultHasher::new();
549+
filename.hash(&mut hasher);
550+
let file_id = quarto_source_map::FileId(hasher.finish() as usize);
551+
ctx.add_file_with_id(file_id, filename.to_string(), Some(content.to_string()));
552+
ctx
553+
}
554+
555+
/// Snapshot the three rendering variants (compact, JSON, human) for a
556+
/// single validation error, so changes to any format's wording/structure
557+
/// are caught in one place. The error is produced end-to-end through
558+
/// `validate()` so the source range is real.
559+
#[test]
560+
fn test_all_three_formats_snapshot() {
561+
use crate::{Schema, SchemaRegistry, validate};
562+
563+
let schema_yaml = quarto_yaml::parse(
564+
r#"
565+
object:
566+
properties:
567+
age:
568+
number:
569+
minimum: 0
570+
maximum: 100
571+
"#,
572+
)
573+
.unwrap();
574+
let schema = Schema::from_yaml(&schema_yaml).unwrap();
575+
576+
let doc_content = r#"age: "not a number""#;
577+
let doc = quarto_yaml::parse_file(doc_content, "test.yaml").unwrap();
578+
let source_ctx = test_source_context("test.yaml", doc_content);
579+
580+
let registry = SchemaRegistry::new();
581+
let error = validate(&doc, &schema, &registry, &source_ctx)
582+
.expect_err("validation should fail for type mismatch");
583+
let diagnostic = ValidationDiagnostic::from_validation_error(&error, &source_ctx);
584+
585+
let combined = format!(
586+
"=== compact ===\n{}\n\n=== json ===\n{}\n\n=== human (ANSI stripped) ===\n{}",
587+
diagnostic.to_compact(),
588+
serde_json::to_string_pretty(&diagnostic.to_json()).unwrap(),
589+
strip_ansi(&diagnostic.to_text(&source_ctx)),
590+
);
591+
592+
insta::assert_snapshot!(combined);
593+
}
594+
595+
#[test]
596+
fn test_to_compact() {
597+
use crate::error::ValidationErrorKind;
598+
599+
let source_ctx = SourceContext::new();
600+
601+
// Error at a nested path: location omitted (no yaml_node), code + path + message + hint.
602+
let mut path = InstancePath::new();
603+
path.push_key("format");
604+
path.push_key("html");
605+
let error = ValidationError::new(
606+
ValidationErrorKind::TypeMismatch {
607+
expected: "boolean".to_string(),
608+
got: "string".to_string(),
609+
},
610+
path,
611+
);
612+
let vd = ValidationDiagnostic::from_validation_error(&error, &source_ctx);
613+
let compact = vd.to_compact();
614+
assert_eq!(
615+
compact,
616+
"[Q-1-11] format.html: Expected boolean, got string (Hint: Use `true` or `false` (YAML 1.2 standard)?)"
617+
);
618+
619+
// Root-level error renders `(root)` rather than an empty path.
620+
let error = ValidationError::new(
621+
ValidationErrorKind::MissingRequiredProperty {
622+
property: "version".to_string(),
623+
allowed: None,
624+
expected_type: None,
625+
},
626+
InstancePath::new(),
627+
);
628+
let vd = ValidationDiagnostic::from_validation_error(&error, &source_ctx);
629+
let compact = vd.to_compact();
630+
assert!(compact.starts_with("[Q-1-10] (root): Missing required property 'version'"));
631+
assert!(!compact.contains('\n'), "compact output must be a single line");
632+
}
633+
410634
#[test]
411635
fn test_suggest_fixes() {
412636
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)