use std::path::{Path, PathBuf};
use rux_runtime::{json_string, Document};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Severity {
Error,
Warning,
}
impl Severity {
fn label(self) -> &'static str {
match self {
Severity::Error => "error",
Severity::Warning => "warning",
}
}
}
#[derive(Clone, Debug)]
pub struct Diagnostic {
pub file: PathBuf,
pub line: Option<usize>,
pub column: Option<usize>,
pub severity: Severity,
pub message: String,
}
pub struct Options {
pub paths: Vec<PathBuf>,
pub json: bool,
pub deny_warnings: bool,
}
pub fn run(options: Options) -> i32 {
rux_runtime::set_stderr_echo(false);
let files = match collect_files(&options.paths) {
Ok(files) => files,
Err(err) => {
eprintln!("rux: {err}");
return 2;
}
};
if files.is_empty() {
eprintln!("rux: no .rux files found");
return 2;
}
let mut found = Vec::new();
for file in &files {
found.extend(check_file(file));
}
if options.json {
print!("{}", to_json(&found));
} else {
for d in &found {
println!("{}", render(d));
}
}
let errors = found.iter().filter(|d| d.severity == Severity::Error).count();
let warnings = found.len() - errors;
if !options.json {
report_summary(files.len(), errors, warnings);
}
if errors > 0 || (options.deny_warnings && warnings > 0) {
1
} else {
0
}
}
fn check_file(file: &Path) -> Vec<Diagnostic> {
let _ = rux_runtime::take_warnings();
match Document::load_checked(file) {
Ok(doc) => doc
.diagnostics()
.warnings
.iter()
.map(|w| Diagnostic {
file: file.to_path_buf(),
line: w.line,
column: None,
severity: Severity::Warning,
message: w.message.clone(),
})
.collect(),
Err(err) => {
let _ = rux_runtime::take_warnings();
vec![Diagnostic {
file: err.file.clone().unwrap_or_else(|| file.to_path_buf()),
line: err.line,
column: err.column,
severity: Severity::Error,
message: err.message.clone(),
}]
}
}
}
fn render(d: &Diagnostic) -> String {
let path = d.file.display();
match (d.line, d.column) {
(Some(l), Some(c)) => format!("{path}:{l}:{c}: {}: {}", d.severity.label(), d.message),
(Some(l), None) => format!("{path}:{l}: {}: {}", d.severity.label(), d.message),
_ => format!("{path}: {}: {}", d.severity.label(), d.message),
}
}
fn report_summary(files: usize, errors: usize, warnings: usize) {
let file_word = if files == 1 { "file" } else { "files" };
if errors == 0 && warnings == 0 {
eprintln!("rux: checked {files} {file_word}, no problems found");
} else {
eprintln!(
"rux: checked {files} {file_word}, {errors} error{}, {warnings} warning{}",
if errors == 1 { "" } else { "s" },
if warnings == 1 { "" } else { "s" },
);
}
}
fn to_json(found: &[Diagnostic]) -> String {
let mut out = String::from("[");
for (i, d) in found.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str("\n {");
out.push_str(&format!("\"file\": {}", json_string(&d.file.display().to_string())));
match d.line {
Some(l) => out.push_str(&format!(", \"line\": {l}")),
None => out.push_str(", \"line\": null"),
}
match d.column {
Some(c) => out.push_str(&format!(", \"column\": {c}")),
None => out.push_str(", \"column\": null"),
}
out.push_str(&format!(", \"severity\": \"{}\"", d.severity.label()));
out.push_str(&format!(", \"message\": {}", json_string(&d.message)));
out.push('}');
}
if !found.is_empty() {
out.push('\n');
}
out.push_str("]\n");
out
}
fn collect_files(paths: &[PathBuf]) -> Result<Vec<PathBuf>, String> {
crate::files::collect(paths, crate::files::Components::SkipWhenWalking)
}
#[cfg(test)]
mod tests {
use super::*;
fn diag(line: Option<usize>, column: Option<usize>, severity: Severity) -> Diagnostic {
Diagnostic {
file: PathBuf::from("app.rux"),
line,
column,
severity,
message: "something".into(),
}
}
#[test]
fn a_located_error_renders_like_a_compiler() {
let d = diag(Some(12), Some(5), Severity::Error);
assert_eq!(render(&d), "app.rux:12:5: error: something");
}
#[test]
fn an_unlocated_warning_still_names_its_file() {
let d = diag(None, None, Severity::Warning);
assert_eq!(render(&d), "app.rux: warning: something");
}
#[test]
fn json_escapes_what_would_otherwise_break_it() {
assert_eq!(json_string(r#"a "b" \ c"#), r#""a \"b\" \\ c""#);
assert_eq!(json_string("line\nbreak"), r#""line\nbreak""#);
assert_eq!(json_string(r"examples\form.rux"), r#""examples\\form.rux""#);
}
#[test]
fn json_is_an_array_and_survives_being_empty() {
assert_eq!(to_json(&[]), "[]\n");
let out = to_json(&[diag(Some(3), Some(9), Severity::Error)]);
assert!(out.starts_with("[\n {"), "{out}");
assert!(out.contains(r#""line": 3"#), "{out}");
assert!(out.contains(r#""column": 9"#), "{out}");
assert!(out.contains(r#""severity": "error""#), "{out}");
assert!(out.trim_end().ends_with(']'), "{out}");
}
#[test]
fn json_keeps_null_positions_rather_than_dropping_them() {
let out = to_json(&[diag(None, None, Severity::Warning)]);
assert!(out.contains(r#""line": null"#), "{out}");
assert!(out.contains(r#""column": null"#), "{out}");
}
}