From 9a4245bfd88ece278cb6138be99643ef81a08549 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Tue, 16 Jun 2026 17:57:09 -0500 Subject: [PATCH] Add a compact output mode (for LLMs) --- Cargo.lock | 1 + crates/quarto-yaml-validation/Cargo.toml | 3 + crates/quarto-yaml-validation/README.md | 14 ++ .../quarto-yaml-validation/src/diagnostic.rs | 224 ++++++++++++++++++ ...ic__tests__all_three_formats_snapshot.snap | 53 +++++ crates/validate-yaml/src/main.rs | 71 ++++-- 6 files changed, 346 insertions(+), 20 deletions(-) create mode 100644 crates/quarto-yaml-validation/src/snapshots/quarto_yaml_validation__diagnostic__tests__all_three_formats_snapshot.snap diff --git a/Cargo.lock b/Cargo.lock index 8bf345dbd..b60cd126c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3991,6 +3991,7 @@ name = "quarto-yaml-validation" version = "0.2.0" dependencies = [ "anyhow", + "insta", "quarto-error-reporting", "quarto-source-map", "quarto-yaml", diff --git a/crates/quarto-yaml-validation/Cargo.toml b/crates/quarto-yaml-validation/Cargo.toml index d1ec54065..b8f8ebdf8 100644 --- a/crates/quarto-yaml-validation/Cargo.toml +++ b/crates/quarto-yaml-validation/Cargo.toml @@ -28,5 +28,8 @@ quarto-error-reporting.workspace = true # Additional dependencies for validation regex = "1.12" +[dev-dependencies] +insta.workspace = true + [lints] workspace = true diff --git a/crates/quarto-yaml-validation/README.md b/crates/quarto-yaml-validation/README.md index 12d9cf60f..620892c7b 100644 --- a/crates/quarto-yaml-validation/README.md +++ b/crates/quarto-yaml-validation/README.md @@ -101,6 +101,10 @@ match validate(&doc, &schema, ®istry, &source_ctx) { // Or get machine-readable JSON let json = diagnostic.to_json(); println!("{}", serde_json::to_string_pretty(&json)?); + + // Or get a single compact line, optimized for token-efficient + // consumption (e.g. feeding errors to an LLM): + eprintln!("{}", diagnostic.to_compact()); } } ``` @@ -150,6 +154,16 @@ Error: [Q-1-16] YAML Validation Failed } ``` +**Compact Output (`diagnostic.to_compact()`):** + +A single line per error — `file:line:col [CODE] path: message (hint: ...)` — +with no box drawing or redundant structural fields. Designed for +token-efficient consumption for LLMs. + +``` +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?) +``` + ## Documentation - **[SCHEMA-FROM-YAML.md](./SCHEMA-FROM-YAML.md)**: Complete YAML syntax reference with examples diff --git a/crates/quarto-yaml-validation/src/diagnostic.rs b/crates/quarto-yaml-validation/src/diagnostic.rs index 4dc3f279a..b4f30e700 100644 --- a/crates/quarto-yaml-validation/src/diagnostic.rs +++ b/crates/quarto-yaml-validation/src/diagnostic.rs @@ -184,6 +184,77 @@ impl ValidationDiagnostic { self.diagnostic.to_text(Some(source_ctx)) } + /// Render as a single compact line, optimized for token-efficient + /// consumption (e.g. feeding validation errors to an LLM). + /// + /// Format: `file:line:col [CODE] path: message (hint: ...)` + /// + /// The location prefix is omitted when no source range is available, and + /// the path renders as `(root)` for top-level errors. Unlike [`to_text`], + /// this drops the ariadne box drawing, source snippet, and the redundant + /// schema-constraint line; hints are kept because they materially help a + /// model propose a correct fix. Multiple hints are joined with `; `. + /// + /// [`to_text`]: Self::to_text + /// + /// # Example + /// + /// ```ignore + /// for d in diagnostics { + /// println!("{}", d.to_compact()); + /// } + /// ``` + pub fn to_compact(&self) -> String { + let mut out = String::new(); + + if let Some(range) = &self.source_range { + out.push_str(&format!( + "{}:{}:{} ", + range.filename, range.start_line, range.start_column + )); + } + + out.push_str(&format!("[{}] ", self.code)); + + let path = Self::instance_path_string(&self.instance_path); + if path.is_empty() { + out.push_str("(root): "); + } else { + out.push_str(&path); + out.push_str(": "); + } + + out.push_str(&self.message()); + + let hints = self.hints(); + if !hints.is_empty() { + out.push_str(&format!(" (Hint: {})", hints.join("; "))); + } + + out + } + + /// Render an instance path as a compact dotted/indexed string. + /// + /// e.g. `[Key("authors"), Index(0), Key("name")]` -> `authors[0].name`. + fn instance_path_string(segments: &[PathSegment]) -> String { + let mut out = String::new(); + for seg in segments { + match seg { + PathSegment::Key(k) => { + if !out.is_empty() { + out.push('.'); + } + out.push_str(k); + } + PathSegment::Index(i) => { + out.push_str(&format!("[{}]", i)); + } + } + } + out + } + /// Helper: Build DiagnosticMessage for text rendering fn build_diagnostic_message( error: &ValidationError, @@ -389,6 +460,159 @@ mod tests { assert_eq!(error.error_code(), "Q-1-18"); } + #[test] + fn test_instance_path_string() { + assert_eq!(ValidationDiagnostic::instance_path_string(&[]), ""); + assert_eq!( + ValidationDiagnostic::instance_path_string(&[PathSegment::Key("format".to_string())]), + "format" + ); + assert_eq!( + ValidationDiagnostic::instance_path_string(&[ + PathSegment::Key("authors".to_string()), + PathSegment::Index(0), + PathSegment::Key("name".to_string()), + ]), + "authors[0].name" + ); + } + + /// Strip ANSI SGR color codes and OSC-8 hyperlink sequences so the human + /// (ariadne) rendering can be snapshotted in a clean, machine-independent + /// form. The OSC-8 hyperlink embeds an absolute `file://` path, which is + /// not portable across machines; stripping it leaves the visible + /// `filename:line:col` text untouched. + fn strip_ansi(s: &str) -> String { + let mut out = String::new(); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if c != '\u{1b}' { + out.push(c); + continue; + } + match chars.peek() { + // CSI (e.g. color): ESC [ ... + Some('[') => { + chars.next(); + while let Some(&nc) = chars.peek() { + chars.next(); + if ('@'..='~').contains(&nc) { + break; + } + } + } + // OSC (e.g. hyperlink): ESC ] ... + Some(']') => { + chars.next(); + while let Some(nc) = chars.next() { + if nc == '\u{07}' { + break; + } + if nc == '\u{1b}' { + if let Some('\\') = chars.peek() { + chars.next(); + } + break; + } + } + } + _ => {} + } + } + out + } + + fn test_source_context(filename: &str, content: &str) -> SourceContext { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + + let mut ctx = SourceContext::new(); + let mut hasher = DefaultHasher::new(); + filename.hash(&mut hasher); + let file_id = quarto_source_map::FileId(hasher.finish() as usize); + ctx.add_file_with_id(file_id, filename.to_string(), Some(content.to_string())); + ctx + } + + /// Snapshot the three rendering variants (compact, JSON, human) for a + /// single validation error, so changes to any format's wording/structure + /// are caught in one place. The error is produced end-to-end through + /// `validate()` so the source range is real. + #[test] + fn test_all_three_formats_snapshot() { + use crate::{Schema, SchemaRegistry, validate}; + + let schema_yaml = quarto_yaml::parse( + r#" +object: + properties: + age: + number: + minimum: 0 + maximum: 100 +"#, + ) + .unwrap(); + let schema = Schema::from_yaml(&schema_yaml).unwrap(); + + let doc_content = r#"age: "not a number""#; + let doc = quarto_yaml::parse_file(doc_content, "test.yaml").unwrap(); + let source_ctx = test_source_context("test.yaml", doc_content); + + let registry = SchemaRegistry::new(); + let error = validate(&doc, &schema, ®istry, &source_ctx) + .expect_err("validation should fail for type mismatch"); + let diagnostic = ValidationDiagnostic::from_validation_error(&error, &source_ctx); + + let combined = format!( + "=== compact ===\n{}\n\n=== json ===\n{}\n\n=== human (ANSI stripped) ===\n{}", + diagnostic.to_compact(), + serde_json::to_string_pretty(&diagnostic.to_json()).unwrap(), + strip_ansi(&diagnostic.to_text(&source_ctx)), + ); + + insta::assert_snapshot!(combined); + } + + #[test] + fn test_to_compact() { + use crate::error::ValidationErrorKind; + + let source_ctx = SourceContext::new(); + + // Error at a nested path: location omitted (no yaml_node), code + path + message + hint. + let mut path = InstancePath::new(); + path.push_key("format"); + path.push_key("html"); + let error = ValidationError::new( + ValidationErrorKind::TypeMismatch { + expected: "boolean".to_string(), + got: "string".to_string(), + }, + path, + ); + let vd = ValidationDiagnostic::from_validation_error(&error, &source_ctx); + let compact = vd.to_compact(); + assert_eq!( + compact, + "[Q-1-11] format.html: Expected boolean, got string (Hint: Use `true` or `false` (YAML 1.2 standard)?)" + ); + + // Root-level error renders `(root)` rather than an empty path. + let error = ValidationError::new( + ValidationErrorKind::MissingRequiredProperty { + property: "version".to_string(), + allowed: None, + expected_type: None, + }, + InstancePath::new(), + ); + let vd = ValidationDiagnostic::from_validation_error(&error, &source_ctx); + let compact = vd.to_compact(); + assert!(compact.starts_with("[Q-1-10] (root): Missing required property 'version'")); + assert!(!compact.contains('\n'), "compact output must be a single line"); + } + #[test] fn test_suggest_fixes() { use crate::error::ValidationErrorKind; diff --git a/crates/quarto-yaml-validation/src/snapshots/quarto_yaml_validation__diagnostic__tests__all_three_formats_snapshot.snap b/crates/quarto-yaml-validation/src/snapshots/quarto_yaml_validation__diagnostic__tests__all_three_formats_snapshot.snap new file mode 100644 index 000000000..974e72dbf --- /dev/null +++ b/crates/quarto-yaml-validation/src/snapshots/quarto_yaml_validation__diagnostic__tests__all_three_formats_snapshot.snap @@ -0,0 +1,53 @@ +--- +source: crates/quarto-yaml-validation/src/diagnostic.rs +expression: combined +--- +=== compact === +test.yaml:1:6 [Q-1-11] age: Expected number, got string (Hint: Use a numeric value without quotes?) + +=== json === +{ + "code": "Q-1-11", + "error_kind": { + "data": { + "expected": "number", + "got": "string" + }, + "type": "TypeMismatch" + }, + "hints": [ + "Use a numeric value without quotes?" + ], + "instance_path": [ + { + "type": "Key", + "value": "age" + } + ], + "message": "Expected number, got string", + "schema_path": [ + "object", + "number" + ], + "source_range": { + "end_column": 18, + "end_line": 1, + "end_offset": 17, + "filename": "test.yaml", + "start_column": 6, + "start_line": 1, + "start_offset": 5 + } +} + +=== human (ANSI stripped) === +Error: [Q-1-11] YAML Validation Failed + ╭─[ test.yaml:1:6 ] + │ + 1 │ age: "not a number" + │ ──────┬───── + │ ╰─────── Expected number, got string +───╯ +✖ At document path: `age` +ℹ Schema constraint: object > number +ℹ Use a numeric value without quotes? diff --git a/crates/validate-yaml/src/main.rs b/crates/validate-yaml/src/main.rs index b8bc253da..c6e03139b 100644 --- a/crates/validate-yaml/src/main.rs +++ b/crates/validate-yaml/src/main.rs @@ -1,10 +1,23 @@ use anyhow::{Context, Result}; -use clap::Parser; +use clap::{Parser, ValueEnum}; use quarto_yaml_validation::{Schema, SchemaRegistry, ValidationDiagnostic, validate}; use std::fs; use std::path::PathBuf; use std::process; +/// How to render validation output +#[derive(Clone, Debug, Default, PartialEq, Eq, ValueEnum)] +enum OutputFormat { + /// Rich ariadne-style diagnostics with source snippets (default) + #[default] + Human, + /// Structured JSON, one object per error + Json, + /// One compact line per error: `file:line:col [CODE] path: message (hint: ...)`. + /// Optimized for token-efficient consumption, e.g. feeding errors to an LLM. + Compact, +} + /// Validate a YAML document against a schema #[derive(Parser, Debug)] #[command(name = "validate-yaml")] @@ -18,7 +31,11 @@ struct Args { #[arg(long, value_name = "FILE")] schema: PathBuf, - /// Output errors as JSON instead of text + /// Output format + #[arg(long, value_enum, default_value_t = OutputFormat::Human)] + format: OutputFormat, + + /// Deprecated alias for `--format json` #[arg(long)] json: bool, } @@ -103,17 +120,24 @@ fn run() -> Result<()> { // Create a schema registry (empty for now, but needed for $ref resolution) let registry = SchemaRegistry::new(); + // `--json` is a deprecated alias for `--format json`. + let format = if args.json { + OutputFormat::Json + } else { + args.format.clone() + }; + // Validate the document against the schema match validate(&input_yaml, &schema, ®istry, &source_ctx) { Ok(()) => { - if args.json { - // JSON success output - println!(r#"{{"success": true}}"#); - } else { - // Human-readable success output - println!("✓ Validation successful"); - println!(" Input: {}", args.input.display()); - println!(" Schema: {}", args.schema.display()); + match format { + OutputFormat::Json => println!(r#"{{"success": true}}"#), + OutputFormat::Compact => println!("ok"), + OutputFormat::Human => { + println!("✓ Validation successful"); + println!(" Input: {}", args.input.display()); + println!(" Schema: {}", args.schema.display()); + } } Ok(()) } @@ -121,16 +145,23 @@ fn run() -> Result<()> { // Convert ValidationError to ValidationDiagnostic let diagnostic = ValidationDiagnostic::from_validation_error(&error, &source_ctx); - if args.json { - // JSON error output with structured paths and source ranges - let json = serde_json::json!({ - "success": false, - "errors": [diagnostic.to_json()] - }); - println!("{}", serde_json::to_string_pretty(&json)?); - } else { - // Human-readable error output with ariadne-style rendering - eprint!("{}", diagnostic.to_text(&source_ctx)); + match format { + OutputFormat::Json => { + // JSON error output with structured paths and source ranges + let json = serde_json::json!({ + "success": false, + "errors": [diagnostic.to_json()] + }); + println!("{}", serde_json::to_string_pretty(&json)?); + } + OutputFormat::Compact => { + // One compact line, optimized for LLM consumption + eprintln!("{}", diagnostic.to_compact()); + } + OutputFormat::Human => { + // Human-readable error output with ariadne-style rendering + eprint!("{}", diagnostic.to_text(&source_ctx)); + } } process::exit(1); }