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 completion;
11pub mod define;
12pub mod doc;
13pub mod expl3;
14pub mod label;
15pub mod math;
16pub mod mode;
17pub mod outline;
18pub mod pkgmeta;
19pub mod signature;
20pub mod tikz;
21pub mod xparse;
22
23pub use define::{DefSite, DefSiteKind, scan_definition_sites, scan_definitions};
24pub use doc::{DocAssociation, DocKind, doc_associations};
25pub use label::{
26    CitationRef, ColorDef, ColorDefKind, GlossaryDef, GlossaryDefKind, LabelDef, LabelId, LabelRef,
27    RefCommand, RefId,
28};
29pub use math::{
30    DelimiterRole, MathAtom, MathAtomInfo, MathAtoms, MathClass, NAMED_MATH_OPERATORS, math_atoms,
31    math_char_info, math_command_info,
32};
33pub use mode::{Mode, ModeIndex, argument_domain};
34pub use outline::{LabelContext, OutlineItem, OutlineSymbol, label_context, outline};
35pub use pkgmeta::{NeedsFormatDecl, OptionDecl, ProvidesDecl, ProvidesKind};
36pub use signature::{
37    ArgKind, ArgSpec, ArgumentDomain, CommandSig, ContentKind, EnvironmentSig, SignatureDb,
38    Signatures, match_arg_slot, match_arg_slot_index, match_verbatim_arg_slot,
39};
40
41use std::collections::BTreeSet;
42
43use smol_str::SmolStr;
44
45use crate::declarations::ResolvedDeclarations;
46use crate::syntax::SyntaxNode;
47
48/// A file's label definitions and reference uses.
49///
50/// `Eq` is load-bearing: the `semantic_model` salsa query is **not** `no_eq`
51/// (unlike `parsed_document`), so an edit leaving this model unchanged backdates
52/// and downstream queries are not re-run.
53#[derive(Debug, Default, PartialEq, Eq)]
54pub struct SemanticModel {
55    pub(crate) labels: Vec<LabelDef>,
56    pub(crate) refs: Vec<LabelRef>,
57    pub(crate) citations: Vec<CitationRef>,
58    /// Glossary/acronym key definitions (`\newglossaryentry`, `\newacronym`, …).
59    pub(crate) glossary_defs: Vec<GlossaryDef>,
60    /// Color-name definitions (`\definecolor`, `\providecolor`, `\colorlet`),
61    /// offered by color-name completion alongside the built-in list.
62    pub(crate) color_defs: Vec<ColorDef>,
63    /// Whether the file contains a `\nocite{*}` wildcard, which pulls every entry
64    /// of the bibliography into the document — so `undefined-citation` cannot flag
65    /// anything in its namespace.
66    pub(crate) nocite_all: bool,
67    /// The file's own `\ProvidesPackage`/`\ProvidesClass`/`\ProvidesFile` (or expl3
68    /// variant) self-identification, if any (first wins). Recognized, never executed.
69    pub(crate) provides: Option<ProvidesDecl>,
70    /// The file's `\NeedsTeXFormat{format}[date]` declaration, if any (first wins).
71    pub(crate) needs_format: Option<NeedsFormatDecl>,
72    /// The file's `\DeclareOption` declarations (including the starred default handler).
73    pub(crate) options: Vec<OptionDecl>,
74    /// The project-declared command aliases *used in this file* whose target is a
75    /// key-argument command ([`builder::key_argument_command`]). Recorded by name
76    /// so a name-based gate can treat `\myref` exactly as it treats the `\cref` it
77    /// was declared `like`, and holding only the names the file actually uses so an
78    /// unrelated declaration edit still backdates this model.
79    pub(crate) declared_key_commands: BTreeSet<SmolStr>,
80}
81
82impl SemanticModel {
83    /// Build the model from a parse tree root.
84    pub fn build(root: &SyntaxNode) -> Self {
85        builder::build(root)
86    }
87
88    /// Build the model under a project's declared ref/cite command aliases.
89    pub fn build_with_declarations(root: &SyntaxNode, declared: &ResolvedDeclarations) -> Self {
90        builder::build_with_declarations(root, declared)
91    }
92
93    pub fn labels(&self) -> &[LabelDef] {
94        &self.labels
95    }
96
97    pub fn label(&self, id: LabelId) -> &LabelDef {
98        &self.labels[id.0 as usize]
99    }
100
101    pub fn refs(&self) -> &[LabelRef] {
102        &self.refs
103    }
104
105    /// The citation uses (`\cite`/`\parencite`/… keys) in this file.
106    pub fn citations(&self) -> &[CitationRef] {
107        &self.citations
108    }
109
110    /// Whether `name` is a command whose arguments hold opaque keys rather than
111    /// typeset text — a curated one ([`builder::key_argument_command`]) or a
112    /// project-declared alias of one.
113    ///
114    /// Answered by *name*, not by the ranges collected into [`refs`](Self::refs)
115    /// and [`citations`](Self::citations): a command whose key cannot be extracted
116    /// (`\myref{\textbf{a}}` yields no `LabelRef`) still has a key argument, and a
117    /// declared alias must not be gated more weakly than the built-in it copies.
118    pub fn is_key_argument_command(&self, name: &str) -> bool {
119        builder::key_argument_command(name) || self.declared_key_commands.contains(name)
120    }
121
122    /// The glossary/acronym key definitions (`\newglossaryentry`/`\newacronym`/…)
123    /// in this file.
124    pub fn glossary_defs(&self) -> &[GlossaryDef] {
125        &self.glossary_defs
126    }
127
128    /// The color-name definitions (`\definecolor`/`\providecolor`/`\colorlet`) in
129    /// this file, offered by color-name completion.
130    pub fn color_defs(&self) -> &[ColorDef] {
131        &self.color_defs
132    }
133
134    /// Whether the file contains a `\nocite{*}` wildcard.
135    pub fn has_wildcard_nocite(&self) -> bool {
136        self.nocite_all
137    }
138
139    /// The file's `\ProvidesPackage`/`\ProvidesClass`/`\ProvidesFile` self-identification.
140    pub fn provides(&self) -> Option<&ProvidesDecl> {
141        self.provides.as_ref()
142    }
143
144    /// The file's `\NeedsTeXFormat` declaration.
145    pub fn needs_format(&self) -> Option<&NeedsFormatDecl> {
146        self.needs_format.as_ref()
147    }
148
149    /// The file's `\DeclareOption` declarations.
150    pub fn options(&self) -> &[OptionDecl] {
151        &self.options
152    }
153
154    pub fn reference(&self, id: RefId) -> &LabelRef {
155        &self.refs[id.0 as usize]
156    }
157
158    /// Label definitions never referenced within *this* file.
159    ///
160    /// A per-file fact, **not** a lint signal: a label referenced only from
161    /// another file looks unreferenced here. The cross-file `unreferenced-label`
162    /// lint instead builds on the project-level
163    /// `project::resolved_labels` (as `undefined-ref` does for refs),
164    /// firing only in a closed, rooted namespace so it never false-positives on
165    /// labels referenced from outside the analyzed set.
166    pub fn unreferenced_labels(&self) -> impl Iterator<Item = LabelId> + '_ {
167        (0..self.labels.len())
168            .map(LabelId::from_index)
169            .filter(move |id| !self.label(*id).referenced)
170    }
171
172    /// References whose key matches no `\label` in *this* file.
173    ///
174    /// A per-file fact, **not** a lint signal: the key may be defined in an
175    /// included file. The `undefined-ref` lint instead consults the cross-file
176    /// `project::resolved_labels`, firing only in a closed, rooted
177    /// document namespace.
178    pub fn unresolved_refs(&self) -> impl Iterator<Item = RefId> + '_ {
179        (0..self.refs.len())
180            .map(RefId::from_index)
181            .filter(move |id| !self.reference(*id).resolved)
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use crate::declarations::Declarations;
189    use crate::parser::parse;
190
191    fn model_of(src: &str) -> SemanticModel {
192        SemanticModel::build(&SyntaxNode::new_root(parse(src).green))
193    }
194
195    fn declared_model(src: &str, json: &str) -> SemanticModel {
196        let declared = serde_json::from_str::<Declarations>(json)
197            .expect("declarations deserialize")
198            .resolve()
199            .expect("declarations resolve");
200        SemanticModel::build_with_declarations(&SyntaxNode::new_root(parse(src).green), &declared)
201    }
202
203    #[test]
204    fn label_creates_def() {
205        let model = model_of("\\label{sec:intro}\n");
206        assert_eq!(model.labels().len(), 1);
207        assert_eq!(model.labels()[0].name, "sec:intro");
208        assert!(!model.labels()[0].referenced);
209    }
210
211    #[test]
212    fn ref_creates_use() {
213        let model = model_of("\\ref{sec:intro}\n");
214        assert_eq!(model.refs().len(), 1);
215        assert_eq!(model.refs()[0].name, "sec:intro");
216        assert_eq!(model.refs()[0].command, RefCommand::Ref);
217        assert!(!model.refs()[0].resolved);
218    }
219
220    #[test]
221    fn label_and_ref_resolve() {
222        let model = model_of("\\label{a}\\ref{a}\n");
223        assert!(model.labels()[0].referenced);
224        assert!(model.refs()[0].resolved);
225        assert_eq!(model.unreferenced_labels().count(), 0);
226        assert_eq!(model.unresolved_refs().count(), 0);
227    }
228
229    #[test]
230    fn ref_family_recognized() {
231        let model = model_of(
232            "\\pageref{x}\\eqref{x}\\autoref{x}\\nameref{x}\\Cref{x}\\vref{x}\\Vref{x}\\cpageref{x}\n",
233        );
234        let kinds: Vec<_> = model.refs().iter().map(|r| r.command).collect();
235        assert_eq!(
236            kinds,
237            vec![
238                RefCommand::PageRef,
239                RefCommand::EqRef,
240                RefCommand::AutoRef,
241                RefCommand::NameRef,
242                RefCommand::CrefUpper,
243                RefCommand::Vref,
244                RefCommand::VrefUpper,
245                RefCommand::CpageRef,
246            ]
247        );
248    }
249
250    #[test]
251    fn non_ref_commands_ignored() {
252        let model = model_of("\\textbf{x}\\section{Hi}\\emph{y}\n");
253        assert_eq!(model.labels().len(), 0);
254        assert_eq!(model.refs().len(), 0);
255    }
256
257    #[test]
258    fn cref_splits_comma_list() {
259        let model = model_of("\\cref{a,b,c}\n");
260        let names: Vec<_> = model.refs().iter().map(|r| r.name.as_str()).collect();
261        assert_eq!(names, vec!["a", "b", "c"]);
262        assert!(model.refs().iter().all(|r| r.command == RefCommand::Cref));
263        let range = model.refs()[0].range;
264        assert!(model.refs().iter().all(|r| r.range == range));
265    }
266
267    #[test]
268    fn declared_reference_inherits_target_key_cardinality() {
269        let model = declared_model(
270            "\\label{a}\\label{b}\\one{a,b}\\many{a,b}\n",
271            r#"{"commands": {
272                 "one": {"like": "eqref"},
273                 "many": {"like": "cref"}
274               }}"#,
275        );
276        let names: Vec<_> = model.refs().iter().map(|r| r.name.as_str()).collect();
277        assert_eq!(names, vec!["a,b", "a", "b"]);
278        assert_eq!(model.refs()[0].command, RefCommand::EqRef);
279        assert!(
280            model.refs()[1..]
281                .iter()
282                .all(|reference| reference.command == RefCommand::Cref)
283        );
284        assert!(model.labels().iter().all(|label| label.referenced));
285    }
286
287    #[test]
288    fn declared_environment_inherits_label_key_behavior() {
289        let model = declared_model(
290            "\\begin{mylisting}[label=custom:list]\nbody\n\\end{mylisting}\n",
291            r#"{"environments": {"mylisting": {"like": "lstlisting"}}}"#,
292        );
293        assert_eq!(model.labels().len(), 1);
294        assert_eq!(model.labels()[0].name, "custom:list");
295    }
296
297    #[test]
298    fn declared_citation_and_nocite_aliases_are_collected() {
299        let model = declared_model(
300            "\\sources{one,two}\\everything{*}\n",
301            r#"{"commands": {
302                 "sources": {"like": "parencite"},
303                 "everything": {"like": "nocite"}
304               }}"#,
305        );
306        let names: Vec<_> = model
307            .citations()
308            .iter()
309            .map(|citation| citation.name.as_str())
310            .collect();
311        assert_eq!(names, vec!["one", "two"]);
312        assert!(
313            model
314                .citations()
315                .iter()
316                .all(|citation| citation.command == "sources")
317        );
318        assert!(model.has_wildcard_nocite());
319    }
320
321    #[test]
322    fn a_declared_alias_is_a_key_argument_command_by_name() {
323        let model = declared_model(
324            "\\myref{\\textbf{a}}\\wrapper{a}\n",
325            r#"{"commands": {
326                 "myref": {"like": "cref"},
327                 "wrapper": {"like": "citeauthor"}
328               }}"#,
329        );
330        assert!(model.refs().is_empty(), "the nested-macro key is skipped");
331        assert!(model.is_key_argument_command("myref"));
332        assert!(model.is_key_argument_command("wrapper"));
333        assert!(
334            model.is_key_argument_command("cref"),
335            "built-ins still pass"
336        );
337        assert!(!model.is_key_argument_command("emph"));
338        assert!(
339            !SemanticModel::build(&SyntaxNode::new_root(parse("\\myref{a}\n").green))
340                .is_key_argument_command("myref"),
341            "undeclared, the alias is an ordinary command"
342        );
343    }
344
345    #[test]
346    fn plain_ref_does_not_split() {
347        let model = model_of("\\ref{a,b}\n");
348        assert_eq!(model.refs().len(), 1);
349        assert_eq!(model.refs()[0].name, "a,b");
350    }
351
352    #[test]
353    fn cref_empty_and_blank_keys_dropped() {
354        assert_eq!(model_of("\\cref{}\n").refs().len(), 0);
355        let model = model_of("\\cref{a,,b}\n");
356        let names: Vec<_> = model.refs().iter().map(|r| r.name.as_str()).collect();
357        assert_eq!(names, vec!["a", "b"]);
358    }
359
360    #[test]
361    fn unresolved_ref_when_no_label() {
362        let model = model_of("\\ref{missing}\n");
363        assert!(!model.refs()[0].resolved);
364        assert_eq!(model.unresolved_refs().count(), 1);
365    }
366
367    #[test]
368    fn unreferenced_label_reported() {
369        let model = model_of("\\label{x}\n");
370        assert_eq!(model.unreferenced_labels().count(), 1);
371    }
372
373    #[test]
374    fn duplicate_labels_preserved() {
375        let model = model_of("\\label{x}\\label{x}\\ref{x}\n");
376        assert_eq!(model.labels().len(), 2);
377        assert!(model.labels().iter().all(|l| l.referenced));
378        assert!(model.refs()[0].resolved);
379    }
380
381    #[test]
382    fn nested_macro_key_skipped() {
383        let model = model_of("\\label{\\foo}\n");
384        assert_eq!(model.labels().len(), 0);
385    }
386
387    #[test]
388    fn label_collected_inside_environment() {
389        let model = model_of("\\begin{figure}\n\\label{fig:one}\n\\end{figure}\n");
390        assert_eq!(model.labels().len(), 1);
391        assert_eq!(model.labels()[0].name, "fig:one");
392    }
393}