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, literal `label` options from curated
5//! environments, and the reference-command family, then a flat `resolve` pass
6//! matches refs to defs by name. Labels live in one document-global namespace,
7//! so there is no scope walk—resolution is a flat name match, not a scope-chain
8//! resolution.
9
10use std::collections::HashSet;
11
12use smol_str::SmolStr;
13
14use rowan::{NodeOrToken, TextRange, TextSize};
15
16use crate::ast::{
17    AstNode, Group, Optional, child, command_name, first_group_range, nth_group_inner,
18};
19use crate::declarations::ResolvedDeclarations;
20use crate::semantic::label::{
21    CitationRef, ColorDef, ColorDefKind, GlossaryDef, GlossaryDefKind, LabelDef, LabelRef,
22    RefCommand,
23};
24use crate::semantic::pkgmeta;
25use crate::semantic::{SemanticModel, Signatures};
26use crate::syntax::{SyntaxKind, SyntaxNode};
27
28pub fn build(root: &SyntaxNode) -> SemanticModel {
29    build_with_declarations(root, &ResolvedDeclarations::default())
30}
31
32/// Build the semantic model while honoring project-declared ref/cite aliases.
33pub fn build_with_declarations(
34    root: &SyntaxNode,
35    declared: &ResolvedDeclarations,
36) -> SemanticModel {
37    let mut model = SemanticModel::default();
38    let signatures = Signatures::new(declared.as_db());
39
40    for node in root.descendants() {
41        if node.kind() == SyntaxKind::BEGIN {
42            if let Some(sig) = signatures.environment_at(&node)
43                && sig.label_key
44                && let Some(optional) = child::<Optional>(&node)
45                && let Some(label) = option_label(&optional)
46            {
47                model.labels.push(label);
48            }
49            continue;
50        }
51        if node.kind() != SyntaxKind::COMMAND {
52            continue;
53        }
54        let command = node;
55        let Some(name) = command_name(&command) else {
56            continue;
57        };
58        let semantic_name = declared.command_like(&name).unwrap_or(&name);
59        // Recorded before the key-collecting arms below, and independently of
60        // them: an alias whose first group holds no extractable key still takes a
61        // key argument, and the name-based gates must see it.
62        if let Some(target) = declared.command_like(&name)
63            && key_argument_command(target)
64        {
65            model
66                .declared_key_commands
67                .insert(SmolStr::new(name.as_str()));
68        }
69
70        if collect_key_command(&mut model, &command, &name, semantic_name) {
71            continue;
72        } else if pkgmeta::provides_kind(&name).is_some() {
73            // Package/class self-identification — first `\Provides…` wins (a file
74            // identifies itself once).
75            if model.provides.is_none()
76                && let Some(decl) = pkgmeta::provides_from_command(&command)
77            {
78                model.provides = Some(decl);
79            }
80        } else if name == "NeedsTeXFormat" {
81            if model.needs_format.is_none()
82                && let Some(decl) = pkgmeta::needs_format_from_command(&command)
83            {
84                model.needs_format = Some(decl);
85            }
86        } else if name == "DeclareOption"
87            && let Some(decl) = pkgmeta::option_from_command(&command)
88        {
89            model.options.push(decl);
90        }
91    }
92
93    resolve(&mut model);
94    model
95}
96
97#[derive(Clone, Copy)]
98enum KeyCommand {
99    Label,
100    Reference(RefCommand),
101    Glossary(GlossaryDefKind),
102    Color(ColorDefKind),
103    Citation(CiteCommand),
104}
105
106fn key_command(name: &str, semantic_name: &str) -> Option<KeyCommand> {
107    if name == "label" {
108        Some(KeyCommand::Label)
109    } else if let Some(kind) = ref_command(semantic_name) {
110        Some(KeyCommand::Reference(kind))
111    } else if let Some(kind) = glossary_definer(name) {
112        Some(KeyCommand::Glossary(kind))
113    } else if let Some(kind) = color_definer(name) {
114        Some(KeyCommand::Color(kind))
115    } else {
116        cite_command(semantic_name).map(KeyCommand::Citation)
117    }
118}
119
120/// Collect one of the key-bearing command families through a shared extraction
121/// path. A recognized family returns `true` even when its key is not a flat
122/// literal, preventing unrelated command classifiers from seeing it.
123fn collect_key_command(
124    model: &mut SemanticModel,
125    command: &SyntaxNode,
126    name: &str,
127    semantic_name: &str,
128) -> bool {
129    let Some(kind) = key_command(name, semantic_name) else {
130        return false;
131    };
132    let Some((inner_range, inner)) = nth_group_inner(command, 0) else {
133        return true;
134    };
135    if matches!(kind, KeyCommand::Citation(CiteCommand::Nocite)) && inner.trim() == "*" {
136        model.nocite_all = true;
137        return true;
138    }
139
140    let split = match kind {
141        KeyCommand::Reference(kind) => kind.is_key_list(),
142        KeyCommand::Citation(_) => true,
143        _ => false,
144    };
145    for (key, key_range) in key_spans(&inner, inner_range, split) {
146        let key = SmolStr::new(key);
147        match kind {
148            KeyCommand::Label => model.labels.push(LabelDef {
149                name: key,
150                range: first_group_range(command),
151                key_range,
152                referenced: false,
153            }),
154            KeyCommand::Reference(command_kind) => model.refs.push(LabelRef {
155                name: key,
156                command: command_kind,
157                range: command.text_range(),
158                key_range,
159                resolved: false,
160            }),
161            KeyCommand::Glossary(kind) => model.glossary_defs.push(GlossaryDef {
162                key,
163                kind,
164                range: first_group_range(command),
165                key_range,
166            }),
167            KeyCommand::Color(kind) => model.color_defs.push(ColorDef {
168                name: key,
169                kind,
170                range: first_group_range(command),
171                key_range,
172            }),
173            KeyCommand::Citation(_) => model.citations.push(CitationRef {
174                name: key,
175                command: SmolStr::new(name),
176                range: command.text_range(),
177                key_range,
178            }),
179        }
180    }
181    true
182}
183
184/// The final top-level `label` entry in an environment options bracket, when its
185/// value is a flat literal. Key-value processors apply repeated keys in order, so
186/// a later dynamic or empty `label` must also clear an earlier literal rather than
187/// leave a definition the source no longer proves.
188fn option_label(optional: &Optional) -> Option<LabelDef> {
189    let syntax = optional.syntax();
190    let base = usize::from(syntax.text_range().start());
191    let source = syntax.text().to_string();
192    let mut entry_start = base;
193    let mut close = None;
194    let mut boundaries = Vec::new();
195
196    for element in syntax.children_with_tokens() {
197        match element {
198            NodeOrToken::Token(token) if token.kind() == SyntaxKind::L_BRACKET => {
199                entry_start = usize::from(token.text_range().end());
200            }
201            NodeOrToken::Token(token) if token.kind() == SyntaxKind::R_BRACKET => {
202                close = Some(usize::from(token.text_range().start()));
203                break;
204            }
205            // Nested groups are child nodes, so commas in their text never reach
206            // this direct-token stream. A control sequence is a child node too.
207            NodeOrToken::Token(token) if token.kind() == SyntaxKind::WORD => {
208                let token_start = usize::from(token.text_range().start());
209                boundaries.extend(
210                    token
211                        .text()
212                        .match_indices(',')
213                        .map(|(i, _)| token_start + i),
214                );
215            }
216            _ => {}
217        }
218    }
219
220    let close = close?;
221    let mut label = None;
222    for entry_end in boundaries.into_iter().chain(std::iter::once(close)) {
223        match option_label_entry(syntax, &source, base, entry_start, entry_end) {
224            LabelEntry::Other => {}
225            LabelEntry::Unknown => label = None,
226            LabelEntry::Literal(found) => label = Some(found),
227        }
228        entry_start = entry_end + 1;
229    }
230    label
231}
232
233enum LabelEntry {
234    Other,
235    /// A `label` key whose value is empty, malformed, or not statically literal.
236    Unknown,
237    Literal(LabelDef),
238}
239
240/// Classify one top-level comma-delimited option entry. The source slices are
241/// exact because every boundary comes from the `OPTIONAL` node's own direct token
242/// stream; nested brace groups remain whole child nodes.
243fn option_label_entry(
244    optional: &SyntaxNode,
245    source: &str,
246    base: usize,
247    start: usize,
248    end: usize,
249) -> LabelEntry {
250    let Some((entry, entry_start, entry_end)) = trimmed_source(source, base, start, end) else {
251        return LabelEntry::Other;
252    };
253    let equals = optional
254        .children_with_tokens()
255        .filter_map(NodeOrToken::into_token)
256        .filter(|token| token.kind() == SyntaxKind::WORD)
257        .find_map(|token| {
258            let token_start = usize::from(token.text_range().start());
259            token
260                .text()
261                .match_indices('=')
262                .map(|(i, _)| token_start + i)
263                .find(|offset| entry_start <= *offset && *offset < entry_end)
264        });
265    let Some(equals) = equals else {
266        return if entry.trim() == "label" {
267            LabelEntry::Unknown
268        } else {
269            LabelEntry::Other
270        };
271    };
272    if source_slice(source, base, entry_start, equals).trim() != "label" {
273        return LabelEntry::Other;
274    }
275    let Some((_, value_start, value_end)) = trimmed_source(source, base, equals + 1, entry_end)
276    else {
277        return LabelEntry::Unknown;
278    };
279    let value_range = TextRange::new(
280        TextSize::from(value_start as u32),
281        TextSize::from(value_end as u32),
282    );
283
284    let mut nodes = optional
285        .children()
286        .filter(|node| ranges_overlap(node.text_range(), value_range));
287    let first_node = nodes.next();
288    if nodes.next().is_some() {
289        return LabelEntry::Unknown;
290    }
291    let (name, key_range) = match first_node {
292        Some(node) if node.text_range() == value_range => {
293            let Some(group) = Group::cast(node) else {
294                return LabelEntry::Unknown;
295            };
296            let Some((inner_range, inner)) = group.inner() else {
297                return LabelEntry::Unknown;
298            };
299            let Some((key, key_range)) = key_spans(&inner, inner_range, false).into_iter().next()
300            else {
301                return LabelEntry::Unknown;
302            };
303            (SmolStr::from(key), key_range)
304        }
305        Some(_) => return LabelEntry::Unknown,
306        None => {
307            let dynamic = optional
308                .children_with_tokens()
309                .filter_map(NodeOrToken::into_token)
310                .filter(|token| ranges_overlap(token.text_range(), value_range))
311                .any(|token| matches!(token.kind(), SyntaxKind::COMMENT | SyntaxKind::HASH));
312            if dynamic {
313                return LabelEntry::Unknown;
314            }
315            (
316                SmolStr::from(source_slice(source, base, value_start, value_end)),
317                value_range,
318            )
319        }
320    };
321
322    LabelEntry::Literal(LabelDef {
323        name,
324        range: TextRange::new(
325            TextSize::from(entry_start as u32),
326            TextSize::from(entry_end as u32),
327        ),
328        key_range,
329        referenced: false,
330    })
331}
332
333fn source_slice(source: &str, base: usize, start: usize, end: usize) -> &str {
334    &source[start - base..end - base]
335}
336
337/// Trim a source subrange and return its text plus absolute byte bounds.
338fn trimmed_source(
339    source: &str,
340    base: usize,
341    start: usize,
342    end: usize,
343) -> Option<(&str, usize, usize)> {
344    let segment = source_slice(source, base, start, end);
345    let (trimmed, lo, hi) = trimmed_span(segment)?;
346    Some((trimmed, start + lo, start + hi))
347}
348
349fn ranges_overlap(left: TextRange, right: TextRange) -> bool {
350    left.start() < right.end() && right.start() < left.end()
351}
352
353/// The behavior of a curated citation command.
354#[derive(Debug, Clone, Copy, PartialEq, Eq)]
355pub enum CiteCommand {
356    /// A command whose first braced argument is a comma-separated citation-key list.
357    Cite,
358    /// `\nocite`, whose key list additionally accepts the `*` wildcard.
359    Nocite,
360}
361
362/// The recognized citation command for a control-word name, or `None`.
363///
364/// This is a closed table because a `cite` prefix does not establish argument
365/// semantics: `\citestyle` and `\citetext`, for example, do not take citation
366/// keys. Multicite and volume-cite commands are also excluded because their
367/// repeated or shifted key groups need a different extractor.
368pub fn cite_command(name: &str) -> Option<CiteCommand> {
369    Some(match name {
370        "nocite" => CiteCommand::Nocite,
371        "cite" | "Cite" | "citep" | "Citep" | "citet" | "Citet" | "citealt" | "Citealt"
372        | "citealp" | "Citealp" | "citenum" | "citeauthor" | "Citeauthor" | "citefullauthor"
373        | "Citefullauthor" | "citeyear" | "citeyearpar" | "citetalias" | "citepalias"
374        | "parencite" | "Parencite" | "footcite" | "Footcite" | "footcitetext" | "Footcitetext"
375        | "textcite" | "Textcite" | "smartcite" | "Smartcite" | "autocite" | "Autocite"
376        | "supercite" | "fullcite" | "footfullcite" | "citetitle" | "Citetitle" | "citedate"
377        | "citeurl" | "notecite" | "Notecite" | "pnotecite" | "Pnotecite" | "fnotecite"
378        | "Fnotecite" | "citename" | "citelist" | "citefield" => CiteCommand::Cite,
379        _ => return None,
380    })
381}
382
383/// The recognized reference command for a control-word name, or `None`. A small
384/// explicit table — the analog of `project::include::include_kind`. Shared with
385/// the completion classifier (`crate::completion`) so the ref-family name set has
386/// a single source of truth.
387pub fn ref_command(name: &str) -> Option<RefCommand> {
388    Some(match name {
389        "ref" => RefCommand::Ref,
390        "pageref" => RefCommand::PageRef,
391        "eqref" => RefCommand::EqRef,
392        "autoref" => RefCommand::AutoRef,
393        "nameref" => RefCommand::NameRef,
394        "cref" => RefCommand::Cref,
395        "Cref" => RefCommand::CrefUpper,
396        "vref" => RefCommand::Vref,
397        "Vref" => RefCommand::VrefUpper,
398        "cpageref" => RefCommand::CpageRef,
399        _ => return None,
400    })
401}
402
403/// The recognized glossary/acronym *definer* command for a control-word name, or
404/// `None`. The definition-side analog of [`ref_command`]; the key is always the
405/// first `{…}` group.
406pub(crate) fn glossary_definer(name: &str) -> Option<GlossaryDefKind> {
407    Some(match name {
408        "newglossaryentry"
409        | "longnewglossaryentry"
410        | "provideglossaryentry"
411        | "longprovideglossaryentry" => GlossaryDefKind::Entry,
412        "newacronym" => GlossaryDefKind::Acronym,
413        "newabbreviation" => GlossaryDefKind::Abbreviation,
414        _ => return None,
415    })
416}
417
418/// The recognized color *definer* command for a control-word name, or `None`.
419/// The definition-side analog of [`glossary_definer`]: the newly defined color
420/// name is always the first `{…}` group (`\definecolor{name}{model}{spec}`,
421/// `\colorlet{name}{base}`).
422pub(crate) fn color_definer(name: &str) -> Option<ColorDefKind> {
423    Some(match name {
424        "definecolor" => ColorDefKind::DefineColor,
425        "providecolor" => ColorDefKind::ProvideColor,
426        "colorlet" => ColorDefKind::Colorlet,
427        _ => return None,
428    })
429}
430
431/// Whether `name` is a glossary/acronym *reference* command whose first `{…}`
432/// group is an entry key (`\gls`, `\acrshort`, `\glsxtrfull`, …). Shared with the
433/// completion classifier (`crate::completion`), like [`ref_command`] and
434/// [`cite_command`], so the name set has a single source of truth. Unlike
435/// citations, every command here takes exactly **one** key per group (no comma
436/// list).
437pub fn is_glossary_ref_command(name: &str) -> bool {
438    // The `\gls` core set: base name + first-letter-uppercase + all-caps
439    // sentence-start variants, each with an optional plural `pl`.
440    const GLS: &[&str] = &[
441        "gls",
442        "Gls",
443        "GLS",
444        "glspl",
445        "Glspl",
446        "GLSpl",
447        // Text-form accessors (`\glstext{key}` prints the entry text without
448        // triggering first-use).
449        "glstext",
450        "Glstext",
451        "glsfirst",
452        "Glsfirst",
453        "glsplural",
454        "Glsplural",
455        "glsfirstplural",
456        "Glsfirstplural",
457        "glsdesc",
458        "Glsdesc",
459        "glsname",
460        "Glsname",
461        "glssymbol",
462        "Glssymbol",
463        // Key-first commands with further groups (`\glslink{key}{text}`).
464        "glslink",
465        "glsdisp",
466        "glsadd",
467        // glossaries-extra short/long/full accessors.
468        "glsxtrshort",
469        "Glsxtrshort",
470        "glsxtrlong",
471        "Glsxtrlong",
472        "glsxtrfull",
473        "Glsxtrfull",
474    ];
475    if GLS.contains(&name) {
476        return true;
477    }
478    // The acronym set: `\acrshort`/`\acrlong`/`\acrfull`, plural `pl`, in
479    // `acr`/`Acr`/`ACR` casing.
480    for stem in ["acr", "Acr", "ACR"] {
481        if let Some(rest) = name.strip_prefix(stem) {
482            return matches!(
483                rest,
484                "short" | "shortpl" | "long" | "longpl" | "full" | "fullpl"
485            );
486        }
487    }
488    false
489}
490
491/// Whether `name` is a command whose arguments hold opaque keys, identifiers, or
492/// text rather than typeset math — the `\label`/`\ref`/`\cite`/`\gls`/color
493/// families plus `\tag` (amsmath, text content) and `\hyperref` (key plus link
494/// text). The union of the family predicates above, kept here so the name sets
495/// stay single-sourced; shared with the linter's key-argument gate
496/// (`crate::linter::rules::in_key_argument`), which uses it to keep identifier
497/// keys like `\label{eq:thing_max}` out of the math-shape rules' scope.
498pub fn key_argument_command(name: &str) -> bool {
499    matches!(name, "label" | "tag" | "hyperref")
500        || ref_command(name).is_some()
501        || cite_command(name).is_some()
502        || is_glossary_ref_command(name)
503        || glossary_definer(name).is_some()
504        || color_definer(name).is_some()
505}
506
507/// Split a group's inner text into keys paired with their precise byte ranges in
508/// the source. When `split` (key-list commands, citations), keys are comma-
509/// separated; otherwise the whole inner is one key (`\label`, single-key refs).
510/// Surrounding whitespace is trimmed (TeX ignores it around these keys) and empty
511/// keys are dropped. `inner_range` is the byte span of `inner`, so each key's range
512/// is sliced off it by offset — exact because trimming removes only single-byte
513/// ASCII whitespace.
514fn key_spans(inner: &str, inner_range: TextRange, split: bool) -> Vec<(&str, TextRange)> {
515    let base = inner_range.start();
516    let mut out = Vec::new();
517    if split {
518        // Track each comma-segment's byte offset within `inner` (the segment text
519        // plus one byte for the comma that followed it).
520        let mut seg_off = 0usize;
521        for segment in inner.split(',') {
522            if let Some((key, lo, hi)) = trimmed_span(segment) {
523                out.push((key, key_range(base, seg_off + lo, seg_off + hi)));
524            }
525            seg_off += segment.len() + 1;
526        }
527    } else if let Some((key, lo, hi)) = trimmed_span(inner) {
528        out.push((key, key_range(base, lo, hi)));
529    }
530    out
531}
532
533/// The trimmed key of `segment` with its start/end byte offsets *within* `segment`,
534/// or `None` when the segment is empty after trimming.
535fn trimmed_span(segment: &str) -> Option<(&str, usize, usize)> {
536    let key = segment.trim();
537    if key.is_empty() {
538        return None;
539    }
540    let lo = segment.len() - segment.trim_start().len();
541    Some((key, lo, lo + key.len()))
542}
543
544/// Build a [`TextRange`] from `base` plus byte offsets `lo`/`hi`.
545fn key_range(base: TextSize, lo: usize, hi: usize) -> TextRange {
546    TextRange::new(
547        base + TextSize::from(lo as u32),
548        base + TextSize::from(hi as u32),
549    )
550}
551
552/// Flat name-match resolution, indexed by name so duplicate definitions and
553/// references remain linear in their combined count.
554fn resolve(model: &mut SemanticModel) {
555    let label_names: HashSet<_> = model
556        .labels
557        .iter()
558        .map(|label| label.name.clone())
559        .collect();
560    let mut referenced_names = HashSet::new();
561    for reference in &mut model.refs {
562        reference.resolved = label_names.contains(&reference.name);
563        if reference.resolved {
564            referenced_names.insert(reference.name.clone());
565        }
566    }
567    for label in &mut model.labels {
568        label.referenced = referenced_names.contains(&label.name);
569    }
570}
571
572#[cfg(test)]
573mod tests {
574    use crate::parser::parse;
575    use crate::syntax::SyntaxNode;
576
577    use super::{CiteCommand, build, cite_command};
578
579    fn model(src: &str) -> crate::semantic::SemanticModel {
580        build(&SyntaxNode::new_root(parse(src).green))
581    }
582
583    #[test]
584    fn label_key_range_excludes_command_and_braces() {
585        let src = "\\label{ sec:intro }\n";
586        let model = model(src);
587        let def = &model.labels()[0];
588        assert_eq!(def.name, "sec:intro");
589        assert_eq!(&src[def.key_range], "sec:intro");
590    }
591
592    #[test]
593    fn curated_environment_options_create_labels() {
594        let src = "\\begin{lstlisting}[caption={A, B}, label = { lst:one }]\n\
595                   x\n\
596                   \\end{lstlisting}\n\
597                   \\begin{frame}[fragile,label=frame:one]\n\
598                   x\n\
599                   \\end{frame}\n\
600                   \\ref{lst:one}\\ref{frame:one}\n";
601        let model = model(src);
602        let labels: Vec<_> = model
603            .labels()
604            .iter()
605            .map(|label| label.name.as_str())
606            .collect();
607        assert_eq!(labels, vec!["lst:one", "frame:one"]);
608        assert!(model.labels().iter().all(|label| label.referenced));
609        assert_eq!(&src[model.labels()[0].range], "label = { lst:one }");
610        assert_eq!(&src[model.labels()[0].key_range], "lst:one");
611        assert_eq!(&src[model.labels()[1].range], "label=frame:one");
612        assert_eq!(&src[model.labels()[1].key_range], "frame:one");
613    }
614
615    #[test]
616    fn only_top_level_literal_label_values_are_collected() {
617        let model = model(
618            "\\begin{tikzpicture}[label={not:a:latex:label}]\\end{tikzpicture}\n\
619             \\begin{lstlisting}[other={label={nested}},label=\\dynamic]\n\
620             x\n\
621             \\end{lstlisting}\n\
622             \\begin{lstlisting}[label={#1}]\n\
623             x\n\
624             \\end{lstlisting}\n",
625        );
626        assert!(model.labels().is_empty());
627    }
628
629    #[test]
630    fn final_valid_label_entry_wins() {
631        let src = "\\begin{lstlisting}[label=first,label={second}]\n\
632                   x\n\
633                   \\end{lstlisting}\n";
634        let model = model(src);
635        assert_eq!(model.labels().len(), 1);
636        assert_eq!(model.labels()[0].name, "second");
637        assert_eq!(&src[model.labels()[0].range], "label={second}");
638    }
639
640    #[test]
641    fn later_dynamic_label_clears_an_earlier_literal() {
642        let model = model(
643            "\\begin{lstlisting}[label=first,label=\\dynamic]\n\
644             x\n\
645             \\end{lstlisting}\n",
646        );
647        assert!(model.labels().is_empty());
648    }
649
650    #[test]
651    fn parameter_template_keys_are_skipped() {
652        let model = model("\\def\\foo#1{\\label{#1}\\eqref{##1}\\cite{#1}}\n");
653        assert!(model.labels().is_empty());
654        assert!(model.refs().is_empty());
655        assert!(model.citations().is_empty());
656    }
657
658    #[test]
659    fn cref_list_keys_get_isolated_ranges() {
660        let src = "\\cref{a,b,c}\n";
661        let model = model(src);
662        let keys: Vec<_> = model
663            .refs()
664            .iter()
665            .map(|r| (r.name.as_str(), &src[r.key_range]))
666            .collect();
667        assert_eq!(
668            keys,
669            vec![("a", "a"), ("b", "b"), ("c", "c")],
670            "each key in a list command isolates its own span"
671        );
672    }
673
674    #[test]
675    fn newglossaryentry_key_scanned_with_range() {
676        let src = "\\newglossaryentry{ex}{name={example},description={an example}}\n";
677        let model = model(src);
678        let def = &model.glossary_defs()[0];
679        assert_eq!(def.key, "ex");
680        assert_eq!(def.kind, crate::semantic::label::GlossaryDefKind::Entry);
681        assert_eq!(&src[def.key_range], "ex");
682    }
683
684    #[test]
685    fn newacronym_optional_arg_does_not_shift_key() {
686        let src = "\\newacronym[longplural={frames}]{fps}{FPS}{frame rate}\n";
687        let model = model(src);
688        let def = &model.glossary_defs()[0];
689        assert_eq!(def.key, "fps");
690        assert_eq!(def.kind, crate::semantic::label::GlossaryDefKind::Acronym);
691        assert_eq!(&src[def.key_range], "fps");
692    }
693
694    #[test]
695    fn glossary_definer_family_scanned() {
696        let src = "\\longnewglossaryentry{a}{name={a}}{desc}\n\\newabbreviation{b}{B}{bee}\n\\provideglossaryentry{c}{name={c}}\n";
697        let model = model(src);
698        let keys: Vec<_> = model
699            .glossary_defs()
700            .iter()
701            .map(|d| d.key.as_str())
702            .collect();
703        assert_eq!(keys, vec!["a", "b", "c"]);
704    }
705
706    #[test]
707    fn glossary_nested_macro_key_skipped() {
708        let model = model("\\newacronym{\\foo}{F}{foo}\n");
709        assert!(model.glossary_defs().is_empty());
710    }
711
712    #[test]
713    fn gls_use_is_not_a_definition() {
714        let model = model("\\gls{ex}\\acrshort{fps}\n");
715        assert!(model.glossary_defs().is_empty());
716    }
717
718    #[test]
719    fn color_definers_scanned_with_ranges() {
720        let src = "\\definecolor{brandblue}{HTML}{0055AA}\n\\colorlet{accent}{brandblue}\n\\providecolor{muted}{gray}{0.5}\n";
721        let model = model(src);
722        let defs: Vec<_> = model
723            .color_defs()
724            .iter()
725            .map(|d| (d.name.as_str(), d.kind, &src[d.key_range]))
726            .collect();
727        use crate::semantic::label::ColorDefKind::*;
728        assert_eq!(
729            defs,
730            vec![
731                ("brandblue", DefineColor, "brandblue"),
732                ("accent", Colorlet, "accent"),
733                ("muted", ProvideColor, "muted"),
734            ]
735        );
736    }
737
738    #[test]
739    fn textcolor_use_is_not_a_color_definition() {
740        let model = model("\\textcolor{red}{x}\\color{blue}\n");
741        assert!(model.color_defs().is_empty());
742    }
743
744    #[test]
745    fn cite_list_keys_get_isolated_ranges() {
746        let src = "\\cite{ foo , bar }\n";
747        let model = model(src);
748        let keys: Vec<_> = model
749            .citations()
750            .iter()
751            .map(|c| (c.name.as_str(), &src[c.key_range]))
752            .collect();
753        assert_eq!(keys, vec![("foo", "foo"), ("bar", "bar")]);
754    }
755
756    #[test]
757    fn cite_prefixed_non_citation_commands_are_ignored() {
758        let model = model(
759            "\\citestyle{authoryear}\\citetext{see \\cite{real}}\\citebox{content}\\citecolor{blue}\n",
760        );
761        let names: Vec<_> = model
762            .citations()
763            .iter()
764            .map(|citation| citation.name.as_str())
765            .collect();
766        assert_eq!(names, vec!["real"]);
767    }
768
769    #[test]
770    fn citation_command_table_is_closed_and_shape_specific() {
771        assert_eq!(cite_command("nocite"), Some(CiteCommand::Nocite));
772        for name in [
773            "cite",
774            "Citep",
775            "citenum",
776            "citetalias",
777            "Footcite",
778            "Autocite",
779            "Citetitle",
780            "Pnotecite",
781            "citefield",
782        ] {
783            assert_eq!(cite_command(name), Some(CiteCommand::Cite), "{name}");
784        }
785        for name in ["citestyle", "citetext", "cites", "volcite", "citebox"] {
786            assert_eq!(cite_command(name), None, "{name}");
787        }
788    }
789}