Skip to content

Commit 39b4133

Browse files
author
Clemens Vasters
committed
feat(rust): add comprehensive validation features and test coverage
- Add merging in instance validator with property/required inheritance - Add uniqueItems validation for arrays (extended mode) - Add patternProperties validation for objects (extended mode) - Add propertyNames validation with pattern/minLength/maxLength support - Add nested namespace reference resolution (e.g., Outer/Inner paths) - Add new error codes: SchemaExtendsNotFound, SchemaExtendsCircular, etc. - Add 15 new instance validator tests for extends, uniqueItems, patternProperties, propertyNames - Add error accuracy tests verifying 15 invalid schemas produce correct error codes - Add warning accuracy tests verifying 16 warning schemas produce correct warnings - All 295 tests pass with 144 test asset files verified
1 parent ce7de49 commit 39b4133

File tree

7 files changed

+1376
-33
lines changed

7 files changed

+1376
-33
lines changed

rust/src/bin/jstruct.rs

Lines changed: 73 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@ struct FileResult {
110110
#[serde(skip_serializing_if = "Option::is_none")]
111111
error: Option<String>,
112112
errors: Vec<ErrorInfo>,
113+
/// Source content for displaying excerpts (not serialized)
114+
#[serde(skip)]
115+
source_content: Option<String>,
113116
}
114117

115118
/// Error information for JSON output
@@ -118,6 +121,7 @@ struct ErrorInfo {
118121
path: String,
119122
message: String,
120123
code: String,
124+
severity: String,
121125
#[serde(skip_serializing_if = "Option::is_none")]
122126
line: Option<usize>,
123127
#[serde(skip_serializing_if = "Option::is_none")]
@@ -305,12 +309,13 @@ fn check_schema(validator: &SchemaValidator, file: &PathBuf) -> FileResult {
305309
valid: false,
306310
error: Some(e.to_string()),
307311
errors: vec![],
312+
source_content: None,
308313
};
309314
}
310315
};
311316

312317
let result = validator.validate(&content);
313-
validation_result_to_file_result(&file_name, result)
318+
validation_result_to_file_result(&file_name, result, Some(content))
314319
}
315320

316321
/// Validate a single instance file
@@ -333,22 +338,25 @@ fn validate_instance(
333338
valid: false,
334339
error: Some(e.to_string()),
335340
errors: vec![],
341+
source_content: None,
336342
};
337343
}
338344
};
339345

340346
let result = validator.validate(&content, schema);
341-
validation_result_to_file_result(&file_name, result)
347+
validation_result_to_file_result(&file_name, result, Some(content))
342348
}
343349

