1use rowan::{TextRange, TextSize};
26
27use crate::ast::{
28 AstNode, Begin, Environment, Optional, child, command_name, first_group_range,
29 group_inner_source, nth_group, nth_group_text,
30};
31use crate::semantic::doc::{DocAssociation, DocKind, doc_associations};
32use crate::semantic::signature::{self, OutlineKind};
33use crate::syntax::{SyntaxKind, SyntaxNode};
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum OutlineSymbol {
38 Section,
40 Float,
42 Theorem,
44 Label,
46 Macro,
48 Environment,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct OutlineItem {
56 pub name: String,
58 pub kind: OutlineSymbol,
59 pub range: TextRange,
61 pub selection_range: TextRange,
63 pub children: Vec<OutlineItem>,
64}
65
66pub fn outline(root: &SyntaxNode) -> Vec<OutlineItem> {
76 let mut raws = collect(root);
77 raws.extend(doc_associations(root).into_iter().map(doc_raw));
78 raws.sort_by_key(|raw| raw.item.range.start());
81 nest_sections(raws, root.text_range().end())
82}
83
84fn doc_raw(assoc: DocAssociation) -> Raw {
87 Raw {
88 level: None,
89 item: OutlineItem {
90 name: assoc.name,
91 kind: match assoc.kind {
92 DocKind::Macro | DocKind::DescribeMacro => OutlineSymbol::Macro,
93 DocKind::Environment | DocKind::DescribeEnv => OutlineSymbol::Environment,
94 },
95 range: assoc.range,
96 selection_range: assoc.name_range,
97 children: Vec::new(),
98 },
99 }
100}
101
102struct Raw {
104 level: Option<u8>,
105 item: OutlineItem,
106}
107
108fn collect(node: &SyntaxNode) -> Vec<Raw> {
113 let mut out = Vec::new();
114 for child in node.children() {
115 match child.kind() {
116 SyntaxKind::COMMAND => collect_command(&child, &mut out),
117 SyntaxKind::ENVIRONMENT => collect_environment(&child, &mut out),
118 _ => out.extend(collect(&child)),
121 }
122 }
123 out
124}
125
126fn collect_command(command: &SyntaxNode, out: &mut Vec<Raw>) {
128 let Some(name) = command_name(command) else {
129 return;
130 };
131
132 if let Some(level) = signature::builtin()
136 .command(&name)
137 .and_then(|c| c.sectioning)
138 {
139 let selection = nth_group(command, 0)
140 .map(|g| g.text_range())
141 .unwrap_or_else(|| command.text_range());
142 out.push(Raw {
143 level: Some(level),
144 item: OutlineItem {
145 name: section_title(command).unwrap_or(name),
146 kind: OutlineSymbol::Section,
147 range: command.text_range(),
148 selection_range: selection,
149 children: Vec::new(),
150 },
151 });
152 } else if name == "label" {
153 if let Some(key) = nth_group_text(command, 0) {
156 let key = key.trim();
157 if !key.is_empty() {
158 let range = first_group_range(command);
159 out.push(Raw {
160 level: None,
161 item: OutlineItem {
162 name: key.to_owned(),
163 kind: OutlineSymbol::Label,
164 range,
165 selection_range: range,
166 children: Vec::new(),
167 },
168 });
169 }
170 }
171 }
172}
173
174fn collect_environment(env: &SyntaxNode, out: &mut Vec<Raw>) {
177 let begin = Environment::cast(env.clone()).and_then(|e| e.begin());
179 let name = begin.as_ref().and_then(Begin::name);
180 let kind = name.as_deref().and_then(|name| {
181 signature::builtin()
182 .environment(name)
183 .and_then(|e| e.outline)
184 });
185
186 let Some(kind) = kind else {
187 out.extend(collect(env));
189 return;
190 };
191
192 let name = name.unwrap_or_default();
193 let selection = begin
194 .as_ref()
195 .map(|b| b.syntax().text_range())
196 .unwrap_or_else(|| env.text_range());
197 out.push(Raw {
198 level: None,
199 item: OutlineItem {
200 name,
201 kind: match kind {
202 OutlineKind::Float => OutlineSymbol::Float,
203 OutlineKind::Theorem => OutlineSymbol::Theorem,
204 },
205 range: env.text_range(),
206 selection_range: selection,
207 children: nest_sections(collect(env), env.text_range().end()),
210 },
211 });
212}
213
214fn nest_sections(raws: Vec<Raw>, end_bound: TextSize) -> Vec<OutlineItem> {
218 let mut roots: Vec<OutlineItem> = Vec::new();
219 let mut stack: Vec<(u8, OutlineItem)> = Vec::new();
221
222 for raw in raws {
223 match raw.level {
224 Some(level) => {
225 let start = raw.item.range.start();
226 while stack.last().is_some_and(|(open, _)| *open >= level) {
229 let (_, mut section) = stack.pop().unwrap();
230 section.range = TextRange::new(section.range.start(), start);
231 attach(&mut roots, &mut stack, section);
232 }
233 stack.push((level, raw.item));
234 }
235 None => attach(&mut roots, &mut stack, raw.item),
237 }
238 }
239
240 while let Some((_, mut section)) = stack.pop() {
242 section.range = TextRange::new(section.range.start(), end_bound);
243 attach(&mut roots, &mut stack, section);
244 }
245 roots
246}
247
248fn attach(roots: &mut Vec<OutlineItem>, stack: &mut [(u8, OutlineItem)], item: OutlineItem) {
250 match stack.last_mut() {
251 Some((_, parent)) => parent.children.push(item),
252 None => roots.push(item),
253 }
254}
255
256fn section_title(command: &SyntaxNode) -> Option<String> {
261 let group = nth_group(command, 0)?;
262 let text = nth_group_text(command, 0)
263 .unwrap_or_else(|| group_inner_source(&group))
264 .trim()
265 .to_owned();
266 (!text.is_empty()).then_some(text)
267}
268
269#[derive(Debug, Clone, PartialEq, Eq)]
273pub enum LabelContext {
274 Section { title: String },
276 Float {
279 env: String,
280 caption: Option<String>,
281 },
282 Theorem {
285 env: String,
286 description: Option<String>,
287 },
288 Equation,
290 Item,
292}
293
294pub fn label_context(root: &SyntaxNode, offset: TextSize) -> Option<LabelContext> {
299 let element = root.covering_element(TextRange::empty(offset));
300 let mut node = match element {
301 rowan::NodeOrToken::Node(n) => Some(n),
302 rowan::NodeOrToken::Token(t) => t.parent(),
303 };
304 while let Some(current) = node {
305 match current.kind() {
306 SyntaxKind::MATH | SyntaxKind::INLINE_MATH | SyntaxKind::DISPLAY_MATH => {
307 return Some(LabelContext::Equation);
308 }
309 SyntaxKind::ENVIRONMENT => {
310 let begin = Environment::cast(current.clone()).and_then(|e| e.begin());
311 if let Some(name) = begin.as_ref().and_then(Begin::name)
312 && let Some(sig) = signature::builtin().environment(&name)
313 {
314 match sig.outline {
315 Some(OutlineKind::Float) => {
316 return Some(LabelContext::Float {
317 caption: caption_text(¤t),
318 env: name,
319 });
320 }
321 Some(OutlineKind::Theorem) => {
322 return Some(LabelContext::Theorem {
323 description: begin.as_ref().and_then(optional_text),
324 env: name,
325 });
326 }
327 None => {
328 if sig.math {
329 return Some(LabelContext::Equation);
330 }
331 if sig.list {
332 return Some(LabelContext::Item);
333 }
334 }
335 }
336 }
337 }
338 _ => {}
339 }
340 node = current.parent();
341 }
342
343 let mut best: Option<SyntaxNode> = None;
346 for command in root
347 .descendants()
348 .filter(|n| n.kind() == SyntaxKind::COMMAND)
349 {
350 if command.text_range().start() > offset {
351 break;
352 }
353 let is_sectioning = command_name(&command)
354 .and_then(|name| {
355 signature::builtin()
356 .command(&name)
357 .and_then(|c| c.sectioning)
358 })
359 .is_some();
360 if is_sectioning {
361 best = Some(command);
362 }
363 }
364 let section = best?;
365 let title = section_title(§ion)
366 .or_else(|| command_name(§ion))
367 .unwrap_or_default();
368 Some(LabelContext::Section { title })
369}
370
371fn caption_text(env: &SyntaxNode) -> Option<String> {
374 env.descendants()
375 .filter(|n| n.kind() == SyntaxKind::COMMAND)
376 .find(|c| command_name(c).as_deref() == Some("caption"))
377 .and_then(|caption| section_title(&caption))
378}
379
380fn optional_text(begin: &Begin) -> Option<String> {
383 let optional = child::<Optional>(begin.syntax())?;
384 let text = optional.syntax().text().to_string();
385 let inner = text
386 .strip_prefix('[')
387 .map(|t| t.strip_suffix(']').unwrap_or(t))
388 .unwrap_or(&text)
389 .trim()
390 .to_owned();
391 (!inner.is_empty()).then_some(inner)
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397 use crate::parser::{LatexFlavor, LexConfig, parse, parse_with_flavor};
398
399 fn outline_of(src: &str) -> Vec<OutlineItem> {
400 outline(&SyntaxNode::new_root(parse(src).green))
401 }
402
403 fn outline_of_dtx(src: &str) -> Vec<OutlineItem> {
406 let config = LexConfig {
407 flavor: LatexFlavor::Document,
408 dtx: true,
409 };
410 let parsed = parse_with_flavor(src, config);
411 assert_eq!(parsed.syntax().to_string(), src, "losslessness violated");
412 outline(&parsed.syntax())
413 }
414
415 #[test]
416 fn sibling_sections_are_roots() {
417 let items = outline_of("\\section{A}\ntext\n\\section{B}\n");
418 assert_eq!(items.len(), 2);
419 assert_eq!(items[0].name, "A");
420 assert_eq!(items[0].kind, OutlineSymbol::Section);
421 assert_eq!(items[1].name, "B");
422 assert!(items[0].children.is_empty());
423 }
424
425 #[test]
426 fn deeper_levels_nest() {
427 let items = outline_of("\\section{A}\n\\subsection{B}\n\\subsubsection{C}\n");
428 assert_eq!(items.len(), 1);
429 assert_eq!(items[0].name, "A");
430 assert_eq!(items[0].children.len(), 1);
431 let b = &items[0].children[0];
432 assert_eq!(b.name, "B");
433 assert_eq!(b.children.len(), 1);
434 assert_eq!(b.children[0].name, "C");
435 }
436
437 #[test]
438 fn shallower_section_pops_back_to_root() {
439 let items = outline_of("\\section{A}\n\\subsection{B}\n\\section{C}\n");
440 assert_eq!(items.len(), 2);
441 assert_eq!(items[0].name, "A");
442 assert_eq!(items[0].children[0].name, "B");
443 assert_eq!(items[1].name, "C");
444 assert!(items[1].children.is_empty());
445 }
446
447 #[test]
448 fn figure_with_label_nests_label() {
449 let items = outline_of("\\begin{figure}\n\\label{fig:x}\n\\end{figure}\n");
450 assert_eq!(items.len(), 1);
451 assert_eq!(items[0].kind, OutlineSymbol::Float);
452 assert_eq!(items[0].name, "figure");
453 assert_eq!(items[0].children.len(), 1);
454 assert_eq!(items[0].children[0].kind, OutlineSymbol::Label);
455 assert_eq!(items[0].children[0].name, "fig:x");
456 }
457
458 #[test]
459 fn theorem_is_theorem_kind() {
460 let items = outline_of("\\begin{theorem}\nx\n\\end{theorem}\n");
461 assert_eq!(items.len(), 1);
462 assert_eq!(items[0].kind, OutlineSymbol::Theorem);
463 assert_eq!(items[0].name, "theorem");
464 }
465
466 #[test]
467 fn label_after_section_nests_under_it() {
468 let items = outline_of("\\section{A}\n\\label{sec:a}\n");
469 assert_eq!(items.len(), 1);
470 assert_eq!(items[0].children.len(), 1);
471 assert_eq!(items[0].children[0].kind, OutlineSymbol::Label);
472 assert_eq!(items[0].children[0].name, "sec:a");
473 }
474
475 #[test]
476 fn float_inside_section_nests() {
477 let items = outline_of("\\section{A}\n\\begin{table}\nx\n\\end{table}\n");
478 assert_eq!(items.len(), 1);
479 assert_eq!(items[0].children.len(), 1);
480 assert_eq!(items[0].children[0].kind, OutlineSymbol::Float);
481 assert_eq!(items[0].children[0].name, "table");
482 }
483
484 #[test]
485 fn label_inside_itemize_is_hoisted_to_section() {
486 let items =
487 outline_of("\\section{A}\n\\begin{itemize}\n\\item \\label{x}\n\\end{itemize}\n");
488 assert_eq!(items.len(), 1);
489 assert_eq!(items[0].children.len(), 1);
492 assert_eq!(items[0].children[0].name, "x");
493 }
494
495 #[test]
496 fn section_extent_ends_at_next_sibling_start() {
497 let src = "\\section{A}\ntext\n\\section{B}\n";
498 let items = outline_of(src);
499 let next = src.rfind("\\section").unwrap();
500 assert_eq!(usize::from(items[0].range.end()), next);
501 }
502
503 #[test]
504 fn nested_macro_title_falls_back_to_source() {
505 let items = outline_of("\\section{\\textsc{Intro}}\n");
506 assert_eq!(items.len(), 1);
507 assert_eq!(items[0].name, "\\textsc{Intro}");
508 }
509
510 fn context_of(src: &str) -> Option<LabelContext> {
512 let offset = src.find("\\label").expect("marker") + "\\label{".len();
513 let root = SyntaxNode::new_root(parse(src).green);
514 label_context(&root, TextSize::new(offset as u32))
515 }
516
517 #[test]
518 fn label_after_section_is_section_context() {
519 let ctx = context_of("\\section{Intro}\ntext\n\\label{sec:a}\nmore\n");
520 assert_eq!(
521 ctx,
522 Some(LabelContext::Section {
523 title: "Intro".to_owned()
524 })
525 );
526 }
527
528 #[test]
529 fn label_in_figure_gets_caption() {
530 let ctx =
531 context_of("\\begin{figure}\n\\caption{A chart}\n\\label{fig:x}\n\\end{figure}\n");
532 assert_eq!(
533 ctx,
534 Some(LabelContext::Float {
535 env: "figure".to_owned(),
536 caption: Some("A chart".to_owned())
537 })
538 );
539 }
540
541 #[test]
542 fn label_in_captionless_table() {
543 let ctx = context_of("\\begin{table}\nx\\label{tab:x}\n\\end{table}\n");
544 assert_eq!(
545 ctx,
546 Some(LabelContext::Float {
547 env: "table".to_owned(),
548 caption: None
549 })
550 );
551 }
552
553 #[test]
554 fn label_in_theorem_with_description() {
555 let ctx = context_of("\\begin{theorem}[Euler]\nx \\label{thm:a}\n\\end{theorem}\n");
556 assert_eq!(
557 ctx,
558 Some(LabelContext::Theorem {
559 env: "theorem".to_owned(),
560 description: Some("Euler".to_owned())
561 })
562 );
563 }
564
565 #[test]
566 fn label_in_equation_environment_is_equation() {
567 let ctx = context_of("\\begin{equation}\nE = mc^2 \\label{eq:e}\n\\end{equation}\n");
568 assert_eq!(ctx, Some(LabelContext::Equation));
569 }
570
571 #[test]
572 fn label_in_display_math_is_equation() {
573 let ctx = context_of("\\[ x \\label{eq:x} \\]\n");
574 assert_eq!(ctx, Some(LabelContext::Equation));
575 }
576
577 #[test]
578 fn label_in_enumerate_is_item() {
579 let ctx = context_of("\\begin{enumerate}\n\\item one \\label{it:1}\n\\end{enumerate}\n");
580 assert_eq!(ctx, Some(LabelContext::Item));
581 }
582
583 #[test]
584 fn float_wins_over_enclosing_section() {
585 let ctx = context_of("\\section{A}\n\\begin{figure}\n\\label{fig:x}\n\\end{figure}\n");
586 assert!(matches!(ctx, Some(LabelContext::Float { .. })));
587 }
588
589 #[test]
590 fn item_label_inside_figure_stays_item() {
591 let ctx = context_of(
593 "\\begin{figure}\n\\begin{itemize}\n\\item \\label{it:x}\n\\end{itemize}\n\\end{figure}\n",
594 );
595 assert_eq!(ctx, Some(LabelContext::Item));
596 }
597
598 #[test]
599 fn label_before_any_section_is_none() {
600 assert_eq!(context_of("text \\label{x} more\n"), None);
601 }
602
603 #[test]
604 fn section_after_the_label_does_not_count() {
605 let ctx = context_of("text \\label{x}\n\\section{Later}\n");
606 assert_eq!(ctx, None);
607 }
608
609 #[test]
610 fn dtx_macro_env_is_a_macro_symbol() {
611 let items = outline_of_dtx("% \\begin{macro}{\\foo}\n% docs.\n% \\end{macro}\n");
612 assert_eq!(items.len(), 1);
613 assert_eq!(items[0].name, "\\foo");
614 assert_eq!(items[0].kind, OutlineSymbol::Macro);
615 }
616
617 #[test]
618 fn dtx_describe_macro_braced_and_braceless() {
619 let braced = outline_of_dtx("% \\DescribeMacro{\\foo} does foo.\n");
620 assert_eq!(braced.len(), 1);
621 assert_eq!(braced[0].name, "\\foo");
622 assert_eq!(braced[0].kind, OutlineSymbol::Macro);
623
624 let braceless = outline_of_dtx("% \\DescribeMacro\\foo does foo.\n");
625 assert_eq!(braceless.len(), 1);
626 assert_eq!(braceless[0].name, "\\foo");
627 assert_eq!(braceless[0].kind, OutlineSymbol::Macro);
628 }
629
630 #[test]
631 fn dtx_environment_constructs_are_environment_symbols() {
632 let env = outline_of_dtx("% \\begin{environment}{myenv}\n% docs.\n% \\end{environment}\n");
633 assert_eq!(env.len(), 1);
634 assert_eq!(env[0].name, "myenv");
635 assert_eq!(env[0].kind, OutlineSymbol::Environment);
636
637 let describe = outline_of_dtx("% \\DescribeEnv{myenv} is an env.\n");
638 assert_eq!(describe.len(), 1);
639 assert_eq!(describe[0].name, "myenv");
640 assert_eq!(describe[0].kind, OutlineSymbol::Environment);
641 }
642
643 #[test]
644 fn dtx_macro_nests_under_preceding_section() {
645 let items =
646 outline_of_dtx("\\section{Impl}\n% \\begin{macro}{\\foo}\n% docs.\n% \\end{macro}\n");
647 assert_eq!(items.len(), 1);
648 assert_eq!(items[0].name, "Impl");
649 assert_eq!(items[0].children.len(), 1);
650 assert_eq!(items[0].children[0].name, "\\foo");
651 assert_eq!(items[0].children[0].kind, OutlineSymbol::Macro);
652 }
653
654 #[test]
655 fn dtx_constructs_without_sections_are_roots_in_order() {
656 let items =
657 outline_of_dtx("% \\DescribeMacro\\foo\n% \\begin{macro}{\\bar}\n% \\end{macro}\n");
658 let names: Vec<&str> = items.iter().map(|i| i.name.as_str()).collect();
659 assert_eq!(names, vec!["\\foo", "\\bar"]);
660 }
661}