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