1use 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
32pub 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 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 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
120fn 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
184fn 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 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 Unknown,
237 Literal(LabelDef),
238}
239
240fn 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
337fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
355pub enum CiteCommand {
356 Cite,
358 Nocite,
360}
361
362pub 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
383pub 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
403pub(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
418pub(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
431pub fn is_glossary_ref_command(name: &str) -> bool {
438 const GLS: &[&str] = &[
441 "gls",
442 "Gls",
443 "GLS",
444 "glspl",
445 "Glspl",
446 "GLSpl",
447 "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 "glslink",
465 "glsdisp",
466 "glsadd",
467 "glsxtrshort",
469 "Glsxtrshort",
470 "glsxtrlong",
471 "Glsxtrlong",
472 "glsxtrfull",
473 "Glsxtrfull",
474 ];
475 if GLS.contains(&name) {
476 return true;
477 }
478 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
491pub 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
507fn 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 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
533fn 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
544fn 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
552fn 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}