Skip to main content

badness_parser/
semantic.rs

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