use crate::check::Severity;
fn severity_word(severity: Severity) -> &'static str {
match severity {
Severity::Block => "error",
Severity::Warn => "warning",
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Finding {
pub check: &'static str,
pub file: String,
pub line: Option<usize>,
pub column: Option<usize>,
pub severity: Severity,
pub message: String,
}
impl Finding {
pub fn new(
check: &'static str,
file: impl Into<String>,
severity: Severity,
message: impl Into<String>,
) -> Self {
Finding {
check,
file: file.into(),
line: None,
column: None,
severity,
message: message.into(),
}
}
pub fn at_offset(mut self, content: &str, offset: usize) -> Self {
let (line, column) = line_col(content, offset);
self.line = Some(line);
self.column = Some(column);
self
}
pub fn at_line(mut self, line: usize) -> Self {
self.line = Some(line);
self
}
pub fn location(&self) -> String {
let mut out = self.file.clone();
if let Some(line) = self.line {
out.push_str(&format!(":{line}"));
if let Some(column) = self.column {
out.push_str(&format!(":{column}"));
}
}
out
}
pub fn render(&self) -> String {
let mut out = self.file.clone();
if let Some(line) = self.line {
out.push_str(&format!(":{line}"));
if let Some(column) = self.column {
out.push_str(&format!(":{column}"));
}
}
format!(
"{out}: {}: {} [{}]",
severity_word(self.severity),
self.message,
self.check
)
}
}
pub fn line_col(content: &str, offset: usize) -> (usize, usize) {
let offset = offset.min(content.len());
let before = &content[..offset];
let line = before.matches('\n').count() + 1;
let line_start = before.rfind('\n').map(|i| i + 1).unwrap_or(0);
let column = content[line_start..offset].chars().count() + 1;
(line, column)
}
pub fn to_json(findings: &[Finding]) -> String {
let items: Vec<String> = findings
.iter()
.map(|f| {
format!(
"{{{},{},{},{},{},{}}}",
crate::json::string_field("check", f.check),
crate::json::string_field("file", &f.file),
crate::json::opt_int_field("line", f.line.map(|l| l as i64)),
crate::json::opt_int_field("column", f.column.map(|c| c as i64)),
crate::json::string_field("severity", severity_word(f.severity)),
crate::json::string_field("message", &f.message),
)
})
.collect();
format!(
"{{{},\"findings\":[{}]}}",
crate::json::string_field("format", "amont-check-v1"),
items.join(",")
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn offsets_become_one_based_positions() {
let src = "abc\ndef\nghi";
assert_eq!(line_col(src, 0), (1, 1));
assert_eq!(line_col(src, 2), (1, 3));
assert_eq!(line_col(src, 4), (2, 1)); assert_eq!(line_col(src, 8), (3, 1));
}
#[test]
fn the_column_counts_characters_not_bytes() {
let src = "let café = dbg!(1);";
let at = src.find("dbg!").unwrap();
assert_eq!(at, 12); assert_eq!(line_col(src, at), (1, 12)); }
#[test]
fn an_offset_past_the_end_clamps() {
let src = "abc";
assert_eq!(line_col(src, 99), (1, 4));
assert_eq!(line_col("", 5), (1, 1));
}
#[test]
fn render_degrades_when_there_is_no_position() {
let whole = Finding::new("large-files", "big.bin", Severity::Block, "12 MB");
assert_eq!(whole.render(), "big.bin: error: 12 MB [large-files]");
let placed = Finding::new("ban-terms", "app.js", Severity::Block, "'debugger'")
.at_offset("a\nb\ndebugger;", 4);
assert_eq!(placed.render(), "app.js:3:1: error: 'debugger' [ban-terms]");
let lined = Finding::new("secrets", "k.pem", Severity::Warn, "a private key").at_line(7);
assert_eq!(lined.render(), "k.pem:7: warning: a private key [secrets]");
}
#[test]
fn json_is_versioned_and_escapes_its_input() {
assert_eq!(
to_json(&[]),
"{\"format\":\"amont-check-v1\",\"findings\":[]}"
);
let f = Finding::new("ban-terms", "a\"b.js", Severity::Block, "x").at_line(2);
let json = to_json(&[f]);
assert!(json.contains(r#""file":"a\"b.js""#), "{json}");
assert!(json.contains(r#""line":2,"column":null"#), "{json}");
}
}