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_else(|| name.to_string()),
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 .map(|text| text.to_string())
264 .unwrap_or_else(|| group_inner_source(&group));
265 let text = text.trim().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).map(|name| name.to_string()))
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> {
404 let config = LexConfig {
405 flavor: LatexFlavor::Document,
406 dtx: true,
407 };
408 let parsed = parse_with_flavor(src, config);
409 assert_eq!(parsed.syntax().to_string(), src, "losslessness violated");
410 outline(&parsed.syntax())
411 }
412
413 #[test]
414 fn sibling_sections_are_roots() {
415 let items = outline_of("\\section{A}\ntext\n\\section{B}\n");
416 assert_eq!(items.len(), 2);
417 assert_eq!(items[0].name, "A");
418 assert_eq!(items[0].kind, OutlineSymbol::Section);
419 assert_eq!(items[1].name, "B");
420 assert!(items[0].children.is_empty());
421 }
422
423 #[test]
424 fn deeper_levels_nest() {
425 let items = outline_of("\\section{A}\n\\subsection{B}\n\\subsubsection{C}\n");
426 assert_eq!(items.len(), 1);
427 assert_eq!(items[0].name, "A");
428 assert_eq!(items[0].children.len(), 1);
429 let b = &items[0].children[0];
430 assert_eq!(b.name, "B");
431 assert_eq!(b.children.len(), 1);
432 assert_eq!(b.children[0].name, "C");
433 }
434
435 #[test]
436 fn shallower_section_pops_back_to_root() {
437 let items = outline_of("\\section{A}\n\\subsection{B}\n\\section{C}\n");
438 assert_eq!(items.len(), 2);
439 assert_eq!(items[0].name, "A");
440 assert_eq!(items[0].children[0].name, "B");
441 assert_eq!(items[1].name, "C");
442 assert!(items[1].children.is_empty());
443 }
444
445 #[test]
446 fn figure_with_label_nests_label() {
447 let items = outline_of("\\begin{figure}\n\\label{fig:x}\n\\end{figure}\n");
448 assert_eq!(items.len(), 1);
449 assert_eq!(items[0].kind, OutlineSymbol::Float);
450 assert_eq!(items[0].name, "figure");
451 assert_eq!(items[0].children.len(), 1);
452 assert_eq!(items[0].children[0].kind, OutlineSymbol::Label);
453 assert_eq!(items[0].children[0].name, "fig:x");
454 }
455
456 #[test]
457 fn theorem_is_theorem_kind() {
458 let items = outline_of("\\begin{theorem}\nx\n\\end{theorem}\n");
459 assert_eq!(items.len(), 1);
460 assert_eq!(items[0].kind, OutlineSymbol::Theorem);
461 assert_eq!(items[0].name, "theorem");
462 }
463
464 #[test]
465 fn label_after_section_nests_under_it() {
466 let items = outline_of("\\section{A}\n\\label{sec:a}\n");
467 assert_eq!(items.len(), 1);
468 assert_eq!(items[0].children.len(), 1);
469 assert_eq!(items[0].children[0].kind, OutlineSymbol::Label);
470 assert_eq!(items[0].children[0].name, "sec:a");
471 }
472
473 #[test]
474 fn float_inside_section_nests() {
475 let items = outline_of("\\section{A}\n\\begin{table}\nx\n\\end{table}\n");
476 assert_eq!(items.len(), 1);
477 assert_eq!(items[0].children.len(), 1);
478 assert_eq!(items[0].children[0].kind, OutlineSymbol::Float);
479 assert_eq!(items[0].children[0].name, "table");
480 }
481
482 #[test]
483 fn label_inside_itemize_is_hoisted_to_section() {
484 let items =
485 outline_of("\\section{A}\n\\begin{itemize}\n\\item \\label{x}\n\\end{itemize}\n");
486 assert_eq!(items.len(), 1);
487 assert_eq!(items[0].children.len(), 1);
488 assert_eq!(items[0].children[0].name, "x");
489 }
490
491 #[test]
492 fn section_extent_ends_at_next_sibling_start() {
493 let src = "\\section{A}\ntext\n\\section{B}\n";
494 let items = outline_of(src);
495 let next = src.rfind("\\section").unwrap();
496 assert_eq!(usize::from(items[0].range.end()), next);
497 }
498
499 #[test]
500 fn nested_macro_title_falls_back_to_source() {
501 let items = outline_of("\\section{\\textsc{Intro}}\n");
502 assert_eq!(items.len(), 1);
503 assert_eq!(items[0].name, "\\textsc{Intro}");
504 }
505
506 fn context_of(src: &str) -> Option<LabelContext> {
507 let offset = src.find("\\label").expect("marker") + "\\label{".len();
508 let root = SyntaxNode::new_root(parse(src).green);
509 label_context(&root, TextSize::new(offset as u32))
510 }
511
512 #[test]
513 fn label_after_section_is_section_context() {
514 let ctx = context_of("\\section{Intro}\ntext\n\\label{sec:a}\nmore\n");
515 assert_eq!(
516 ctx,
517 Some(LabelContext::Section {
518 title: "Intro".to_owned()
519 })
520 );
521 }
522
523 #[test]
524 fn label_in_figure_gets_caption() {
525 let ctx =
526 context_of("\\begin{figure}\n\\caption{A chart}\n\\label{fig:x}\n\\end{figure}\n");
527 assert_eq!(
528 ctx,
529 Some(LabelContext::Float {
530 env: "figure".to_owned(),
531 caption: Some("A chart".to_owned())
532 })
533 );
534 }
535
536 #[test]
537 fn label_in_captionless_table() {
538 let ctx = context_of("\\begin{table}\nx\\label{tab:x}\n\\end{table}\n");
539 assert_eq!(
540 ctx,
541 Some(LabelContext::Float {
542 env: "table".to_owned(),
543 caption: None
544 })
545 );
546 }
547
548 #[test]
549 fn label_in_theorem_with_description() {
550 let ctx = context_of("\\begin{theorem}[Euler]\nx \\label{thm:a}\n\\end{theorem}\n");
551 assert_eq!(
552 ctx,
553 Some(LabelContext::Theorem {
554 env: "theorem".to_owned(),
555 description: Some("Euler".to_owned())
556 })
557 );
558 }
559
560 #[test]
561 fn label_in_equation_environment_is_equation() {
562 let ctx = context_of("\\begin{equation}\nE = mc^2 \\label{eq:e}\n\\end{equation}\n");
563 assert_eq!(ctx, Some(LabelContext::Equation));
564 }
565
566 #[test]
567 fn label_in_display_math_is_equation() {
568 let ctx = context_of("\\[ x \\label{eq:x} \\]\n");
569 assert_eq!(ctx, Some(LabelContext::Equation));
570 }
571
572 #[test]
573 fn label_in_enumerate_is_item() {
574 let ctx = context_of("\\begin{enumerate}\n\\item one \\label{it:1}\n\\end{enumerate}\n");
575 assert_eq!(ctx, Some(LabelContext::Item));
576 }
577
578 #[test]
579 fn float_wins_over_enclosing_section() {
580 let ctx = context_of("\\section{A}\n\\begin{figure}\n\\label{fig:x}\n\\end{figure}\n");
581 assert!(matches!(ctx, Some(LabelContext::Float { .. })));
582 }
583
584 #[test]
585 fn item_label_inside_figure_stays_item() {
586 let ctx = context_of(
587 "\\begin{figure}\n\\begin{itemize}\n\\item \\label{it:x}\n\\end{itemize}\n\\end{figure}\n",
588 );
589 assert_eq!(ctx, Some(LabelContext::Item));
590 }
591
592 #[test]
593 fn label_before_any_section_is_none() {
594 assert_eq!(context_of("text \\label{x} more\n"), None);
595 }
596
597 #[test]
598 fn section_after_the_label_does_not_count() {
599 let ctx = context_of("text \\label{x}\n\\section{Later}\n");
600 assert_eq!(ctx, None);
601 }
602
603 #[test]
604 fn dtx_macro_env_is_a_macro_symbol() {
605 let items = outline_of_dtx("% \\begin{macro}{\\foo}\n% docs.\n% \\end{macro}\n");
606 assert_eq!(items.len(), 1);
607 assert_eq!(items[0].name, "\\foo");
608 assert_eq!(items[0].kind, OutlineSymbol::Macro);
609 }
610
611 #[test]
612 fn dtx_describe_macro_braced_and_braceless() {
613 let braced = outline_of_dtx("% \\DescribeMacro{\\foo} does foo.\n");
614 assert_eq!(braced.len(), 1);
615 assert_eq!(braced[0].name, "\\foo");
616 assert_eq!(braced[0].kind, OutlineSymbol::Macro);
617
618 let braceless = outline_of_dtx("% \\DescribeMacro\\foo does foo.\n");
619 assert_eq!(braceless.len(), 1);
620 assert_eq!(braceless[0].name, "\\foo");
621 assert_eq!(braceless[0].kind, OutlineSymbol::Macro);
622 }
623
624 #[test]
625 fn dtx_environment_constructs_are_environment_symbols() {
626 let env = outline_of_dtx("% \\begin{environment}{myenv}\n% docs.\n% \\end{environment}\n");
627 assert_eq!(env.len(), 1);
628 assert_eq!(env[0].name, "myenv");
629 assert_eq!(env[0].kind, OutlineSymbol::Environment);
630
631 let describe = outline_of_dtx("% \\DescribeEnv{myenv} is an env.\n");
632 assert_eq!(describe.len(), 1);
633 assert_eq!(describe[0].name, "myenv");
634 assert_eq!(describe[0].kind, OutlineSymbol::Environment);
635 }
636
637 #[test]
638 fn dtx_macro_nests_under_preceding_section() {
639 let items =
640 outline_of_dtx("\\section{Impl}\n% \\begin{macro}{\\foo}\n% docs.\n% \\end{macro}\n");
641 assert_eq!(items.len(), 1);
642 assert_eq!(items[0].name, "Impl");
643 assert_eq!(items[0].children.len(), 1);
644 assert_eq!(items[0].children[0].name, "\\foo");
645 assert_eq!(items[0].children[0].kind, OutlineSymbol::Macro);
646 }
647
648 #[test]
649 fn dtx_constructs_without_sections_are_roots_in_order() {
650 let items =
651 outline_of_dtx("% \\DescribeMacro\\foo\n% \\begin{macro}{\\bar}\n% \\end{macro}\n");
652 let names: Vec<&str> = items.iter().map(|i| i.name.as_str()).collect();
653 assert_eq!(names, vec!["\\foo", "\\bar"]);
654 }
655}