use anyhow::{Context, Result};
use biblatex::{Bibliography, ChunksExt, Entry, EntryType};
use super::fetch::CliExit;
use super::output::OutputMode;
#[derive(Clone, Copy, PartialEq, Eq)]
enum Severity {
Error,
Warning,
}
impl Severity {
fn as_str(self) -> &'static str {
match self {
Severity::Error => "error",
Severity::Warning => "warning",
}
}
}
type Req = &'static [&'static str];
fn required_fields(t: &EntryType) -> Vec<Req> {
match t {
EntryType::Article => vec![
&["author"],
&["title"],
&["journal", "journaltitle"],
&["year", "date"],
],
EntryType::Book | EntryType::MvBook => vec![
&["author", "editor"],
&["title"],
&["publisher"],
&["year", "date"],
],
EntryType::InProceedings | EntryType::InCollection | EntryType::InBook => {
vec![&["author"], &["title"], &["booktitle"], &["year", "date"]]
}
EntryType::Proceedings | EntryType::MvProceedings => {
vec![&["title"], &["year", "date"]]
}
EntryType::PhdThesis | EntryType::MastersThesis | EntryType::Thesis => vec![
&["author"],
&["title"],
&["school", "institution"],
&["year", "date"],
],
EntryType::TechReport | EntryType::Report => {
vec![&["author"], &["title"], &["institution"], &["year", "date"]]
}
_ => vec![&["title"]],
}
}
fn has_any(entry: &Entry, names: Req) -> bool {
names.iter().any(|n| {
entry
.fields
.get(*n)
.is_some_and(|c| !c.format_verbatim().trim().is_empty())
})
}
fn entry_type_label(t: &EntryType) -> String {
format!("{t:?}").to_ascii_lowercase()
}
pub fn run(path: String, strict: bool, mode: OutputMode) -> Result<()> {
let text = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read bibliography file {path}"))?;
let mut errors = 0u32;
let mut warnings = 0u32;
let mut emit = |key: &str, entry_type: &str, rule: &str, sev: Severity, message: String| {
match sev {
Severity::Error => errors += 1,
Severity::Warning => warnings += 1,
}
let record = serde_json::json!({
"key": key,
"entry_type": entry_type,
"rule": rule,
"severity": sev.as_str(),
"message": message,
});
#[allow(clippy::print_stdout)]
{
println!("{record}");
}
};
match Bibliography::parse(&text) {
Err(e) => {
emit(
"",
"",
"parse_error",
Severity::Error,
format!("file did not parse as BibTeX: {e}"),
);
}
Ok(bib) => {
for entry in bib.iter() {
let key = entry.key.clone();
let et = entry_type_label(&entry.entry_type);
for slot in required_fields(&entry.entry_type) {
if !has_any(entry, slot) {
emit(
&key,
&et,
"missing_required_field",
Severity::Warning,
format!("missing expected field for `{et}`: {}", slot.join(" / ")),
);
}
}
for (name, chunks) in &entry.fields {
if chunks.format_verbatim().trim().is_empty() {
emit(
&key,
&et,
"empty_field",
Severity::Warning,
format!("field `{name}` is present but empty"),
);
}
}
if let Some(chunks) = entry.fields.get("title") {
let rendered = chunks.to_biblatex_string(false);
let dollars = rendered.matches('$').count();
if rendered.contains("$$") || dollars % 2 == 1 {
emit(
&key,
&et,
"title_math_hazard",
Severity::Warning,
"title math is not clean inline `$...$` (found `$$` or an unbalanced \
`$`); some renderers (e.g. DocumenterCitations) cannot process it"
.to_string(),
);
}
}
}
}
}
let total = errors + warnings;
if mode != OutputMode::Quiet {
#[allow(clippy::print_stderr)]
{
eprintln!(
"lint: {total} findings — {errors} error, {warnings} warning{}",
if strict {
" (strict: warnings fail)"
} else {
""
}
);
}
}
let failing = errors + if strict { warnings } else { 0 };
if failing == 0 {
Ok(())
} else {
Err(anyhow::Error::new(CliExit(failing.min(255) as i32)))
}
}