Skip to main content

badness_parser/bib/
semantic.rs

1//! Single-file BibTeX semantic analysis: the per-file entry / cite-key / `@string`
2//! model.
3//!
4//! The bib analog of [`crate::semantic`]. BibTeX has no lexical scoping — cite keys
5//! and `@string` macros live in one file-global namespace — so the model is a
6//! **flat** set of vectors (entries, `@string` defs, `@string` uses), built in one
7//! CST walk by [`builder::build`], then a resolve pass flags duplicate cite keys and
8//! marks each `@string` use resolved/undefined. No caching lives here; the salsa
9//! layer that memoizes it (`bib_semantic_model`) is a later increment (Phase 4).
10//!
11//! **Cross-file resolution is out of scope for this slice.** A `@string` defined in
12//! one `.bib` and used in another, or cite keys spanning a multi-file bibliography,
13//! resolve only once a project-level query unions the per-file models — deferred,
14//! exactly as on the LaTeX side. This is per-file only.
15
16pub mod builder;
17pub mod entry;
18pub mod signature;
19
20pub use entry::{Entry, StringDef, StringUse};
21pub use signature::{BibFieldDb, EntrySig, FieldCategory, FieldSig, RequiredField, builtin};
22
23pub use builder::MONTH_MACROS;
24
25use crate::bib::syntax::SyntaxNode;
26
27/// A file's regular entries, `@string` definitions, and `@string` uses.
28///
29/// `Eq` is load-bearing: the future `bib_semantic_model` salsa query will be **not**
30/// `no_eq` (like `semantic_model`), so an edit leaving this model unchanged backdates
31/// and downstream queries are not re-run.
32#[derive(Debug, Default, PartialEq, Eq)]
33pub struct Model {
34    pub(crate) entries: Vec<Entry>,
35    pub(crate) string_defs: Vec<StringDef>,
36    pub(crate) string_uses: Vec<StringUse>,
37}
38
39impl Model {
40    /// Build the model from a bib parse-tree root.
41    pub fn build(root: &SyntaxNode) -> Self {
42        builder::build(root)
43    }
44
45    /// Every regular entry, in source order.
46    pub fn entries(&self) -> &[Entry] {
47        &self.entries
48    }
49
50    /// Every `@string` definition, in source order.
51    pub fn string_defs(&self) -> &[StringDef] {
52        &self.string_defs
53    }
54
55    /// Every `@string` use, in source order.
56    pub fn string_uses(&self) -> &[StringUse] {
57        &self.string_uses
58    }
59
60    /// Entries whose cite key duplicates an earlier one (the 2nd+ occurrence).
61    ///
62    /// A per-file fact, **not** a lint signal: a `duplicate-key` diagnostic is built
63    /// on this by the linter (Phase 3), which decides severity and how to point at
64    /// each occurrence.
65    pub fn duplicate_keys(&self) -> impl Iterator<Item = &Entry> {
66        self.entries.iter().filter(|entry| entry.duplicate)
67    }
68
69    /// `@string` uses that match no in-file definition or predefined month macro.
70    ///
71    /// A per-file fact, **not** a lint signal: in a multi-file bibliography the macro
72    /// may be defined elsewhere. The Phase-3 `undefined-string` lint would gate on a
73    /// cross-file resolution, mirroring `undefined-ref`.
74    pub fn undefined_string_uses(&self) -> impl Iterator<Item = &StringUse> {
75        self.string_uses.iter().filter(|u| !u.resolved)
76    }
77
78    /// `@string` definitions never referenced by any use in this file.
79    ///
80    /// A per-file fact, **not** a lint signal on its own: in a multi-file
81    /// bibliography a `@string` defined here may be referenced from another `.bib`,
82    /// so the Phase-3 `unused-string` lint that builds on this carries a single-file
83    /// false-positive caveat until cross-file resolution gates it (Phase 4), exactly
84    /// as [`undefined_string_uses`](Self::undefined_string_uses) is gated. Both
85    /// `name` fields are lowercased, so the membership test is case-correct.
86    pub fn unused_string_defs(&self) -> impl Iterator<Item = &StringDef> {
87        let used: std::collections::HashSet<&str> =
88            self.string_uses.iter().map(|u| u.name.as_str()).collect();
89        self.string_defs
90            .iter()
91            .filter(move |d| !used.contains(d.name.as_str()))
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use crate::bib::parse;
99
100    fn model_of(src: &str) -> Model {
101        Model::build(&parse(src).syntax())
102    }
103
104    #[test]
105    fn collects_entry_type_and_key() {
106        let model = model_of("@article{knuth1984, title = {X}}\n");
107        assert_eq!(model.entries().len(), 1);
108        assert_eq!(model.entries()[0].entry_type, "article");
109        assert_eq!(model.entries()[0].key, "knuth1984");
110        assert!(!model.entries()[0].duplicate);
111    }
112
113    #[test]
114    fn caches_cleaned_title_and_author() {
115        let model =
116            model_of("@article{k, title = {The {\\TeX}book}, author = {Knuth, Donald E.}}\n");
117        let e = &model.entries()[0];
118        assert_eq!(e.title.as_deref(), Some("The {\\TeX}book"));
119        assert_eq!(e.authors.as_deref(), Some("Knuth, Donald E."));
120    }
121
122    #[test]
123    fn authors_falls_back_to_editor() {
124        let model = model_of("@book{k, title = {T}, editor = {Lamport, Leslie}}\n");
125        let e = &model.entries()[0];
126        assert_eq!(e.authors.as_deref(), Some("Lamport, Leslie"));
127    }
128
129    #[test]
130    fn missing_title_and_author_stay_none() {
131        let model = model_of("@misc{k, year = {2020}}\n");
132        let e = &model.entries()[0];
133        assert!(e.title.is_none());
134        assert!(e.authors.is_none());
135    }
136
137    #[test]
138    fn entry_type_is_lowercased() {
139        let model = model_of("@InProceedings{k, title = {X}}\n");
140        assert_eq!(model.entries()[0].entry_type, "inproceedings");
141    }
142
143    #[test]
144    fn keyless_entry_skipped() {
145        // Recovery case: nothing after the brace, no key to record.
146        let model = model_of("@misc{");
147        assert_eq!(model.entries().len(), 0);
148    }
149
150    #[test]
151    fn duplicate_keys_flagged_case_insensitively() {
152        let model = model_of("@misc{Key, t = {a}}\n@book{key, t = {b}}\n@misc{other, t = {c}}\n");
153        assert_eq!(model.entries().len(), 3);
154        // First `Key` clean, second `key` duplicate, `other` clean.
155        assert!(!model.entries()[0].duplicate);
156        assert!(model.entries()[1].duplicate);
157        assert!(!model.entries()[2].duplicate);
158        let dups: Vec<_> = model.duplicate_keys().map(|e| e.key.as_str()).collect();
159        assert_eq!(dups, vec!["key"]);
160    }
161
162    #[test]
163    fn string_def_collected() {
164        let model = model_of("@string{cup = {Cambridge University Press}}\n");
165        assert_eq!(model.string_defs().len(), 1);
166        assert_eq!(model.string_defs()[0].name, "cup");
167    }
168
169    #[test]
170    fn string_use_resolved_by_in_file_def() {
171        let model = model_of("@string{cup = {C}}\n@book{k, publisher = cup}\n");
172        assert_eq!(model.string_uses().len(), 1);
173        assert_eq!(model.string_uses()[0].name, "cup");
174        assert!(model.string_uses()[0].resolved);
175        assert_eq!(model.undefined_string_uses().count(), 0);
176    }
177
178    #[test]
179    fn month_macro_use_is_resolved() {
180        let model = model_of("@article{k, month = jan}\n");
181        assert_eq!(model.string_uses().len(), 1);
182        assert!(model.string_uses()[0].resolved);
183    }
184
185    #[test]
186    fn undefined_string_use_reported() {
187        let model = model_of("@book{k, publisher = nope}\n");
188        assert_eq!(model.undefined_string_uses().count(), 1);
189        assert_eq!(model.string_uses()[0].name, "nope");
190    }
191
192    #[test]
193    fn number_value_is_not_a_string_use() {
194        let model = model_of("@article{k, year = 2020}\n");
195        assert_eq!(model.string_uses().len(), 0);
196    }
197
198    #[test]
199    fn unused_string_def_reported() {
200        let model =
201            model_of("@string{cup = {C}}\n@string{used = {U}}\n@book{k, publisher = used}\n");
202        let unused: Vec<_> = model
203            .unused_string_defs()
204            .map(|d| d.name.as_str())
205            .collect();
206        assert_eq!(unused, vec!["cup"]);
207    }
208
209    #[test]
210    fn all_strings_used_reports_none() {
211        let model = model_of("@string{cup = {C}}\n@book{k, publisher = cup}\n");
212        assert_eq!(model.unused_string_defs().count(), 0);
213    }
214
215    #[test]
216    fn string_use_in_concatenation() {
217        // `pub` is a macro use; `{Press}` and the quoted piece are not.
218        let model = model_of("@book{k, publisher = pub # { Press}}\n@string{pub = {Foo}}\n");
219        let uses: Vec<_> = model
220            .string_uses()
221            .iter()
222            .map(|u| u.name.as_str())
223            .collect();
224        assert_eq!(uses, vec!["pub"]);
225        assert!(model.string_uses()[0].resolved);
226    }
227}