use std::path::PathBuf;
use crate::linter::diagnostic::{Diagnostic, Severity};
use super::{Rule, RuleContext};
pub struct UndefinedCitation;
impl Rule for UndefinedCitation {
fn id(&self) -> &'static str {
"undefined-citation"
}
fn default_severity(&self) -> Severity {
Severity::Warning
}
fn check_file(&self, ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let Some(citations) = ctx.citations else {
return;
};
if !citations.is_closed(ctx.path)
|| !citations.is_root_component(ctx.path)
|| citations.has_wildcard_nocite(ctx.path)
{
return;
}
sink.extend(
ctx.model
.citations()
.iter()
.filter(|cite| !citations.is_defined(ctx.path, &cite.name))
.map(|cite| Diagnostic {
rule: self.id(),
severity: self.default_severity(),
path: PathBuf::new(),
start: usize::from(cite.range.start()),
end: usize::from(cite.range.end()),
message: format!("citation of undefined key `{}`", cite.name),
fix: None,
}),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse;
use crate::project::ResolvedCitations;
use crate::project::citations::CiteFileFacts;
use crate::project::graph::{FileFacts, IncludeGraph};
use crate::project::include::BibTarget;
use crate::semantic::SemanticModel;
use crate::syntax::SyntaxNode;
use smol_str::SmolStr;
use std::collections::HashMap;
use std::path::PathBuf;
const DOC: &str = "doc.tex";
const BIB: &str = "refs.bib";
fn resolution(keys: &[&str], rooted: bool, wildcard: bool) -> ResolvedCitations {
let graph = IncludeGraph::build(
&[FileFacts {
path: PathBuf::from(DOC),
include_edges: Vec::new(),
}],
None,
);
let mut bib = HashMap::new();
bib.insert(PathBuf::from(BIB), keys.iter().map(SmolStr::new).collect());
ResolvedCitations::build(
&[CiteFileFacts {
path: PathBuf::from(DOC),
bib_targets: vec![BibTarget::Path(PathBuf::from(BIB))],
nocite_all: wildcard,
is_document_root: rooted,
}],
&graph,
&bib,
)
}
fn findings(src: &str, citations: Option<&ResolvedCitations>) -> Vec<Diagnostic> {
let root = SyntaxNode::new_root(parse(src).green);
let model = SemanticModel::build(&root);
let ctx = RuleContext {
path: std::path::Path::new(DOC),
root: &root,
model: &model,
resolution: None,
citations,
};
let mut out = Vec::new();
UndefinedCitation.check_file(&ctx, &mut out);
out
}
#[test]
fn flags_cite_with_no_matching_entry() {
let r = resolution(&["knuth1984"], true, false);
let out = findings("\\cite{missing}\n", Some(&r));
assert_eq!(out.len(), 1);
assert_eq!(out[0].rule, "undefined-citation");
assert!(out[0].message.contains("missing"));
}
#[test]
fn defined_key_is_fine() {
let r = resolution(&["knuth1984"], true, false);
assert!(findings("\\cite{knuth1984}\n", Some(&r)).is_empty());
}
#[test]
fn inert_without_resolution() {
assert!(findings("\\cite{missing}\n", None).is_empty());
}
#[test]
fn rootless_namespace_does_not_fire() {
let r = resolution(&[], false, false);
assert!(findings("\\cite{missing}\n", Some(&r)).is_empty());
}
#[test]
fn wildcard_nocite_suppresses() {
let r = resolution(&[], true, true);
assert!(findings("\\cite{anything}\n", Some(&r)).is_empty());
}
#[test]
fn cite_list_flags_each_undefined_key() {
let r = resolution(&["a"], true, false);
let out = findings("\\cite{a,b}\n", Some(&r));
assert_eq!(out.len(), 1);
assert!(out[0].message.contains('b'));
}
#[test]
fn natbib_and_biblatex_commands_recognized() {
let r = resolution(&["a"], true, false);
let out = findings("\\citep{x}\\textcite{y}\\parencite{z}\n", Some(&r));
assert_eq!(out.len(), 3);
}
}