use std::collections::HashMap;
use std::path::PathBuf;
use crate::bib::ast::{entry_type, field_name, field_value, fields};
use crate::bib::syntax::{SyntaxElement, SyntaxKind, SyntaxNode};
use crate::linter::diagnostic::{Diagnostic, Severity};
use super::{BibRule, BibRuleContext};
pub struct DuplicateField;
impl BibRule for DuplicateField {
fn id(&self) -> &'static str {
"duplicate-field"
}
fn default_severity(&self) -> Severity {
Severity::Warning
}
fn interests(&self) -> &'static [SyntaxKind] {
&[SyntaxKind::ENTRY]
}
fn check(&self, el: &SyntaxElement, _ctx: &BibRuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let Some(entry) = el.as_node() else {
return;
};
let ty = entry_type(entry);
let mut first_value: HashMap<String, Option<String>> = HashMap::new();
for field in fields(entry) {
let Some(name) = field_name(&field) else {
continue;
};
let lower = name.to_lowercase();
let value = field_value(&field).map(|v| v.to_string().trim().to_string());
let Some(kept) = first_value.get(&lower) else {
first_value.insert(lower, value);
continue; };
let identical = matches!((kept, &value), (Some(a), Some(b)) if a == b);
let fix = identical
.then(|| {
super::edits::field_deletion_fix(
&field,
format!("remove duplicate field `{name}`"),
)
})
.flatten();
let range = field_name_range(&field).unwrap_or_else(|| field.text_range());
let message = match &ty {
Some(ty) => format!("duplicate field `{name}` on `{ty}` entry"),
None => format!("duplicate field `{name}`"),
};
sink.push(Diagnostic {
rule: self.id(),
severity: self.default_severity(),
path: PathBuf::new(),
start: usize::from(range.start()),
end: usize::from(range.end()),
message,
fix,
});
}
}
}
fn field_name_range(field: &SyntaxNode) -> Option<rowan::TextRange> {
field
.children()
.find(|c| c.kind() == SyntaxKind::FIELD_NAME)
.map(|n| n.text_range())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bib::parse;
use crate::bib::semantic::Model;
fn findings(src: &str) -> Vec<Diagnostic> {
let root = parse(src).syntax();
let model = Model::build(&root);
let ctx = BibRuleContext {
path: std::path::Path::new("x.bib"),
root: &root,
model: &model,
db: crate::bib::semantic::builtin(),
};
let mut out = Vec::new();
for el in root.descendants_with_tokens() {
if DuplicateField.interests().contains(&el.kind()) {
DuplicateField.check(&el, &ctx, &mut out);
}
}
out
}
#[test]
fn flags_repeated_field() {
let out = findings("@article{k, author = {A}, author = {B}}\n");
assert_eq!(out.len(), 1);
assert_eq!(out[0].rule, "duplicate-field");
assert!(
out[0].message.contains("`author`"),
"got: {}",
out[0].message
);
}
#[test]
fn flags_each_extra_occurrence() {
let out = findings("@misc{k, note = {a}, note = {b}, note = {c}}\n");
assert_eq!(out.len(), 2);
assert!(out.iter().all(|d| d.message.contains("`note`")));
}
#[test]
fn case_insensitive() {
let out = findings("@article{k, Author = {A}, author = {B}}\n");
assert_eq!(out.len(), 1);
}
#[test]
fn distinct_fields_are_fine() {
assert!(findings("@article{k, author = {A}, title = {T}}\n").is_empty());
}
#[test]
fn underlines_the_second_occurrence() {
let src = "@article{k, author = {A}, author = {B}}\n";
let out = findings(src);
assert_eq!(out.len(), 1);
let start = src.find("author = {B}").unwrap();
let end = start + "author".len();
assert_eq!((out[0].start, out[0].end), (start, end));
}
#[test]
fn differing_values_are_report_only() {
let out = findings("@article{k, author = {A}, author = {B}}\n");
assert_eq!(out.len(), 1);
assert!(out[0].fix.is_none());
}
#[test]
fn identical_values_offer_a_deletion_fix() {
let out = findings("@misc{k, note = {x}, note = {x}}\n");
assert_eq!(out.len(), 1);
assert!(out[0].fix.is_some());
}
fn fixed(src: &str) -> String {
let out = findings(src);
assert_eq!(out.len(), 1, "expected exactly one finding");
let fix = out[0].fix.as_ref().expect("a fix");
let mut s = src.to_string();
s.replace_range(fix.start..fix.end, &fix.content);
s
}
#[test]
fn fix_removes_redundant_last_field() {
let src = "@misc{k,\n note = {x},\n note = {x}\n}\n";
assert_eq!(fixed(src), "@misc{k,\n note = {x}\n}\n");
}
#[test]
fn fix_removes_redundant_middle_field() {
let src = "@misc{k,\n note = {x},\n note = {x},\n year = 2020\n}\n";
assert_eq!(fixed(src), "@misc{k,\n note = {x},\n year = 2020\n}\n");
}
#[test]
fn differing_delimiters_are_not_identical() {
let out = findings("@misc{k, note = {x}, note = \"x\"}\n");
assert_eq!(out.len(), 1);
assert!(out[0].fix.is_none());
}
}