Skip to main content

badness_parser/
semantic.rs

1//! Single-file semantic analysis.
2//!
3//! [`builder::build`] collects definitions and uses in a single CST walk, then
4//! resolves them within the file. Incremental caching belongs to the root crate.
5//!
6//! Labels have document-wide rather than lexical scope. This model remains flat
7//! and leaves cross-file resolution to the project layer.
8
9pub mod builder;
10pub mod define;
11pub mod doc;
12pub mod expl3;
13pub mod label;
14pub mod math;
15pub mod mode;
16pub mod outline;
17pub mod pkgmeta;
18pub mod signature;
19pub mod tikz;
20pub mod xparse;
21
22pub use define::{DefSite, DefSiteKind, scan_definition_sites, scan_definitions};
23pub use doc::{DocAssociation, DocKind, doc_associations};
24pub use label::{
25    CitationRef, ColorDef, ColorDefKind, GlossaryDef, GlossaryDefKind, LabelDef, LabelId, LabelRef,
26    RefCommand, RefId,
27};
28pub use math::{
29    DelimiterRole, MathAtom, MathAtomInfo, MathAtoms, MathClass, NAMED_MATH_OPERATORS, math_atoms,
30    math_char_info, math_command_info,
31};
32pub use mode::{Mode, ModeIndex, argument_domain};
33pub use outline::{LabelContext, OutlineItem, OutlineSymbol, label_context, outline};
34pub use pkgmeta::{NeedsFormatDecl, OptionDecl, ProvidesDecl, ProvidesKind};
35pub use signature::{
36    ArgKind, ArgSpec, ArgumentDomain, CommandSig, ContentKind, EnvironmentSig, SignatureDb,
37    Signatures, match_arg_slot, match_arg_slot_index, match_verbatim_arg_slot,
38};
39
40use crate::syntax::SyntaxNode;
41
42/// A file's label definitions and reference uses.
43///
44/// `Eq` is load-bearing: the `semantic_model` salsa query is **not** `no_eq`
45/// (unlike `parsed_document`), so an edit leaving this model unchanged backdates
46/// and downstream queries are not re-run.
47#[derive(Debug, Default, PartialEq, Eq)]
48pub struct SemanticModel {
49    pub(crate) labels: Vec<LabelDef>,
50    pub(crate) refs: Vec<LabelRef>,
51    pub(crate) citations: Vec<CitationRef>,
52    /// Glossary/acronym key definitions (`\newglossaryentry`, `\newacronym`, …).
53    pub(crate) glossary_defs: Vec<GlossaryDef>,
54    /// Color-name definitions (`\definecolor`, `\providecolor`, `\colorlet`),
55    /// offered by color-name completion alongside the built-in list.
56    pub(crate) color_defs: Vec<ColorDef>,
57    /// Whether the file contains a `\nocite{*}` wildcard, which pulls every entry
58    /// of the bibliography into the document — so `undefined-citation` cannot flag
59    /// anything in its namespace.
60    pub(crate) nocite_all: bool,
61    /// The file's own `\ProvidesPackage`/`\ProvidesClass`/`\ProvidesFile` (or expl3
62    /// variant) self-identification, if any (first wins). Recognized, never executed.
63    pub(crate) provides: Option<ProvidesDecl>,
64    /// The file's `\NeedsTeXFormat{format}[date]` declaration, if any (first wins).
65    pub(crate) needs_format: Option<NeedsFormatDecl>,
66    /// The file's `\DeclareOption` declarations (including the starred default handler).
67    pub(crate) options: Vec<OptionDecl>,
68}
69
70impl SemanticModel {
71    /// Build the model from a parse tree root.
72    pub fn build(root: &SyntaxNode) -> Self {
73        builder::build(root)
74    }
75
76    pub fn labels(&self) -> &[LabelDef] {
77        &self.labels
78    }
79
80    pub fn label(&self, id: LabelId) -> &LabelDef {
81        &self.labels[id.0 as usize]
82    }
83
84    pub fn refs(&self) -> &[LabelRef] {
85        &self.refs
86    }
87
88    /// The citation uses (`\cite`/`\parencite`/… keys) in this file.
89    pub fn citations(&self) -> &[CitationRef] {
90        &self.citations
91    }
92
93    /// The glossary/acronym key definitions (`\newglossaryentry`/`\newacronym`/…)
94    /// in this file.
95    pub fn glossary_defs(&self) -> &[GlossaryDef] {
96        &self.glossary_defs
97    }
98
99    /// The color-name definitions (`\definecolor`/`\providecolor`/`\colorlet`) in
100    /// this file, offered by color-name completion.
101    pub fn color_defs(&self) -> &[ColorDef] {
102        &self.color_defs
103    }
104
105    /// Whether the file contains a `\nocite{*}` wildcard.
106    pub fn has_wildcard_nocite(&self) -> bool {
107        self.nocite_all
108    }
109
110    /// The file's `\ProvidesPackage`/`\ProvidesClass`/`\ProvidesFile` self-identification.
111    pub fn provides(&self) -> Option<&ProvidesDecl> {
112        self.provides.as_ref()
113    }
114
115    /// The file's `\NeedsTeXFormat` declaration.
116    pub fn needs_format(&self) -> Option<&NeedsFormatDecl> {
117        self.needs_format.as_ref()
118    }
119
120    /// The file's `\DeclareOption` declarations.
121    pub fn options(&self) -> &[OptionDecl] {
122        &self.options
123    }
124
125    pub fn reference(&self, id: RefId) -> &LabelRef {
126        &self.refs[id.0 as usize]
127    }
128
129    /// Label definitions never referenced within *this* file.
130    ///
131    /// A per-file fact, **not** a lint signal: a label referenced only from
132    /// another file looks unreferenced here. The cross-file `unreferenced-label`
133    /// lint instead builds on the project-level
134    /// `project::resolved_labels` (as `undefined-ref` does for refs),
135    /// firing only in a closed, rooted namespace so it never false-positives on
136    /// labels referenced from outside the analyzed set.
137    pub fn unreferenced_labels(&self) -> impl Iterator<Item = LabelId> + '_ {
138        (0..self.labels.len())
139            .map(LabelId::from_index)
140            .filter(move |id| !self.label(*id).referenced)
141    }
142
143    /// References whose key matches no `\label` in *this* file.
144    ///
145    /// A per-file fact, **not** a lint signal: the key may be defined in an
146    /// included file. The `undefined-ref` lint instead consults the cross-file
147    /// `project::resolved_labels`, firing only in a closed, rooted
148    /// document namespace.
149    pub fn unresolved_refs(&self) -> impl Iterator<Item = RefId> + '_ {
150        (0..self.refs.len())
151            .map(RefId::from_index)
152            .filter(move |id| !self.reference(*id).resolved)
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use crate::parser::parse;
160
161    fn model_of(src: &str) -> SemanticModel {
162        SemanticModel::build(&SyntaxNode::new_root(parse(src).green))
163    }
164
165    #[test]
166    fn label_creates_def() {
167        let model = model_of("\\label{sec:intro}\n");
168        assert_eq!(model.labels().len(), 1);
169        assert_eq!(model.labels()[0].name, "sec:intro");
170        assert!(!model.labels()[0].referenced);
171    }
172
173    #[test]
174    fn ref_creates_use() {
175        let model = model_of("\\ref{sec:intro}\n");
176        assert_eq!(model.refs().len(), 1);
177        assert_eq!(model.refs()[0].name, "sec:intro");
178        assert_eq!(model.refs()[0].command, RefCommand::Ref);
179        assert!(!model.refs()[0].resolved);
180    }
181
182    #[test]
183    fn label_and_ref_resolve() {
184        let model = model_of("\\label{a}\\ref{a}\n");
185        assert!(model.labels()[0].referenced);
186        assert!(model.refs()[0].resolved);
187        assert_eq!(model.unreferenced_labels().count(), 0);
188        assert_eq!(model.unresolved_refs().count(), 0);
189    }
190
191    #[test]
192    fn ref_family_recognized() {
193        let model = model_of(
194            "\\pageref{x}\\eqref{x}\\autoref{x}\\nameref{x}\\Cref{x}\\vref{x}\\Vref{x}\\cpageref{x}\n",
195        );
196        let kinds: Vec<_> = model.refs().iter().map(|r| r.command).collect();
197        assert_eq!(
198            kinds,
199            vec![
200                RefCommand::PageRef,
201                RefCommand::EqRef,
202                RefCommand::AutoRef,
203                RefCommand::NameRef,
204                RefCommand::CrefUpper,
205                RefCommand::Vref,
206                RefCommand::VrefUpper,
207                RefCommand::CpageRef,
208            ]
209        );
210    }
211
212    #[test]
213    fn non_ref_commands_ignored() {
214        let model = model_of("\\textbf{x}\\section{Hi}\\emph{y}\n");
215        assert_eq!(model.labels().len(), 0);
216        assert_eq!(model.refs().len(), 0);
217    }
218
219    #[test]
220    fn cref_splits_comma_list() {
221        let model = model_of("\\cref{a,b,c}\n");
222        let names: Vec<_> = model.refs().iter().map(|r| r.name.as_str()).collect();
223        assert_eq!(names, vec!["a", "b", "c"]);
224        assert!(model.refs().iter().all(|r| r.command == RefCommand::Cref));
225        // All split keys share the single command range.
226        let range = model.refs()[0].range;
227        assert!(model.refs().iter().all(|r| r.range == range));
228    }
229
230    #[test]
231    fn plain_ref_does_not_split() {
232        let model = model_of("\\ref{a,b}\n");
233        assert_eq!(model.refs().len(), 1);
234        assert_eq!(model.refs()[0].name, "a,b");
235    }
236
237    #[test]
238    fn cref_empty_and_blank_keys_dropped() {
239        assert_eq!(model_of("\\cref{}\n").refs().len(), 0);
240        let model = model_of("\\cref{a,,b}\n");
241        let names: Vec<_> = model.refs().iter().map(|r| r.name.as_str()).collect();
242        assert_eq!(names, vec!["a", "b"]);
243    }
244
245    #[test]
246    fn unresolved_ref_when_no_label() {
247        let model = model_of("\\ref{missing}\n");
248        assert!(!model.refs()[0].resolved);
249        assert_eq!(model.unresolved_refs().count(), 1);
250    }
251
252    #[test]
253    fn unreferenced_label_reported() {
254        let model = model_of("\\label{x}\n");
255        assert_eq!(model.unreferenced_labels().count(), 1);
256    }
257
258    #[test]
259    fn duplicate_labels_preserved() {
260        let model = model_of("\\label{x}\\label{x}\\ref{x}\n");
261        assert_eq!(model.labels().len(), 2);
262        assert!(model.labels().iter().all(|l| l.referenced));
263        assert!(model.refs()[0].resolved);
264    }
265
266    #[test]
267    fn nested_macro_key_skipped() {
268        let model = model_of("\\label{\\foo}\n");
269        assert_eq!(model.labels().len(), 0);
270    }
271
272    #[test]
273    fn label_collected_inside_environment() {
274        let model = model_of("\\begin{figure}\n\\label{fig:one}\n\\end{figure}\n");
275        assert_eq!(model.labels().len(), 1);
276        assert_eq!(model.labels()[0].name, "fig:one");
277    }
278}