Skip to main content

badness_parser/bib/
semantic.rs

1//! Single-file BibTeX semantic analysis.
2//!
3//! [`builder::build`] collects entries, `@string` definitions, and uses in one
4//! CST walk. A resolve pass then marks duplicate cite keys and unresolved uses.
5//!
6//! BibTeX has file-global rather than lexical scope. Cross-file resolution and
7//! incremental caching belong to the project layer.
8
9pub mod builder;
10pub mod entry;
11pub mod signature;
12
13pub use entry::{Entry, StringDef, StringUse};
14pub use signature::{BibFieldDb, EntrySig, FieldCategory, FieldSig, RequiredField, builtin};
15
16pub use builder::MONTH_MACROS;
17
18use crate::bib::syntax::SyntaxNode;
19
20/// A file's regular entries, `@string` definitions, and `@string` uses.
21///
22/// Equality lets incremental queries avoid recomputing unchanged dependents.
23#[derive(Debug, Default, PartialEq, Eq)]
24pub struct Model {
25    pub(crate) entries: Vec<Entry>,
26    pub(crate) string_defs: Vec<StringDef>,
27    pub(crate) string_uses: Vec<StringUse>,
28}
29
30impl Model {
31    /// Build the model from a bib parse-tree root.
32    pub fn build(root: &SyntaxNode) -> Self {
33        builder::build(root)
34    }
35
36    /// Every regular entry, in source order.
37    pub fn entries(&self) -> &[Entry] {
38        &self.entries
39    }
40
41    /// Every `@string` definition, in source order.
42    pub fn string_defs(&self) -> &[StringDef] {
43        &self.string_defs
44    }
45
46    /// Every `@string` use, in source order.
47    pub fn string_uses(&self) -> &[StringUse] {
48        &self.string_uses
49    }
50
51    /// Returns entries whose cite key duplicates an earlier entry in this file.
52    pub fn duplicate_keys(&self) -> impl Iterator<Item = &Entry> {
53        self.entries.iter().filter(|entry| entry.duplicate)
54    }
55
56    /// `@string` uses that match no in-file definition or predefined month macro.
57    ///
58    /// A macro may still be defined in another bibliography file.
59    pub fn undefined_string_uses(&self) -> impl Iterator<Item = &StringUse> {
60        self.string_uses.iter().filter(|u| !u.resolved)
61    }
62
63    /// `@string` definitions never referenced by any use in this file.
64    ///
65    /// A definition may still be used from another bibliography file. Names are
66    /// compared in lowercase.
67    pub fn unused_string_defs(&self) -> impl Iterator<Item = &StringDef> {
68        let used: std::collections::HashSet<&str> =
69            self.string_uses.iter().map(|u| u.name.as_str()).collect();
70        self.string_defs
71            .iter()
72            .filter(move |d| !used.contains(d.name.as_str()))
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79    use crate::bib::parse;
80
81    fn model_of(src: &str) -> Model {
82        Model::build(&parse(src).syntax())
83    }
84
85    #[test]
86    fn collects_entry_type_and_key() {
87        let model = model_of("@article{knuth1984, title = {X}}\n");
88        assert_eq!(model.entries().len(), 1);
89        assert_eq!(model.entries()[0].entry_type, "article");
90        assert_eq!(model.entries()[0].key, "knuth1984");
91        assert!(!model.entries()[0].duplicate);
92    }
93
94    #[test]
95    fn caches_cleaned_title_and_author() {
96        let model =
97            model_of("@article{k, title = {The {\\TeX}book}, author = {Knuth, Donald E.}}\n");
98        let e = &model.entries()[0];
99        assert_eq!(e.title.as_deref(), Some("The {\\TeX}book"));
100        assert_eq!(e.authors.as_deref(), Some("Knuth, Donald E."));
101    }
102
103    #[test]
104    fn authors_falls_back_to_editor() {
105        let model = model_of("@book{k, title = {T}, editor = {Lamport, Leslie}}\n");
106        let e = &model.entries()[0];
107        assert_eq!(e.authors.as_deref(), Some("Lamport, Leslie"));
108    }
109
110    #[test]
111    fn missing_title_and_author_stay_none() {
112        let model = model_of("@misc{k, year = {2020}}\n");
113        let e = &model.entries()[0];
114        assert!(e.title.is_none());
115        assert!(e.authors.is_none());
116    }
117
118    #[test]
119    fn entry_type_is_lowercased() {
120        let model = model_of("@InProceedings{k, title = {X}}\n");
121        assert_eq!(model.entries()[0].entry_type, "inproceedings");
122    }
123
124    #[test]
125    fn keyless_entry_skipped() {
126        // Recovery case: nothing after the brace, no key to record.
127        let model = model_of("@misc{");
128        assert_eq!(model.entries().len(), 0);
129    }
130
131    #[test]
132    fn duplicate_keys_flagged_case_insensitively() {
133        let model = model_of("@misc{Key, t = {a}}\n@book{key, t = {b}}\n@misc{other, t = {c}}\n");
134        assert_eq!(model.entries().len(), 3);
135        // First `Key` clean, second `key` duplicate, `other` clean.
136        assert!(!model.entries()[0].duplicate);
137        assert!(model.entries()[1].duplicate);
138        assert!(!model.entries()[2].duplicate);
139        let dups: Vec<_> = model.duplicate_keys().map(|e| e.key.as_str()).collect();
140        assert_eq!(dups, vec!["key"]);
141    }
142
143    #[test]
144    fn string_def_collected() {
145        let model = model_of("@string{cup = {Cambridge University Press}}\n");
146        assert_eq!(model.string_defs().len(), 1);
147        assert_eq!(model.string_defs()[0].name, "cup");
148    }
149
150    #[test]
151    fn string_use_resolved_by_in_file_def() {
152        let model = model_of("@string{cup = {C}}\n@book{k, publisher = cup}\n");
153        assert_eq!(model.string_uses().len(), 1);
154        assert_eq!(model.string_uses()[0].name, "cup");
155        assert!(model.string_uses()[0].resolved);
156        assert_eq!(model.undefined_string_uses().count(), 0);
157    }
158
159    #[test]
160    fn month_macro_use_is_resolved() {
161        let model = model_of("@article{k, month = jan}\n");
162        assert_eq!(model.string_uses().len(), 1);
163        assert!(model.string_uses()[0].resolved);
164    }
165
166    #[test]
167    fn undefined_string_use_reported() {
168        let model = model_of("@book{k, publisher = nope}\n");
169        assert_eq!(model.undefined_string_uses().count(), 1);
170        assert_eq!(model.string_uses()[0].name, "nope");
171    }
172
173    #[test]
174    fn number_value_is_not_a_string_use() {
175        let model = model_of("@article{k, year = 2020}\n");
176        assert_eq!(model.string_uses().len(), 0);
177    }
178
179    #[test]
180    fn unused_string_def_reported() {
181        let model =
182            model_of("@string{cup = {C}}\n@string{used = {U}}\n@book{k, publisher = used}\n");
183        let unused: Vec<_> = model
184            .unused_string_defs()
185            .map(|d| d.name.as_str())
186            .collect();
187        assert_eq!(unused, vec!["cup"]);
188    }
189
190    #[test]
191    fn all_strings_used_reports_none() {
192        let model = model_of("@string{cup = {C}}\n@book{k, publisher = cup}\n");
193        assert_eq!(model.unused_string_defs().count(), 0);
194    }
195
196    #[test]
197    fn string_use_in_concatenation() {
198        // `pub` is a macro use; `{Press}` and the quoted piece are not.
199        let model = model_of("@book{k, publisher = pub # { Press}}\n@string{pub = {Foo}}\n");
200        let uses: Vec<_> = model
201            .string_uses()
202            .iter()
203            .map(|u| u.name.as_str())
204            .collect();
205        assert_eq!(uses, vec!["pub"]);
206        assert!(model.string_uses()[0].resolved);
207    }
208}