use clap::{Parser, Subcommand, ValueEnum};
use serde::Serialize;
use std::path::PathBuf;
use std::fs;
use std::process;
use ron_schema::{
parse_schema, parse_ron, validate, extract_source_line, resolve_imports,
SchemaResolver, ValidationError, ErrorKind, Warning, WarningKind,
};
#[derive(Serialize)]
struct JsonOutput {
success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
results: Vec<JsonFileResult>,
}
#[derive(Serialize)]
struct JsonFileResult {
file: String,
errors: Vec<JsonError>,
warnings: Vec<JsonError>,
}
#[derive(Serialize)]
struct JsonError {
code: String,
severity: String,
path: String,
message: String,
line: usize,
column: usize,
span: JsonSpan,
}
#[derive(Serialize)]
struct JsonSpan {
start: JsonPosition,
end: JsonPosition,
}
#[derive(Serialize)]
struct JsonPosition {
line: usize,
column: usize,
}
fn to_json_error(error: &ValidationError) -> JsonError {
JsonError {
code: error_code(&error.kind).to_string(),
severity: "error".to_string(),
path: error.path.clone(),
message: error_message(error),
line: error.span.start.line,
column: error.span.start.column,
span: JsonSpan {
start: JsonPosition {
line: error.span.start.line,
column: error.span.start.column,
},
end: JsonPosition {
line: error.span.end.line,
column: error.span.end.column,
},
},
}
}
fn to_json_warning(warning: &Warning) -> JsonError {
JsonError {
code: warning_code(&warning.kind).to_string(),
severity: "warning".to_string(),
path: warning.path.clone(),
message: warning_message(warning),
line: warning.span.start.line,
column: warning.span.start.column,
span: JsonSpan {
start: JsonPosition {
line: warning.span.start.line,
column: warning.span.start.column,
},
end: JsonPosition {
line: warning.span.end.line,
column: warning.span.end.column,
},
},
}
}
fn warning_code(kind: &WarningKind) -> &'static str {
match kind {
WarningKind::FieldOrderMismatch { .. } => "field-order",
}
}
fn warning_message(warning: &Warning) -> String {
match &warning.kind {
WarningKind::FieldOrderMismatch { field_name, expected_after } => {
format!(
"field `{}` appears before `{}` but is declared after it in the schema",
field_name, expected_after
)
}
}
}
fn error_code(kind: &ErrorKind) -> &'static str {
match kind {
ErrorKind::MissingField { .. } => "missing-field",
ErrorKind::UnknownField { .. } => "unknown-field",
ErrorKind::TypeMismatch { .. } => "type-mismatch",
ErrorKind::InvalidEnumVariant { .. } => "invalid-variant",
ErrorKind::InvalidOptionValue { .. } => "invalid-option",
ErrorKind::InvalidListElement { .. } => "invalid-element",
ErrorKind::ExpectedStruct { .. } => "expected-struct",
ErrorKind::ExpectedList { .. } => "expected-list",
ErrorKind::ExpectedOption { .. } => "expected-option",
ErrorKind::InvalidVariantData { .. } => "invalid-variant-data",
ErrorKind::ExpectedMap { .. } => "expected-map",
ErrorKind::InvalidMapKey { .. } => "invalid-map-key",
ErrorKind::InvalidMapValue { .. } => "invalid-map-value",
ErrorKind::ExpectedTuple { .. } => "expected-tuple",
ErrorKind::TupleLengthMismatch { .. } => "tuple-length",
ErrorKind::InvalidTupleElement { .. } => "invalid-tuple-element",
}
}
fn error_message(error: &ValidationError) -> String {
match &error.kind {
ErrorKind::MissingField { field_name } => {
format!("missing required field `{}`", field_name)
}
ErrorKind::UnknownField { field_name } => {
format!("field `{}` is not defined in the schema", field_name)
}
ErrorKind::TypeMismatch { expected, found } => {
format!("field `{}`: expected {}, found {}", error.path, expected, found)
}
ErrorKind::InvalidEnumVariant { enum_name, variant, valid } => {
format!(
"field `{}`: `{}` is not a valid {} variant, expected one of: {}",
error.path, variant, enum_name, valid.join(", ")
)
}
ErrorKind::InvalidOptionValue { expected, found } => {
format!("field `{}`: expected {}, found {}", error.path, expected, found)
}
ErrorKind::InvalidListElement { index, expected, found } => {
format!("field `{}`: element {} expected {}, found {}", error.path, index, expected, found)
}
ErrorKind::ExpectedStruct { found } => {
format!("field `{}`: expected struct, found {}", error.path, found)
}
ErrorKind::ExpectedList { found } => {
format!("field `{}`: expected list, found {}", error.path, found)
}
ErrorKind::ExpectedOption { found } => {
format!("field `{}`: expected Some(...) or None, found {}", error.path, found)
}
ErrorKind::InvalidVariantData { enum_name, variant, expected, found } => {
format!("field `{}`: variant `{}::{}` expected {}, found {}", error.path, enum_name, variant, expected, found)
}
ErrorKind::ExpectedMap { found } => {
format!("field `{}`: expected map, found {}", error.path, found)
}
ErrorKind::InvalidMapKey { key, expected, found } => {
format!("field `{}`: map key {} expected {}, found {}", error.path, key, expected, found)
}
ErrorKind::InvalidMapValue { key, expected, found } => {
format!("field `{}`[{}]: expected {}, found {}", error.path, key, expected, found)
}
ErrorKind::ExpectedTuple { found } => {
format!("field `{}`: expected tuple, found {}", error.path, found)
}
ErrorKind::TupleLengthMismatch { expected, found } => {
format!("field `{}`: expected {} elements, found {}", error.path, expected, found)
}
ErrorKind::InvalidTupleElement { index, expected, found } => {
format!("field `{}`: element {} expected {}, found {}", error.path, index, expected, found)
}
}
}
fn underline_label(kind: &ErrorKind) -> String {
match kind {
ErrorKind::MissingField { field_name } => {
format!("struct ends here without field `{}`", field_name)
}
ErrorKind::UnknownField { .. } => "unknown field".to_string(),
ErrorKind::TypeMismatch { expected, .. } => format!("expected {}", expected),
ErrorKind::InvalidEnumVariant { valid, .. } => {
format!("expected one of: {}", valid.join(", "))
}
ErrorKind::InvalidOptionValue { expected, .. } => format!("expected {}", expected),
ErrorKind::InvalidListElement { expected, .. } => format!("expected {}", expected),
ErrorKind::ExpectedStruct { .. } => "expected struct".to_string(),
ErrorKind::ExpectedList { .. } => "expected list".to_string(),
ErrorKind::ExpectedOption { .. } => "expected Some(...) or None".to_string(),
ErrorKind::InvalidVariantData { expected, .. } => format!("expected {expected}"),
ErrorKind::ExpectedMap { .. } => "expected map".to_string(),
ErrorKind::InvalidMapKey { expected, .. } => format!("expected {expected}"),
ErrorKind::InvalidMapValue { expected, .. } => format!("expected {expected}"),
ErrorKind::ExpectedTuple { .. } => "expected tuple".to_string(),
ErrorKind::TupleLengthMismatch { expected, .. } => format!("expected {expected} elements"),
ErrorKind::InvalidTupleElement { expected, .. } => format!("expected {expected}"),
}
}
fn format_error(error: &ValidationError, source: &str, file_path: &str) -> String {
let line = error.span.start.line;
let col = error.span.start.column;
let source_line = extract_source_line(source, error.span);
let line_num_width = source_line.line_number.to_string().len();
let gutter_pad = " ".repeat(line_num_width);
let underline_start = source_line.highlight_start;
let underline_len = if source_line.highlight_end > source_line.highlight_start {
source_line.highlight_end - source_line.highlight_start
} else {
1
};
let underline_pad = " ".repeat(underline_start);
let underline = "^".repeat(underline_len);
let label = underline_label(&error.kind);
format!(
"error[{}] at {}:{}:{}\n {}\n {} │ {}\n {} │ {}{} {}",
error_code(&error.kind),
file_path,
line,
col,
error_message(error),
source_line.line_number,
source_line.line_text,
gutter_pad,
underline_pad,
underline,
label,
)
}
fn format_warning(warning: &Warning, source: &str, file_path: &str) -> String {
let line = warning.span.start.line;
let col = warning.span.start.column;
let source_line = extract_source_line(source, warning.span);
let line_num_width = source_line.line_number.to_string().len();
let gutter_pad = " ".repeat(line_num_width);
let underline_start = source_line.highlight_start;
let underline_len = if source_line.highlight_end > source_line.highlight_start {
source_line.highlight_end - source_line.highlight_start
} else {
1
};
let underline_pad = " ".repeat(underline_start);
let underline = "^".repeat(underline_len);
format!(
"warning[{}] at {}:{}:{}\n {}\n {} │ {}\n {} │ {}{} out of order",
warning_code(&warning.kind),
file_path,
line,
col,
warning_message(warning),
source_line.line_number,
source_line.line_text,
gutter_pad,
underline_pad,
underline,
)
}
#[derive(Parser)]
#[command(name = "ron-schema", version, about = "Validate RON files against schemas")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Clone, Copy, Default, ValueEnum)]
enum OutputFormat {
#[default]
Human,
Json,
}
#[derive(Subcommand)]
enum Commands {
Validate {
#[arg(long)]
schema: PathBuf,
target: PathBuf,
#[arg(long, default_value = "human")]
format: OutputFormat,
#[arg(long)]
deny_warnings: bool,
},
}
fn validate_file(
schema: &ron_schema::Schema,
file_path: &PathBuf,
display_path: &str,
) -> (usize, usize) {
let source = match fs::read_to_string(file_path) {
Ok(s) => s,
Err(e) => {
eprintln!("error: could not read {}: {}", display_path, e);
return (1, 0);
}
};
let ron_value = match parse_ron(&source) {
Ok(v) => v,
Err(e) => {
let source_line = extract_source_line(&source, e.span);
eprintln!(
"error[parse] at {}:{}:{}\n {:?}\n {} │ {}",
display_path,
e.span.start.line,
e.span.start.column,
e.kind,
source_line.line_number,
source_line.line_text,
);
return (1, 0);
}
};
let result = validate(schema, &ron_value);
for warning in &result.warnings {
println!("{}", format_warning(warning, &source, display_path));
println!();
}
for error in &result.errors {
println!("{}", format_error(error, &source, display_path));
println!();
}
if !result.errors.is_empty() {
println!("Found {} error{} in {}", result.errors.len(), if result.errors.len() == 1 { "" } else { "s" }, display_path);
}
(result.errors.len(), result.warnings.len())
}
fn validate_file_json(
schema: &ron_schema::Schema,
file_path: &PathBuf,
display_path: &str,
) -> JsonFileResult {
let source = match fs::read_to_string(file_path) {
Ok(s) => s,
Err(e) => {
return JsonFileResult {
file: display_path.to_string(),
errors: vec![JsonError {
code: "io-error".to_string(),
severity: "error".to_string(),
path: String::new(),
message: format!("could not read file: {}", e),
line: 0,
column: 0,
span: JsonSpan {
start: JsonPosition { line: 0, column: 0 },
end: JsonPosition { line: 0, column: 0 },
},
}],
warnings: vec![],
};
}
};
let ron_value = match parse_ron(&source) {
Ok(v) => v,
Err(e) => {
return JsonFileResult {
file: display_path.to_string(),
errors: vec![JsonError {
code: "parse".to_string(),
severity: "error".to_string(),
path: String::new(),
message: format!("{:?}", e.kind),
line: e.span.start.line,
column: e.span.start.column,
span: JsonSpan {
start: JsonPosition {
line: e.span.start.line,
column: e.span.start.column,
},
end: JsonPosition {
line: e.span.end.line,
column: e.span.end.column,
},
},
}],
warnings: vec![],
};
}
};
let result = validate(schema, &ron_value);
JsonFileResult {
file: display_path.to_string(),
errors: result.errors.iter().map(to_json_error).collect(),
warnings: result.warnings.iter().map(to_json_warning).collect(),
}
}
struct FileSchemaResolver {
base_dir: PathBuf,
}
impl SchemaResolver for FileSchemaResolver {
fn resolve(&self, import_path: &str) -> Result<String, String> {
let full_path = self.base_dir.join(import_path);
fs::read_to_string(&full_path)
.map_err(|e| format!("could not read {}: {}", full_path.display(), e))
}
}
fn print_json_output(output: &JsonOutput) {
match serde_json::to_string_pretty(output) {
Ok(json) => println!("{json}"),
Err(e) => {
eprintln!("error: failed to serialize JSON output: {e}");
process::exit(2);
}
}
}
fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Validate { schema, target, format, deny_warnings } => {
let schema_source = match fs::read_to_string(&schema) {
Ok(s) => s,
Err(e) => {
let msg = format!("could not read schema {}: {}", schema.display(), e);
match format {
OutputFormat::Human => eprintln!("error: {msg}"),
OutputFormat::Json => print_json_output(&JsonOutput {
success: false,
error: Some(msg),
results: vec![],
}),
}
process::exit(2);
}
};
let mut parsed_schema = match parse_schema(&schema_source) {
Ok(s) => s,
Err(e) => {
let msg = format!(
"schema parse error at {}:{}:{}: {:?}",
schema.display(),
e.span.start.line,
e.span.start.column,
e.kind,
);
match format {
OutputFormat::Human => eprintln!(
"error[schema] at {}:{}:{}\n {:?}",
schema.display(),
e.span.start.line,
e.span.start.column,
e.kind,
),
OutputFormat::Json => print_json_output(&JsonOutput {
success: false,
error: Some(msg),
results: vec![],
}),
}
process::exit(2);
}
};
if !parsed_schema.imports.is_empty() {
let base_dir = schema.parent().unwrap_or_else(|| std::path::Path::new(".")).to_path_buf();
let resolver = FileSchemaResolver { base_dir };
if let Err(e) = resolve_imports(&mut parsed_schema, &resolver) {
let msg = format!(
"import error at {}:{}:{}: {:?}",
schema.display(),
e.span.start.line,
e.span.start.column,
e.kind,
);
match format {
OutputFormat::Human => eprintln!(
"error[import] at {}:{}:{}\n {:?}",
schema.display(),
e.span.start.line,
e.span.start.column,
e.kind,
),
OutputFormat::Json => print_json_output(&JsonOutput {
success: false,
error: Some(msg),
results: vec![],
}),
}
process::exit(2);
}
}
match format {
OutputFormat::Human => run_human(&parsed_schema, &target, deny_warnings),
OutputFormat::Json => run_json(&parsed_schema, &target, deny_warnings),
}
}
}
}
fn run_human(schema: &ron_schema::Schema, target: &PathBuf, deny_warnings: bool) {
if target.is_file() {
let display_path = target.display().to_string();
let (error_count, warning_count) = validate_file(schema, target, &display_path);
if error_count > 0 || (deny_warnings && warning_count > 0) {
process::exit(1);
}
} else if target.is_dir() {
let mut total_files = 0;
let mut files_with_errors = 0;
let mut total_errors = 0;
let mut total_warnings = 0;
let entries = collect_ron_files(target);
for file_path in &entries {
let display_path = file_path.display().to_string();
total_files += 1;
let (error_count, warning_count) = validate_file(schema, file_path, &display_path);
if error_count > 0 {
files_with_errors += 1;
total_errors += error_count;
}
total_warnings += warning_count;
}
println!(
"Validated {} file{}: {} valid, {} with errors ({} error{} total)",
total_files,
if total_files == 1 { "" } else { "s" },
total_files - files_with_errors,
files_with_errors,
total_errors,
if total_errors == 1 { "" } else { "s" },
);
if total_errors > 0 || (deny_warnings && total_warnings > 0) {
process::exit(1);
}
} else {
eprintln!("error: {} is not a file or directory", target.display());
process::exit(2);
}
}
fn run_json(schema: &ron_schema::Schema, target: &PathBuf, deny_warnings: bool) {
let results = if target.is_file() {
let display_path = target.display().to_string();
vec![validate_file_json(schema, target, &display_path)]
} else if target.is_dir() {
let entries = collect_ron_files(target);
entries.iter()
.map(|file_path| {
let display_path = file_path.display().to_string();
validate_file_json(schema, file_path, &display_path)
})
.collect()
} else {
let msg = format!("{} is not a file or directory", target.display());
print_json_output(&JsonOutput {
success: false,
error: Some(msg),
results: vec![],
});
process::exit(2);
};
let has_errors = results.iter().any(|r| !r.errors.is_empty());
let has_warnings = results.iter().any(|r| !r.warnings.is_empty());
print_json_output(&JsonOutput {
success: true,
results,
error: None,
});
if has_errors || (deny_warnings && has_warnings) {
process::exit(1);
}
}
fn collect_ron_files(dir: &PathBuf) -> Vec<PathBuf> {
let mut files = Vec::new();
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
files.extend(collect_ron_files(&path));
} else if path.extension().is_some_and(|ext| ext == "ron") {
files.push(path);
}
}
}
files.sort();
files
}