Skip to main content

badness_parser/semantic/
builder.rs

1//! Build the per-file label/reference model from the CST.
2//!
3//! A single whole-tree walk (mirror of `project::collect_include_edges`)
4//! collects `\label{…}` definitions and the reference-command family, then a
5//! flat `resolve` pass matches refs to defs by name. Labels live in one
6//! document-global namespace, so there is no scope walk — resolution is a flat
7//! name match, not a scope-chain resolution.
8
9use smol_str::SmolStr;
10
11use rowan::{TextRange, TextSize};
12
13use crate::ast::{command_name, first_group_range, nth_group_inner};
14use crate::semantic::SemanticModel;
15use crate::semantic::label::{
16    CitationRef, ColorDef, ColorDefKind, GlossaryDef, GlossaryDefKind, LabelDef, LabelRef,
17    RefCommand,
18};
19use crate::semantic::pkgmeta;
20use crate::syntax::{SyntaxKind, SyntaxNode};
21
22pub fn build(root: &SyntaxNode) -> SemanticModel {
23    let mut model = SemanticModel::default();
24
25    for command in root
26        .descendants()
27        .filter(|node| node.kind() == SyntaxKind::COMMAND)
28    {
29        let Some(name) = command_name(&command) else {
30            continue;
31        };
32
33        if name == "label" {
34            // A nested-macro key (`\label{\foo}`) yields `None`; skip it,
35            // conservative like an unresolvable include target. A label key is the
36            // whole inner content (not comma-split): `split = false`.
37            if let Some((inner_range, inner)) = nth_group_inner(&command, 0) {
38                for (key, key_range) in key_spans(&inner, inner_range, false) {
39                    model.labels.push(LabelDef {
40                        name: SmolStr::from(key),
41                        range: first_group_range(&command),
42                        key_range,
43                        referenced: false,
44                    });
45                }
46            }
47        } else if let Some(kind) = ref_command(&name)
48            && let Some((inner_range, inner)) = nth_group_inner(&command, 0)
49        {
50            for (key, key_range) in key_spans(&inner, inner_range, kind.is_key_list()) {
51                model.refs.push(LabelRef {
52                    name: SmolStr::from(key),
53                    command: kind,
54                    range: command.text_range(),
55                    key_range,
56                    resolved: false,
57                });
58            }
59        } else if let Some(kind) = glossary_definer(&name) {
60            // Like `\label`: the key is the whole first group (never comma-split),
61            // and a nested-macro key (`\newacronym{\foo}…`) is skipped. The
62            // optional `[opts]` of `\newacronym` is an OPTIONAL node, not a GROUP,
63            // so it never shifts the key's group index.
64            if let Some((inner_range, inner)) = nth_group_inner(&command, 0) {
65                for (key, key_range) in key_spans(&inner, inner_range, false) {
66                    model.glossary_defs.push(GlossaryDef {
67                        key: SmolStr::from(key),
68                        kind,
69                        range: first_group_range(&command),
70                        key_range,
71                    });
72                }
73            }
74        } else if let Some(kind) = color_definer(&name) {
75            // The defined color name is the first `{…}` group for all three
76            // definers (`\definecolor{name}…`, `\colorlet{name}{base}`), never
77            // comma-split, and a nested-macro name is skipped like `\label{\foo}`.
78            if let Some((inner_range, inner)) = nth_group_inner(&command, 0) {
79                for (key, key_range) in key_spans(&inner, inner_range, false) {
80                    model.color_defs.push(ColorDef {
81                        name: SmolStr::from(key),
82                        kind,
83                        range: first_group_range(&command),
84                        key_range,
85                    });
86                }
87            }
88        } else if is_cite_command(&name)
89            && let Some((inner_range, inner)) = nth_group_inner(&command, 0)
90        {
91            // `\nocite{*}` is a wildcard pulling in every entry — recorded as a flag,
92            // not a key, so it suppresses `undefined-citation` rather than being one.
93            if name == "nocite" && inner.trim() == "*" {
94                model.nocite_all = true;
95            } else {
96                // Cite commands always take a comma-separated key list.
97                for (key, key_range) in key_spans(&inner, inner_range, true) {
98                    model.citations.push(CitationRef {
99                        name: SmolStr::from(key),
100                        command: SmolStr::from(name.as_str()),
101                        range: command.text_range(),
102                        key_range,
103                    });
104                }
105            }
106        } else if pkgmeta::provides_kind(&name).is_some() {
107            // Package/class self-identification — first `\Provides…` wins (a file
108            // identifies itself once).
109            if model.provides.is_none()
110                && let Some(decl) = pkgmeta::provides_from_command(&command)
111            {
112                model.provides = Some(decl);
113            }
114        } else if name == "NeedsTeXFormat" {
115            if model.needs_format.is_none()
116                && let Some(decl) = pkgmeta::needs_format_from_command(&command)
117            {
118                model.needs_format = Some(decl);
119            }
120        } else if name == "DeclareOption"
121            && let Some(decl) = pkgmeta::option_from_command(&command)
122        {
123            model.options.push(decl);
124        }
125    }
126
127    resolve(&mut model);
128    model
129}
130
131/// Whether `name` is a citation command (`\cite` and the natbib/biblatex family,
132/// plus `\nocite`). Capitalized biblatex variants (`\Cite`, `\Textcite`, …) and
133/// the `cite`-prefixed natbib set are covered by the prefix check; an explicit
134/// short list catches the rest. Keys are comma-separated for all of them.
135pub fn is_cite_command(name: &str) -> bool {
136    const EXTRA: &[&str] = &[
137        "parencite",
138        "Parencite",
139        "footcite",
140        "footcitetext",
141        "textcite",
142        "Textcite",
143        "smartcite",
144        "Smartcite",
145        "autocite",
146        "Autocite",
147        "supercite",
148        "fullcite",
149        "footfullcite",
150        "nocite",
151        "notecite",
152        "Notecite",
153        "pnotecite",
154        "fnotecite",
155    ];
156    // The `\cite` family: `\cite`, `\citep`, `\citet`, `\citeauthor`,
157    // `\citeyear`, `\Citep`, … all begin with `cite`/`Cite`.
158    name.starts_with("cite") || name.starts_with("Cite") || EXTRA.contains(&name)
159}
160
161/// The recognized reference command for a control-word name, or `None`. A small
162/// explicit table — the analog of `project::include::include_kind`. Shared with
163/// the completion classifier (`crate::completion`) so the ref-family name set has
164/// a single source of truth.
165pub fn ref_command(name: &str) -> Option<RefCommand> {
166    Some(match name {
167        "ref" => RefCommand::Ref,
168        "pageref" => RefCommand::PageRef,
169        "eqref" => RefCommand::EqRef,
170        "autoref" => RefCommand::AutoRef,
171        "nameref" => RefCommand::NameRef,
172        "cref" => RefCommand::Cref,
173        "Cref" => RefCommand::CrefUpper,
174        "vref" => RefCommand::Vref,
175        "Vref" => RefCommand::VrefUpper,
176        "cpageref" => RefCommand::CpageRef,
177        _ => return None,
178    })
179}
180
181/// The recognized glossary/acronym *definer* command for a control-word name, or
182/// `None`. The definition-side analog of [`ref_command`]; the key is always the
183/// first `{…}` group.
184pub(crate) fn glossary_definer(name: &str) -> Option<GlossaryDefKind> {
185    Some(match name {
186        "newglossaryentry"
187        | "longnewglossaryentry"
188        | "provideglossaryentry"
189        | "longprovideglossaryentry" => GlossaryDefKind::Entry,
190        "newacronym" => GlossaryDefKind::Acronym,
191        "newabbreviation" => GlossaryDefKind::Abbreviation,
192        _ => return None,
193    })
194}
195
196/// The recognized color *definer* command for a control-word name, or `None`.
197/// The definition-side analog of [`glossary_definer`]: the newly defined color
198/// name is always the first `{…}` group (`\definecolor{name}{model}{spec}`,
199/// `\colorlet{name}{base}`).
200pub(crate) fn color_definer(name: &str) -> Option<ColorDefKind> {
201    Some(match name {
202        "definecolor" => ColorDefKind::DefineColor,
203        "providecolor" => ColorDefKind::ProvideColor,
204        "colorlet" => ColorDefKind::Colorlet,
205        _ => return None,
206    })
207}
208
209/// Whether `name` is a glossary/acronym *reference* command whose first `{…}`
210/// group is an entry key (`\gls`, `\acrshort`, `\glsxtrfull`, …). Shared with the
211/// completion classifier (`crate::completion`), like [`ref_command`] and
212/// [`is_cite_command`], so the name set has a single source of truth. Unlike
213/// citations, every command here takes exactly **one** key per group (no comma
214/// list).
215pub fn is_glossary_ref_command(name: &str) -> bool {
216    // The `\gls` core set: base name + first-letter-uppercase + all-caps
217    // sentence-start variants, each with an optional plural `pl`.
218    const GLS: &[&str] = &[
219        "gls",
220        "Gls",
221        "GLS",
222        "glspl",
223        "Glspl",
224        "GLSpl",
225        // Text-form accessors (`\glstext{key}` prints the entry text without
226        // triggering first-use).
227        "glstext",
228        "Glstext",
229        "glsfirst",
230        "Glsfirst",
231        "glsplural",
232        "Glsplural",
233        "glsfirstplural",
234        "Glsfirstplural",
235        "glsdesc",
236        "Glsdesc",
237        "glsname",
238        "Glsname",
239        "glssymbol",
240        "Glssymbol",
241        // Key-first commands with further groups (`\glslink{key}{text}`).
242        "glslink",
243        "glsdisp",
244        "glsadd",
245        // glossaries-extra short/long/full accessors.
246        "glsxtrshort",
247        "Glsxtrshort",
248        "glsxtrlong",
249        "Glsxtrlong",
250        "glsxtrfull",
251        "Glsxtrfull",
252    ];
253    if GLS.contains(&name) {
254        return true;
255    }
256    // The acronym set: `\acrshort`/`\acrlong`/`\acrfull`, plural `pl`, in
257    // `acr`/`Acr`/`ACR` casing — a stem check like `is_cite_command`'s
258    // `cite`/`Cite` prefix trick.
259    for stem in ["acr", "Acr", "ACR"] {
260        if let Some(rest) = name.strip_prefix(stem) {
261            return matches!(
262                rest,
263                "short" | "shortpl" | "long" | "longpl" | "full" | "fullpl"
264            );
265        }
266    }
267    false
268}
269
270/// Whether `name` is a command whose arguments hold opaque keys, identifiers, or
271/// text rather than typeset math — the `\label`/`\ref`/`\cite`/`\gls`/color
272/// families plus `\tag` (amsmath, text content) and `\hyperref` (key plus link
273/// text). The union of the family predicates above, kept here so the name sets
274/// stay single-sourced; shared with the linter's key-argument gate
275/// (`crate::linter::rules::in_key_argument`), which uses it to keep identifier
276/// keys like `\label{eq:thing_max}` out of the math-shape rules' scope.
277pub fn key_argument_command(name: &str) -> bool {
278    matches!(name, "label" | "tag" | "hyperref")
279        || ref_command(name).is_some()
280        || is_cite_command(name)
281        || is_glossary_ref_command(name)
282        || glossary_definer(name).is_some()
283        || color_definer(name).is_some()
284}
285
286/// Split a group's inner text into keys paired with their precise byte ranges in
287/// the source. When `split` (key-list commands, citations), keys are comma-
288/// separated; otherwise the whole inner is one key (`\label`, single-key refs).
289/// Surrounding whitespace is trimmed (TeX ignores it around these keys) and empty
290/// keys are dropped. `inner_range` is the byte span of `inner`, so each key's range
291/// is sliced off it by offset — exact because trimming removes only single-byte
292/// ASCII whitespace.
293fn key_spans(inner: &str, inner_range: TextRange, split: bool) -> Vec<(&str, TextRange)> {
294    let base = inner_range.start();
295    let mut out = Vec::new();
296    if split {
297        // Track each comma-segment's byte offset within `inner` (the segment text
298        // plus one byte for the comma that followed it).
299        let mut seg_off = 0usize;
300        for segment in inner.split(',') {
301            if let Some((key, lo, hi)) = trimmed_span(segment) {
302                out.push((key, key_range(base, seg_off + lo, seg_off + hi)));
303            }
304            seg_off += segment.len() + 1;
305        }
306    } else if let Some((key, lo, hi)) = trimmed_span(inner) {
307        out.push((key, key_range(base, lo, hi)));
308    }
309    out
310}
311
312/// The trimmed key of `segment` with its start/end byte offsets *within* `segment`,
313/// or `None` when the segment is empty after trimming.
314fn trimmed_span(segment: &str) -> Option<(&str, usize, usize)> {
315    let key = segment.trim();
316    if key.is_empty() {
317        return None;
318    }
319    let lo = segment.len() - segment.trim_start().len();
320    Some((key, lo, lo + key.len()))
321}
322
323/// Build a [`TextRange`] from `base` plus byte offsets `lo`/`hi`.
324fn key_range(base: TextSize, lo: usize, hi: usize) -> TextRange {
325    TextRange::new(
326        base + TextSize::from(lo as u32),
327        base + TextSize::from(hi as u32),
328    )
329}
330
331/// Flat name-match resolution: mark each ref `resolved` when a same-named label
332/// exists, and each such label `referenced`.
333fn resolve(model: &mut SemanticModel) {
334    for ref_idx in 0..model.refs.len() {
335        let name = model.refs[ref_idx].name.clone();
336        let mut hit = false;
337        for label in &mut model.labels {
338            if label.name == name {
339                label.referenced = true;
340                hit = true;
341            }
342        }
343        model.refs[ref_idx].resolved = hit;
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use crate::parser::parse;
350    use crate::syntax::SyntaxNode;
351
352    use super::build;
353
354    fn model(src: &str) -> crate::semantic::SemanticModel {
355        build(&SyntaxNode::new_root(parse(src).green))
356    }
357
358    #[test]
359    fn label_key_range_excludes_command_and_braces() {
360        let src = "\\label{ sec:intro }\n";
361        let model = model(src);
362        let def = &model.labels()[0];
363        assert_eq!(def.name, "sec:intro");
364        // The key range covers only the trimmed key, not the braces or padding.
365        assert_eq!(&src[def.key_range], "sec:intro");
366    }
367
368    #[test]
369    fn cref_list_keys_get_isolated_ranges() {
370        let src = "\\cref{a,b,c}\n";
371        let model = model(src);
372        let keys: Vec<_> = model
373            .refs()
374            .iter()
375            .map(|r| (r.name.as_str(), &src[r.key_range]))
376            .collect();
377        assert_eq!(
378            keys,
379            vec![("a", "a"), ("b", "b"), ("c", "c")],
380            "each key in a list command isolates its own span"
381        );
382    }
383
384    #[test]
385    fn newglossaryentry_key_scanned_with_range() {
386        let src = "\\newglossaryentry{ex}{name={example},description={an example}}\n";
387        let model = model(src);
388        let def = &model.glossary_defs()[0];
389        assert_eq!(def.key, "ex");
390        assert_eq!(def.kind, crate::semantic::label::GlossaryDefKind::Entry);
391        assert_eq!(&src[def.key_range], "ex");
392    }
393
394    #[test]
395    fn newacronym_optional_arg_does_not_shift_key() {
396        let src = "\\newacronym[longplural={frames}]{fps}{FPS}{frame rate}\n";
397        let model = model(src);
398        let def = &model.glossary_defs()[0];
399        assert_eq!(def.key, "fps");
400        assert_eq!(def.kind, crate::semantic::label::GlossaryDefKind::Acronym);
401        assert_eq!(&src[def.key_range], "fps");
402    }
403
404    #[test]
405    fn glossary_definer_family_scanned() {
406        let src = "\\longnewglossaryentry{a}{name={a}}{desc}\n\\newabbreviation{b}{B}{bee}\n\\provideglossaryentry{c}{name={c}}\n";
407        let model = model(src);
408        let keys: Vec<_> = model
409            .glossary_defs()
410            .iter()
411            .map(|d| d.key.as_str())
412            .collect();
413        assert_eq!(keys, vec!["a", "b", "c"]);
414    }
415
416    #[test]
417    fn glossary_nested_macro_key_skipped() {
418        // Like `\label{\foo}`: an unresolvable key is skipped, never guessed.
419        let model = model("\\newacronym{\\foo}{F}{foo}\n");
420        assert!(model.glossary_defs().is_empty());
421    }
422
423    #[test]
424    fn gls_use_is_not_a_definition() {
425        let model = model("\\gls{ex}\\acrshort{fps}\n");
426        assert!(model.glossary_defs().is_empty());
427    }
428
429    #[test]
430    fn color_definers_scanned_with_ranges() {
431        let src = "\\definecolor{brandblue}{HTML}{0055AA}\n\\colorlet{accent}{brandblue}\n\\providecolor{muted}{gray}{0.5}\n";
432        let model = model(src);
433        let defs: Vec<_> = model
434            .color_defs()
435            .iter()
436            .map(|d| (d.name.as_str(), d.kind, &src[d.key_range]))
437            .collect();
438        use crate::semantic::label::ColorDefKind::*;
439        assert_eq!(
440            defs,
441            vec![
442                ("brandblue", DefineColor, "brandblue"),
443                ("accent", Colorlet, "accent"),
444                ("muted", ProvideColor, "muted"),
445            ]
446        );
447    }
448
449    #[test]
450    fn textcolor_use_is_not_a_color_definition() {
451        let model = model("\\textcolor{red}{x}\\color{blue}\n");
452        assert!(model.color_defs().is_empty());
453    }
454
455    #[test]
456    fn cite_list_keys_get_isolated_ranges() {
457        let src = "\\cite{ foo , bar }\n";
458        let model = model(src);
459        let keys: Vec<_> = model
460            .citations()
461            .iter()
462            .map(|c| (c.name.as_str(), &src[c.key_range]))
463            .collect();
464        assert_eq!(keys, vec![("foo", "foo"), ("bar", "bar")]);
465    }
466}