1use rowan::{TextRange, TextSize};
27
28use crate::ast::{
29 AstNode, Begin, Environment, Optional, child, command_name, first_group_range,
30 group_inner_source, nth_group, nth_group_text,
31};
32use crate::semantic::doc::{DocAssociation, DocKind, doc_associations};
33use crate::semantic::signature::{self, OutlineKind};
34use crate::syntax::{SyntaxKind, SyntaxNode};
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum OutlineSymbol {
39 Section,
41 Float,
43 Theorem,
45 Frame,
47 Label,
49 Macro,
51 Environment,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct OutlineItem {
59 pub name: String,
61 pub kind: OutlineSymbol,
62 pub range: TextRange,
64 pub selection_range: TextRange,
66 pub children: Vec<OutlineItem>,
67}
68
69pub fn outline(root: &SyntaxNode) -> Vec<OutlineItem> {
79 let mut raws = collect(root);
80 raws.extend(doc_associations(root).into_iter().map(doc_raw));
81 raws.sort_by_key(|raw| raw.item.range.start());
84 nest_sections(raws, root.text_range().end())
85}
86
87fn doc_raw(assoc: DocAssociation) -> Raw {
90 Raw {
91 level: None,
92 item: OutlineItem {
93 name: assoc.name,
94 kind: match assoc.kind {
95 DocKind::Macro | DocKind::DescribeMacro => OutlineSymbol::Macro,
96 DocKind::Environment | DocKind::DescribeEnv => OutlineSymbol::Environment,
97 },
98 range: assoc.range,
99 selection_range: assoc.name_range,
100 children: Vec::new(),
101 },
102 }
103}
104
105struct Raw {
107 level: Option<u8>,
108 item: OutlineItem,
109}
110
111fn collect(node: &SyntaxNode) -> Vec<Raw> {
116 let mut out = Vec::new();
117 for child in node.children() {
118 match child.kind() {
119 SyntaxKind::COMMAND => collect_command(&child, &mut out),
120 SyntaxKind::ENVIRONMENT => collect_environment(&child, &mut out),
121 _ => out.extend(collect(&child)),
124 }
125 }
126 out
127}
128
129fn collect_command(command: &SyntaxNode, out: &mut Vec<Raw>) {
132 let Some(name) = command_name(command) else {
133 return;
134 };
135
136 if name == "frame"
139 && let Some((title, selection_range)) = frametitle_command(command)
140 {
141 out.push(Raw {
142 level: None,
143 item: OutlineItem {
144 name: title,
145 kind: OutlineSymbol::Frame,
146 range: command.text_range(),
147 selection_range,
148 children: nest_sections(collect(command), command.text_range().end()),
149 },
150 });
151 return;
152 }
153
154 if let Some(level) = signature::builtin()
158 .command(&name)
159 .and_then(|c| c.sectioning)
160 {
161 let selection = nth_group(command, 0)
162 .map(|g| g.text_range())
163 .unwrap_or_else(|| command.text_range());
164 out.push(Raw {
165 level: Some(level),
166 item: OutlineItem {
167 name: section_title(command).unwrap_or_else(|| name.to_string()),
168 kind: OutlineSymbol::Section,
169 range: command.text_range(),
170 selection_range: selection,
171 children: Vec::new(),
172 },
173 });
174 } else if name == "label" {
175 if let Some(key) = nth_group_text(command, 0) {
178 let key = key.trim();
179 if !key.is_empty() {
180 let range = first_group_range(command);
181 out.push(Raw {
182 level: None,
183 item: OutlineItem {
184 name: key.to_owned(),
185 kind: OutlineSymbol::Label,
186 range,
187 selection_range: range,
188 children: Vec::new(),
189 },
190 });
191 }
192 }
193 }
194}
195
196fn collect_environment(env: &SyntaxNode, out: &mut Vec<Raw>) {
199 let begin = Environment::cast(env.clone()).and_then(|e| e.begin());
201 let name = begin.as_ref().and_then(Begin::name);
202 let kind = name.as_deref().and_then(|name| {
203 signature::builtin()
204 .environment(name)
205 .and_then(|e| e.outline)
206 });
207
208 let Some(kind) = kind else {
209 out.extend(collect(env));
211 return;
212 };
213
214 let name = name.unwrap_or_default();
215 let begin_selection = begin
216 .as_ref()
217 .map(|b| b.syntax().text_range())
218 .unwrap_or_else(|| env.text_range());
219 let (display_name, symbol, selection) = match kind {
220 OutlineKind::Float => (name, OutlineSymbol::Float, begin_selection),
221 OutlineKind::Theorem => (name, OutlineSymbol::Theorem, begin_selection),
222 OutlineKind::Frame => {
223 let Some((title, selection)) = frame_title(env, begin.as_ref()) else {
224 out.extend(collect(env));
227 return;
228 };
229 (title, OutlineSymbol::Frame, selection)
230 }
231 };
232 out.push(Raw {
233 level: None,
234 item: OutlineItem {
235 name: display_name,
236 kind: symbol,
237 range: env.text_range(),
238 selection_range: selection,
239 children: nest_sections(collect(env), env.text_range().end()),
242 },
243 });
244}
245
246fn frame_title(env: &SyntaxNode, begin: Option<&Begin>) -> Option<(String, TextRange)> {
250 begin
251 .and_then(|begin| title_argument(begin.syntax(), 0))
252 .or_else(|| frametitle_command(env))
253}
254
255fn frametitle_command(node: &SyntaxNode) -> Option<(String, TextRange)> {
257 node.descendants()
258 .filter(|descendant| descendant.kind() == SyntaxKind::COMMAND)
259 .find(|command| command_name(command).as_deref() == Some("frametitle"))
260 .and_then(|command| title_argument(&command, 0))
261}
262
263fn nest_sections(raws: Vec<Raw>, end_bound: TextSize) -> Vec<OutlineItem> {
267 let mut roots: Vec<OutlineItem> = Vec::new();
268 let mut stack: Vec<(u8, OutlineItem)> = Vec::new();
270
271 for raw in raws {
272 match raw.level {
273 Some(level) => {
274 let start = raw.item.range.start();
275 while stack.last().is_some_and(|(open, _)| *open >= level) {
278 let (_, mut section) = stack.pop().unwrap();
279 section.range = TextRange::new(section.range.start(), start);
280 attach(&mut roots, &mut stack, section);
281 }
282 stack.push((level, raw.item));
283 }
284 None => attach(&mut roots, &mut stack, raw.item),
286 }
287 }
288
289 while let Some((_, mut section)) = stack.pop() {
291 section.range = TextRange::new(section.range.start(), end_bound);
292 attach(&mut roots, &mut stack, section);
293 }
294 roots
295}
296
297fn attach(roots: &mut Vec<OutlineItem>, stack: &mut [(u8, OutlineItem)], item: OutlineItem) {
299 match stack.last_mut() {
300 Some((_, parent)) => parent.children.push(item),
301 None => roots.push(item),
302 }
303}
304
305fn section_title(command: &SyntaxNode) -> Option<String> {
310 title_argument(command, 0).map(|(title, _)| title)
311}
312
313fn title_argument(node: &SyntaxNode, n: usize) -> Option<(String, TextRange)> {
316 let group = nth_group(node, n)?;
317 let text = nth_group_text(node, n)
318 .map(|text| text.to_string())
319 .unwrap_or_else(|| group_inner_source(&group));
320 let text = text.trim().to_owned();
321 (!text.is_empty()).then(|| (text, group.text_range()))
322}
323
324#[derive(Debug, Clone, PartialEq, Eq)]
328pub enum LabelContext {
329 Section { title: String },
331 Float {
334 env: String,
335 caption: Option<String>,
336 },
337 Theorem {
340 env: String,
341 description: Option<String>,
342 },
343 Equation,
345 Item,
347}
348
349pub fn label_context(root: &SyntaxNode, offset: TextSize) -> Option<LabelContext> {
354 let element = root.covering_element(TextRange::empty(offset));
355 let mut node = match element {
356 rowan::NodeOrToken::Node(n) => Some(n),
357 rowan::NodeOrToken::Token(t) => t.parent(),
358 };
359 while let Some(current) = node {
360 match current.kind() {
361 SyntaxKind::MATH | SyntaxKind::INLINE_MATH | SyntaxKind::DISPLAY_MATH => {
362 return Some(LabelContext::Equation);
363 }
364 SyntaxKind::ENVIRONMENT => {
365 let begin = Environment::cast(current.clone()).and_then(|e| e.begin());
366 if let Some(name) = begin.as_ref().and_then(Begin::name)
367 && let Some(sig) = signature::builtin().environment(&name)
368 {
369 match sig.outline {
370 Some(OutlineKind::Float) => {
371 return Some(LabelContext::Float {
372 caption: caption_text(¤t),
373 env: name,
374 });
375 }
376 Some(OutlineKind::Theorem) => {
377 return Some(LabelContext::Theorem {
378 description: begin.as_ref().and_then(optional_text),
379 env: name,
380 });
381 }
382 Some(OutlineKind::Frame) => {}
383 None => {
384 if sig.math {
385 return Some(LabelContext::Equation);
386 }
387 if sig.list {
388 return Some(LabelContext::Item);
389 }
390 }
391 }
392 }
393 }
394 _ => {}
395 }
396 node = current.parent();
397 }
398
399 let mut best: Option<SyntaxNode> = None;
402 for command in root
403 .descendants()
404 .filter(|n| n.kind() == SyntaxKind::COMMAND)
405 {
406 if command.text_range().start() > offset {
407 break;
408 }
409 let is_sectioning = command_name(&command)
410 .and_then(|name| {
411 signature::builtin()
412 .command(&name)
413 .and_then(|c| c.sectioning)
414 })
415 .is_some();
416 if is_sectioning {
417 best = Some(command);
418 }
419 }
420 let section = best?;
421 let title = section_title(§ion)
422 .or_else(|| command_name(§ion).map(|name| name.to_string()))
423 .unwrap_or_default();
424 Some(LabelContext::Section { title })
425}
426
427fn caption_text(env: &SyntaxNode) -> Option<String> {
430 env.descendants()
431 .filter(|n| n.kind() == SyntaxKind::COMMAND)
432 .find(|c| command_name(c).as_deref() == Some("caption"))
433 .and_then(|caption| section_title(&caption))
434}
435
436fn optional_text(begin: &Begin) -> Option<String> {
439 let optional = child::<Optional>(begin.syntax())?;
440 let text = optional.syntax().text().to_string();
441 let inner = text
442 .strip_prefix('[')
443 .map(|t| t.strip_suffix(']').unwrap_or(t))
444 .unwrap_or(&text)
445 .trim()
446 .to_owned();
447 (!inner.is_empty()).then_some(inner)
448}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453 use crate::parser::{LatexFlavor, LexConfig, parse, parse_with_flavor};
454
455 fn outline_of(src: &str) -> Vec<OutlineItem> {
456 outline(&SyntaxNode::new_root(parse(src).green))
457 }
458
459 fn outline_of_dtx(src: &str) -> Vec<OutlineItem> {
460 let config = LexConfig {
461 flavor: LatexFlavor::Document,
462 dtx: true,
463 };
464 let parsed = parse_with_flavor(src, config);
465 assert_eq!(parsed.syntax().to_string(), src, "losslessness violated");
466 outline(&parsed.syntax())
467 }
468
469 #[test]
470 fn sibling_sections_are_roots() {
471 let items = outline_of("\\section{A}\ntext\n\\section{B}\n");
472 assert_eq!(items.len(), 2);
473 assert_eq!(items[0].name, "A");
474 assert_eq!(items[0].kind, OutlineSymbol::Section);
475 assert_eq!(items[1].name, "B");
476 assert!(items[0].children.is_empty());
477 }
478
479 #[test]
480 fn deeper_levels_nest() {
481 let items = outline_of("\\section{A}\n\\subsection{B}\n\\subsubsection{C}\n");
482 assert_eq!(items.len(), 1);
483 assert_eq!(items[0].name, "A");
484 assert_eq!(items[0].children.len(), 1);
485 let b = &items[0].children[0];
486 assert_eq!(b.name, "B");
487 assert_eq!(b.children.len(), 1);
488 assert_eq!(b.children[0].name, "C");
489 }
490
491 #[test]
492 fn shallower_section_pops_back_to_root() {
493 let items = outline_of("\\section{A}\n\\subsection{B}\n\\section{C}\n");
494 assert_eq!(items.len(), 2);
495 assert_eq!(items[0].name, "A");
496 assert_eq!(items[0].children[0].name, "B");
497 assert_eq!(items[1].name, "C");
498 assert!(items[1].children.is_empty());
499 }
500
501 #[test]
502 fn figure_with_label_nests_label() {
503 let items = outline_of("\\begin{figure}\n\\label{fig:x}\n\\end{figure}\n");
504 assert_eq!(items.len(), 1);
505 assert_eq!(items[0].kind, OutlineSymbol::Float);
506 assert_eq!(items[0].name, "figure");
507 assert_eq!(items[0].children.len(), 1);
508 assert_eq!(items[0].children[0].kind, OutlineSymbol::Label);
509 assert_eq!(items[0].children[0].name, "fig:x");
510 }
511
512 #[test]
513 fn theorem_is_theorem_kind() {
514 let items = outline_of("\\begin{theorem}\nx\n\\end{theorem}\n");
515 assert_eq!(items.len(), 1);
516 assert_eq!(items[0].kind, OutlineSymbol::Theorem);
517 assert_eq!(items[0].name, "theorem");
518 }
519
520 #[test]
521 fn label_after_section_nests_under_it() {
522 let items = outline_of("\\section{A}\n\\label{sec:a}\n");
523 assert_eq!(items.len(), 1);
524 assert_eq!(items[0].children.len(), 1);
525 assert_eq!(items[0].children[0].kind, OutlineSymbol::Label);
526 assert_eq!(items[0].children[0].name, "sec:a");
527 }
528
529 #[test]
530 fn float_inside_section_nests() {
531 let items = outline_of("\\section{A}\n\\begin{table}\nx\n\\end{table}\n");
532 assert_eq!(items.len(), 1);
533 assert_eq!(items[0].children.len(), 1);
534 assert_eq!(items[0].children[0].kind, OutlineSymbol::Float);
535 assert_eq!(items[0].children[0].name, "table");
536 }
537
538 #[test]
539 fn titled_beamer_frames_nest_inside_sections() {
540 let src = "\\section{Talk}\n\
541 \\begin{frame}[fragile]\n\
542 \\frametitle[Short]{First \\emph{slide}}\n\
543 \\label{frame:first}\n\
544 \\end{frame}\n\
545 \\begin{frame}[plain]{Second slide}\n\
546 body\n\
547 \\end{frame}\n";
548 let items = outline_of(src);
549
550 assert_eq!(items.len(), 1);
551 let frames = &items[0].children;
552 assert_eq!(
553 frames
554 .iter()
555 .map(|item| item.name.as_str())
556 .collect::<Vec<_>>(),
557 vec!["First \\emph{slide}", "Second slide"]
558 );
559 assert!(frames.iter().all(|item| item.kind == OutlineSymbol::Frame));
560 assert_eq!(
561 &src[usize::from(frames[0].selection_range.start())
562 ..usize::from(frames[0].selection_range.end())],
563 "{First \\emph{slide}}"
564 );
565 assert_eq!(frames[0].children.len(), 1);
566 assert_eq!(frames[0].children[0].name, "frame:first");
567 assert_eq!(
568 &src[usize::from(frames[1].selection_range.start())
569 ..usize::from(frames[1].selection_range.end())],
570 "{Second slide}"
571 );
572 }
573
574 #[test]
575 fn untitled_beamer_frame_remains_transparent() {
576 let items =
577 outline_of("\\section{Talk}\n\\begin{frame}\n\\label{frame:untitled}\n\\end{frame}\n");
578
579 assert_eq!(items.len(), 1);
580 assert_eq!(items[0].children.len(), 1);
581 assert_eq!(items[0].children[0].kind, OutlineSymbol::Label);
582 assert_eq!(items[0].children[0].name, "frame:untitled");
583 }
584
585 #[test]
586 fn command_form_beamer_frame_uses_frametitle() {
587 let items = outline_of(
588 "\\section{Talk}\n\\frame{\\frametitle{Command slide}\\label{frame:command}body}\n",
589 );
590
591 assert_eq!(items.len(), 1);
592 let frame = &items[0].children[0];
593 assert_eq!(frame.kind, OutlineSymbol::Frame);
594 assert_eq!(frame.name, "Command slide");
595 assert_eq!(frame.children.len(), 1);
596 assert_eq!(frame.children[0].name, "frame:command");
597 }
598
599 #[test]
600 fn label_inside_itemize_is_hoisted_to_section() {
601 let items =
602 outline_of("\\section{A}\n\\begin{itemize}\n\\item \\label{x}\n\\end{itemize}\n");
603 assert_eq!(items.len(), 1);
604 assert_eq!(items[0].children.len(), 1);
605 assert_eq!(items[0].children[0].name, "x");
606 }
607
608 #[test]
609 fn section_extent_ends_at_next_sibling_start() {
610 let src = "\\section{A}\ntext\n\\section{B}\n";
611 let items = outline_of(src);
612 let next = src.rfind("\\section").unwrap();
613 assert_eq!(usize::from(items[0].range.end()), next);
614 }
615
616 #[test]
617 fn nested_macro_title_falls_back_to_source() {
618 let items = outline_of("\\section{\\textsc{Intro}}\n");
619 assert_eq!(items.len(), 1);
620 assert_eq!(items[0].name, "\\textsc{Intro}");
621 }
622
623 fn context_of(src: &str) -> Option<LabelContext> {
624 let offset = src.find("\\label").expect("marker") + "\\label{".len();
625 let root = SyntaxNode::new_root(parse(src).green);
626 label_context(&root, TextSize::new(offset as u32))
627 }
628
629 #[test]
630 fn label_after_section_is_section_context() {
631 let ctx = context_of("\\section{Intro}\ntext\n\\label{sec:a}\nmore\n");
632 assert_eq!(
633 ctx,
634 Some(LabelContext::Section {
635 title: "Intro".to_owned()
636 })
637 );
638 }
639
640 #[test]
641 fn label_in_figure_gets_caption() {
642 let ctx =
643 context_of("\\begin{figure}\n\\caption{A chart}\n\\label{fig:x}\n\\end{figure}\n");
644 assert_eq!(
645 ctx,
646 Some(LabelContext::Float {
647 env: "figure".to_owned(),
648 caption: Some("A chart".to_owned())
649 })
650 );
651 }
652
653 #[test]
654 fn label_in_captionless_table() {
655 let ctx = context_of("\\begin{table}\nx\\label{tab:x}\n\\end{table}\n");
656 assert_eq!(
657 ctx,
658 Some(LabelContext::Float {
659 env: "table".to_owned(),
660 caption: None
661 })
662 );
663 }
664
665 #[test]
666 fn label_in_theorem_with_description() {
667 let ctx = context_of("\\begin{theorem}[Euler]\nx \\label{thm:a}\n\\end{theorem}\n");
668 assert_eq!(
669 ctx,
670 Some(LabelContext::Theorem {
671 env: "theorem".to_owned(),
672 description: Some("Euler".to_owned())
673 })
674 );
675 }
676
677 #[test]
678 fn label_in_equation_environment_is_equation() {
679 let ctx = context_of("\\begin{equation}\nE = mc^2 \\label{eq:e}\n\\end{equation}\n");
680 assert_eq!(ctx, Some(LabelContext::Equation));
681 }
682
683 #[test]
684 fn label_in_display_math_is_equation() {
685 let ctx = context_of("\\[ x \\label{eq:x} \\]\n");
686 assert_eq!(ctx, Some(LabelContext::Equation));
687 }
688
689 #[test]
690 fn label_in_enumerate_is_item() {
691 let ctx = context_of("\\begin{enumerate}\n\\item one \\label{it:1}\n\\end{enumerate}\n");
692 assert_eq!(ctx, Some(LabelContext::Item));
693 }
694
695 #[test]
696 fn float_wins_over_enclosing_section() {
697 let ctx = context_of("\\section{A}\n\\begin{figure}\n\\label{fig:x}\n\\end{figure}\n");
698 assert!(matches!(ctx, Some(LabelContext::Float { .. })));
699 }
700
701 #[test]
702 fn item_label_inside_figure_stays_item() {
703 let ctx = context_of(
704 "\\begin{figure}\n\\begin{itemize}\n\\item \\label{it:x}\n\\end{itemize}\n\\end{figure}\n",
705 );
706 assert_eq!(ctx, Some(LabelContext::Item));
707 }
708
709 #[test]
710 fn label_before_any_section_is_none() {
711 assert_eq!(context_of("text \\label{x} more\n"), None);
712 }
713
714 #[test]
715 fn section_after_the_label_does_not_count() {
716 let ctx = context_of("text \\label{x}\n\\section{Later}\n");
717 assert_eq!(ctx, None);
718 }
719
720 #[test]
721 fn dtx_macro_env_is_a_macro_symbol() {
722 let items = outline_of_dtx("% \\begin{macro}{\\foo}\n% docs.\n% \\end{macro}\n");
723 assert_eq!(items.len(), 1);
724 assert_eq!(items[0].name, "\\foo");
725 assert_eq!(items[0].kind, OutlineSymbol::Macro);
726 }
727
728 #[test]
729 fn dtx_describe_macro_braced_and_braceless() {
730 let braced = outline_of_dtx("% \\DescribeMacro{\\foo} does foo.\n");
731 assert_eq!(braced.len(), 1);
732 assert_eq!(braced[0].name, "\\foo");
733 assert_eq!(braced[0].kind, OutlineSymbol::Macro);
734
735 let braceless = outline_of_dtx("% \\DescribeMacro\\foo does foo.\n");
736 assert_eq!(braceless.len(), 1);
737 assert_eq!(braceless[0].name, "\\foo");
738 assert_eq!(braceless[0].kind, OutlineSymbol::Macro);
739 }
740
741 #[test]
742 fn dtx_environment_constructs_are_environment_symbols() {
743 let env = outline_of_dtx("% \\begin{environment}{myenv}\n% docs.\n% \\end{environment}\n");
744 assert_eq!(env.len(), 1);
745 assert_eq!(env[0].name, "myenv");
746 assert_eq!(env[0].kind, OutlineSymbol::Environment);
747
748 let describe = outline_of_dtx("% \\DescribeEnv{myenv} is an env.\n");
749 assert_eq!(describe.len(), 1);
750 assert_eq!(describe[0].name, "myenv");
751 assert_eq!(describe[0].kind, OutlineSymbol::Environment);
752 }
753
754 #[test]
755 fn dtx_macro_nests_under_preceding_section() {
756 let items =
757 outline_of_dtx("\\section{Impl}\n% \\begin{macro}{\\foo}\n% docs.\n% \\end{macro}\n");
758 assert_eq!(items.len(), 1);
759 assert_eq!(items[0].name, "Impl");
760 assert_eq!(items[0].children.len(), 1);
761 assert_eq!(items[0].children[0].name, "\\foo");
762 assert_eq!(items[0].children[0].kind, OutlineSymbol::Macro);
763 }
764
765 #[test]
766 fn dtx_constructs_without_sections_are_roots_in_order() {
767 let items =
768 outline_of_dtx("% \\DescribeMacro\\foo\n% \\begin{macro}{\\bar}\n% \\end{macro}\n");
769 let names: Vec<&str> = items.iter().map(|i| i.name.as_str()).collect();
770 assert_eq!(names, vec!["\\foo", "\\bar"]);
771 }
772}