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