use std::path::{Path as StdPath, PathBuf};
use serde::Serialize;
use crate::extract::{self, Found, Options, resolve_format};
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct Diagnostic {
pub(crate) severity: String,
pub(crate) code: String,
pub(crate) message: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub(crate) struct Summary {
pub(crate) strings: usize,
pub(crate) unlocated: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct FileReport {
pub(crate) file: String,
pub(crate) format: String,
pub(crate) strings: Vec<Found>,
pub(crate) diagnostics: Vec<Diagnostic>,
pub(crate) summary: Summary,
}
impl FileReport {
pub(crate) fn was_skipped(&self) -> bool {
self.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "skipped")
}
pub(crate) fn is_incomplete(&self) -> bool {
self.diagnostics
.iter()
.any(|diagnostic| diagnostic.severity == "error")
}
}
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct ScanOptions {
pub(crate) dedupe: bool,
pub(crate) extract: Options,
pub(crate) format: Option<&'static str>,
}
const BINARY_SNIFF_BYTES: usize = 8 * 1024;
pub(crate) fn scan_file(path: &PathBuf, options: ScanOptions) -> Option<FileReport> {
let file = report_path(path);
let format = options.format.unwrap_or_else(|| format_of(path));
let bytes = match std::fs::read(path) {
Ok(bytes) => bytes,
Err(error) => return Some(skipped(file, format, &error.to_string())),
};
if is_binary(&bytes) {
return None;
}
match String::from_utf8(bytes) {
Ok(content) => Some(scan_content(without_bom(&content), file, format, options)),
Err(_) => Some(skipped(file, format, "not UTF-8 text")),
}
}
#[cfg(windows)]
fn report_path(path: &StdPath) -> String {
path.to_string_lossy().replace('\\', "/")
}
#[cfg(not(windows))]
fn report_path(path: &StdPath) -> String {
path.to_string_lossy().into_owned()
}
fn is_binary(bytes: &[u8]) -> bool {
bytes
.iter()
.take(BINARY_SNIFF_BYTES)
.any(|byte| *byte == b'\0')
}
fn format_of(path: &StdPath) -> &'static str {
resolve_format(None, path.file_name().and_then(|name| name.to_str()))
}
pub(crate) fn scan_content(
content: &str,
file: String,
format: &str,
options: ScanOptions,
) -> FileReport {
let examined = extract::examine(content, format, options.extract);
let mut strings = examined.found;
if options.dedupe {
let mut seen = std::collections::HashSet::new();
strings.retain(|found| seen.insert(found.value.clone()));
}
let mut diagnostics = Vec::new();
if let Some(message) = examined.parse_error {
diagnostics.push(Diagnostic {
severity: "warning".to_string(),
code: "unparsed".to_string(),
message,
});
}
let unlocated = strings
.iter()
.filter(|found| found.position.is_none())
.count();
FileReport {
file,
format: format.to_string(),
summary: Summary {
strings: strings.len(),
unlocated,
},
strings,
diagnostics,
}
}
pub(crate) fn exit_code(reports: &[FileReport], strict: bool) -> u8 {
if reports.iter().any(FileReport::is_incomplete) {
return 2;
}
if strict && reports.iter().any(FileReport::was_skipped) {
return 2;
}
u8::from(!reports.iter().any(|report| report.summary.strings > 0))
}
pub(crate) fn describe(report: &FileReport, found: &Found) -> String {
match found.position {
Some(position) => format!(
"{}:{}:{} {}",
report.file, position.line, position.column, found.value
),
None => format!("{}:- {}", report.file, found.value),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::TempTree;
fn plain() -> ScanOptions {
ScanOptions::default()
}
#[test]
fn a_document_with_strings_exits_zero() {
let report = scan_content(r#"{"a":"one"}"#, "a.json".into(), "json", plain());
assert_eq!(report.summary.strings, 1);
assert_eq!(exit_code(&[report], false), 0);
}
#[test]
fn a_document_with_none_exits_one() {
let report = scan_content("{}", "a.json".into(), "json", plain());
assert_eq!(report.summary.strings, 0);
assert_eq!(exit_code(&[report], false), 1);
}
#[test]
fn nothing_to_scan_exits_one() {
assert_eq!(exit_code(&[], false), 1);
}
#[test]
fn a_document_too_deep_to_parse_is_reported_not_half_read() {
let document = format!("{}x{}", "[".repeat(600), "]".repeat(600));
let report = scan_content(&document, "a.yaml".into(), "yaml", plain());
assert_eq!(report.summary.strings, 0);
assert_eq!(report.diagnostics[0].code, "unparsed");
assert!(!report.was_skipped(), "a deep document is not unreadable");
}
#[test]
fn a_parse_failure_is_a_warning_not_an_exit_two() {
let report = scan_content("{not json", "a.json".into(), "json", plain());
assert_eq!(report.diagnostics.len(), 1);
assert_eq!(report.diagnostics[0].severity, "warning");
assert!(!report.was_skipped());
assert_eq!(exit_code(&[report], false), 1);
}
#[test]
fn an_unreadable_file_is_reported_and_does_not_end_the_run() {
let tree = TempTree::new("scan-unreadable");
let report = scan_file(&tree.path().join("gone.json"), plain()).expect("a report");
assert!(report.was_skipped());
assert_eq!(report.diagnostics[0].severity, "warning");
assert_eq!(exit_code(std::slice::from_ref(&report), false), 1);
assert_eq!(exit_code(&[report], true), 2, "--strict is opt-in");
}
#[test]
fn a_binary_file_is_not_a_report_at_all() {
let tree = TempTree::new("scan-binary");
let file = tree.path().join("logo.png");
std::fs::write(&file, [0x89, 0x50, 0x4e, 0x47, 0x00, 0x0d]).expect("a file");
assert!(scan_file(&file, plain()).is_none());
}
#[test]
fn text_that_cannot_be_decoded_is_still_a_named_skip() {
let tree = TempTree::new("scan-invalid-utf8");
let file = tree.path().join("notes.txt");
std::fs::write(&file, [b'h', b'i', 0xff, 0xfe]).expect("a file");
let report = scan_file(&file, plain()).expect("a report");
assert!(report.was_skipped());
assert_eq!(report.diagnostics[0].message, "not UTF-8 text");
assert_eq!(exit_code(std::slice::from_ref(&report), false), 1);
assert_eq!(exit_code(&[report], true), 2);
}
#[test]
fn a_nul_byte_beyond_the_window_does_not_make_a_file_binary() {
let tree = TempTree::new("scan-late-nul");
let file = tree.path().join("big.txt");
let mut bytes = vec![b'a'; BINARY_SNIFF_BYTES];
bytes.extend_from_slice(&[0, b'"', b'x', b'"']);
std::fs::write(&file, bytes).expect("a file");
let report = scan_file(&file, plain()).expect("a report");
assert!(!report.was_skipped());
assert_eq!(report.summary.strings, 1);
}
#[cfg(unix)]
#[test]
fn a_backslash_in_a_unix_file_name_is_not_a_separator() {
let tree = TempTree::new("scan-backslash");
let file = tree.write("od\\d.json", "{\"a\":\"value\"}");
let report = scan_file(&file, plain()).expect("a report");
assert!(report.file.ends_with("od\\d.json"), "{}", report.file);
}
#[test]
fn the_format_comes_from_the_file_name() {
let tree = TempTree::new("scan-format");
let file = tree.write("config.toml", "a = \"value\"\n");
let report = scan_file(&file, plain()).expect("a report");
assert_eq!(report.format, "toml");
assert_eq!(report.summary.strings, 1);
}
#[test]
fn a_source_file_is_read_as_its_language() {
let tree = TempTree::new("scan-source");
let file = tree.write("messages.ts", "const m = 'Delete this?';\n");
let report = scan_file(&file, plain()).expect("a report");
assert_eq!(report.format, "typescript");
assert_eq!(report.strings[0].value, "Delete this?");
}
#[test]
fn an_unclaimed_extension_falls_back_and_still_answers() {
let tree = TempTree::new("scan-fallback");
let file = tree.write("notes.rtf", "note = 'Delete this?'\n");
let report = scan_file(&file, plain()).expect("a report");
assert_eq!(report.format, "fallback");
assert_eq!(report.strings[0].value, "Delete this?");
}
#[test]
fn a_forced_format_overrides_the_file_name() {
let tree = TempTree::new("scan-forced");
let file = tree.write("data.json", "a = \"value\"\n");
let report = scan_file(
&file,
ScanOptions {
format: Some("toml"),
..plain()
},
)
.expect("a report");
assert_eq!(report.format, "toml");
assert_eq!(report.summary.strings, 1);
}
#[test]
fn dedupe_collapses_repeats_to_the_first() {
let content = r#"{"a":"same","b":"other","c":"same"}"#;
let kept = scan_content(content, "a.json".into(), "json", plain());
assert_eq!(kept.summary.strings, 3);
let deduped = scan_content(
content,
"a.json".into(),
"json",
ScanOptions {
dedupe: true,
..plain()
},
);
assert_eq!(deduped.summary.strings, 2);
assert_eq!(deduped.strings[0].value, "same");
assert_eq!(
deduped.strings[0].position.expect("a position").line,
1,
"the first occurrence keeps its own position"
);
}
#[test]
fn values_the_source_does_not_spell_are_counted() {
let report = scan_content(
"a: plain\nb: |\n first\n second\n",
"a.yaml".into(),
"yaml",
plain(),
);
assert_eq!(report.summary.strings, 2);
assert_eq!(report.summary.unlocated, 1);
assert!(report.strings[1].position.is_none());
}
#[test]
fn json_locates_a_value_the_source_does_not_spell() {
let report = scan_content(r#"{"a":"first\nsecond"}"#, "a.json".into(), "json", plain());
assert_eq!(report.summary.unlocated, 0);
assert_eq!(report.strings[0].position.expect("a position").column, 7);
}
#[test]
fn the_human_line_carries_the_position_when_there_is_one() {
let report = scan_content(r#"{"a":"one"}"#, "a.json".into(), "json", plain());
assert_eq!(describe(&report, &report.strings[0]), "a.json:1:7 one");
}
#[test]
fn the_human_line_says_so_when_there_is_no_position() {
let report = scan_content("b: |\n x\n y\n", "a.yaml".into(), "yaml", plain());
assert!(describe(&report, &report.strings[0]).starts_with("a.yaml:-"));
}
}
fn skipped(file: String, format: &'static str, reason: &str) -> FileReport {
FileReport {
file,
format: format.to_string(),
strings: Vec::new(),
diagnostics: vec![Diagnostic {
severity: "warning".to_string(),
code: "skipped".to_string(),
message: reason.to_string(),
}],
summary: Summary {
strings: 0,
unlocated: 0,
},
}
}
pub(crate) fn without_bom(content: &str) -> &str {
content.strip_prefix('\u{feff}').unwrap_or(content)
}
#[cfg(test)]
mod hazards {
use super::*;
#[test]
fn a_byte_order_mark_is_not_part_of_the_document() {
assert_eq!(without_bom("\u{feff}abc"), "abc");
assert_eq!(without_bom("abc"), "abc");
assert_eq!(without_bom("a\u{feff}b"), "a\u{feff}b");
}
}