344350
/// Convert ValidationResult to FileResult
345-
fn validation_result_to_file_result(file: &str, result: ValidationResult) -> FileResult {
351+
fn validation_result_to_file_result(file: &str, result: ValidationResult, source_content: Option<String>) -> FileResult {
346352
let errors: Vec<ErrorInfo> = result
347-
.errors()
353+
.all_errors()
354+
.iter()
348355
.map(|e| ErrorInfo {
349356
path: e.path.clone(),
350357
message: e.message.clone(),
351358
code: e.code.clone(),
359+
severity: e.severity.to_string(),
352360
line: if e.location.is_unknown() {
353361
None
354362
} else {
@@ -367,6 +375,7 @@ fn validation_result_to_file_result(file: &str, result: ValidationResult) -> Fil
367375
valid: result.is_valid(),
368376
error: None,
369377
errors,
378+
source_content,
370379
}
371380
}
372381

@@ -392,23 +401,45 @@ fn output_results(results: &[FileResult], format: OutputFormat, verbose: bool) {
392401

393402
/// Output results as human-readable text
394403
fn output_text(results: &[FileResult], verbose: bool) {
395-
for result in results {
404+
// Pre-compute source lines for all results that have source content
405+
let source_lines: Vec<Option<Vec<&str>>> = results
406+
.iter()
407+
.map(|r| r.source_content.as_ref().map(|s| s.lines().collect()))
408+
.collect();
409+
410+
for (idx, result) in results.iter().enumerate() {
396411
if let Some(ref error) = result.error {
397412
println!("\u{2717} {}: {}", result.file, error);
398413
} else if result.valid {
399414
println!("\u{2713} {}: valid", result.file);
400415
} else {
401416
println!("\u{2717} {}: invalid", result.file);
417+
let lines = source_lines[idx].as_ref();
402418
for error in &result.errors {
403419
let path = if error.path.is_empty() { "/" } else { &error.path };
404-
let loc = if verbose {
405-
error.line.map(|l| {
406-
format!(" (line {}, col {})", l, error.column.unwrap_or(0))
407-
}).unwrap_or_default()
408-
} else {
409-
String::new()
410-
};
411-
println!(" - {}: {}{}", path, error.message, loc);
420+
let severity_icon = if error.severity == "warning" { "\u{26A0}" } else { "\u{2717}" };
421+
422+
// Always show line/column when available
423+
let loc = error.line.map(|l| {
424+
format!(" (line {}, col {})", l, error.column.unwrap_or(0))
425+
}).unwrap_or_default();
426+
427+
println!(" {} [{}] {}: {}{}", severity_icon, error.code, path, error.message, loc);
428+
429+
// In verbose mode, show source excerpt with caret marker
430+
if verbose {
431+
if let (Some(line_num), Some(col), Some(src_lines)) = (error.line, error.column, lines) {
432+
if line_num > 0 && line_num <= src_lines.len() {
433+
let source_line = src_lines[line_num - 1];
434+
println!(" |");
435+
println!(" {} | {}", line_num, source_line);
436+
// Create caret marker at the column position
437+
let line_num_width = line_num.to_string().len();
438+
let padding = " ".repeat(line_num_width + col);
439+
println!(" |{}^", padding);
440+
}
441+
}
442+
}
412443
}
413444
}
414445
}
@@ -428,6 +459,12 @@ fn output_json(results: &[FileResult]) {
428459
fn output_tap(results: &[FileResult], verbose: bool) {
429460
println!("1..{}", results.len());
430461

462+
// Pre-compute source lines for all results that have source content
463+
let source_lines: Vec<Option<Vec<&str>>> = results
464+
.iter()
465+
.map(|r| r.source_content.as_ref().map(|s| s.lines().collect()))
466+
.collect();
467+
431468
for (i, result) in results.iter().enumerate() {
432469
let n = i + 1;
433470

@@ -438,16 +475,31 @@ fn output_tap(results: &[FileResult], verbose: bool) {
438475
println!("ok {} - {}", n, result.file);
439476
} else {
440477
println!("not ok {} - {}", n, result.file);
478+
let lines = source_lines[i].as_ref();
441479
for error in &result.errors {
442480
let path = if error.path.is_empty() { "/" } else { &error.path };
443-
let loc = if verbose {
444-
error.line.map(|l| {
445-
format!(" (line {}, col {})", l, error.column.unwrap_or(0))
446-
}).unwrap_or_default()
447-
} else {
448-
String::new()
449-
};
450-
println!(" # {}: {}{}", path, error.message, loc);
481+
let severity = if error.severity == "warning" { "warning" } else { "error" };
482+
483+
// Always show line/column when available
484+
let loc = error.line.map(|l| {
485+
format!(" (line {}, col {})", l, error.column.unwrap_or(0))
486+
}).unwrap_or_default();
487+
488+
println!(" # [{}] {} {}: {}{}", error.code, severity, path, error.message, loc);
489+
490+
// In verbose mode, show source excerpt
491+
if verbose {
492+
if let (Some(line_num), Some(src_lines)) = (error.line, lines) {
493+
if line_num > 0 && line_num <= src_lines.len() {
494+
let source_line = src_lines[line_num - 1];
495+
println!(" # > {}", source_line);
496+
if let Some(col) = error.column {
497+
let padding = " ".repeat(col.saturating_sub(1));
498+
println!(" # > {}^", padding);
499+
}
500+
}
501+
}
502+
}
451503
}
452504
}
453505
}

rust/src/error_codes.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,16 @@ pub enum SchemaErrorCode {
9898
SchemaImportFailed,
9999
SchemaImportCircular,
100100

101+
// Extends errors
102+
SchemaExtendsNotString,
103+
SchemaExtendsEmpty,
104+
SchemaExtendsNotFound,
105+
SchemaExtendsCircular,
106+
107+
// Altnames errors
108+
SchemaAltnamesNotObject,
109+
SchemaAltnamesValueNotString,
110+
101111
// Composition errors
102112
SchemaAllOfNotArray,
103113
SchemaAnyOfNotArray,
@@ -173,6 +183,12 @@ impl SchemaErrorCode {
173183
Self::SchemaImportNotAllowed => "SCHEMA_IMPORT_NOT_ALLOWED",
174184
Self::SchemaImportFailed => "SCHEMA_IMPORT_FAILED",
175185
Self::SchemaImportCircular => "SCHEMA_IMPORT_CIRCULAR",
186+
Self::SchemaExtendsNotString => "SCHEMA_EXTENDS_NOT_STRING",
187+
Self::SchemaExtendsEmpty => "SCHEMA_EXTENDS_EMPTY",
188+
Self::SchemaExtendsNotFound => "SCHEMA_EXTENDS_NOT_FOUND",
189+
Self::SchemaExtendsCircular => "SCHEMA_EXTENDS_CIRCULAR",
190+
Self::SchemaAltnamesNotObject => "SCHEMA_ALTNAMES_NOT_OBJECT",
191+
Self::SchemaAltnamesValueNotString => "SCHEMA_ALTNAMES_VALUE_NOT_STRING",
176192
Self::SchemaAllOfNotArray => "SCHEMA_ALLOF_NOT_ARRAY",
177193
Self::SchemaAnyOfNotArray => "SCHEMA_ANYOF_NOT_ARRAY",
178194
Self::SchemaOneOfNotArray => "SCHEMA_ONEOF_NOT_ARRAY",
@@ -229,6 +245,8 @@ pub enum InstanceErrorCode {
229245
InstanceTooFewProperties,
230246
InstanceTooManyProperties,
231247
InstanceDependentRequiredMissing,
248+
InstancePatternPropertyMismatch,
249+
InstancePropertyNameInvalid,
232250

233251
// Array errors
234252
InstanceArrayExpected,
@@ -337,6 +355,8 @@ impl InstanceErrorCode {
337355
Self::InstanceTooFewProperties => "INSTANCE_TOO_FEW_PROPERTIES",
338356
Self::InstanceTooManyProperties => "INSTANCE_TOO_MANY_PROPERTIES",
339357
Self::InstanceDependentRequiredMissing => "INSTANCE_DEPENDENT_REQUIRED_MISSING",
358+
Self::InstancePatternPropertyMismatch => "INSTANCE_PATTERN_PROPERTY_MISMATCH",
359+
Self::InstancePropertyNameInvalid => "INSTANCE_PROPERTY_NAME_INVALID",
340360
Self::InstanceArrayExpected => "INSTANCE_ARRAY_EXPECTED",
341361
Self::InstanceArrayTooShort => "INSTANCE_ARRAY_TOO_SHORT",
342362
Self::InstanceArrayTooLong => "INSTANCE_ARRAY_TOO_LONG",

0 commit comments

Comments
 (0)