Skip to main content

badness_parser/semantic/
outline.rs

1//! Build a document-symbol outline from the CST: the sectioning hierarchy
2//! (`\part` … `\subparagraph`), with titled Beamer frames, float/theorem
3//! environments, `\label`s, and a `.dtx`'s documented macros/environments (via
4//! [`doc_associations`]) as leaves.
5//! LSP-agnostic by design (byte ranges, no `lsp_types`) so it is
6//! unit-testable without the language server; the `lsp` module converts the
7//! [`OutlineItem`] tree into `lsp_types::DocumentSymbol`.
8//!
9//! Classification reads the built-in [`signature`] DB: a command's
10//! [`sectioning`](signature::CommandSig::sectioning) level and an environment's
11//! [`outline`](signature::EnvironmentSig::outline) category. User-defined
12//! sectioning commands are out of scope here — sectioning is a static, standard
13//! set — so we consult [`signature::builtin`] directly rather than the scanned
14//! two-tier [`signature::Signatures`].
15//!
16//! Two passes. [`collect`] walks the CST in document order into a flat list of
17//! `(level, item)` pairs: sectioning commands carry their level, while frames,
18//! floats, theorems, and labels carry `None`. Non-outline environments
19//! (`document`, `itemize`, …), along with untitled frames, are *transparent* —
20//! their contents splice into the parent stream so a `\label` inside `itemize`
21//! still surfaces. [`nest_sections`] then folds the flat list into the hierarchy
22//! with a level stack (sectioning commands are CST siblings; nesting is implied by
23//! level, not tree shape), attaching each non-section item to the deepest open
24//! section and stretching each section's range to where it closes.
25
26use 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/// The kind of an outline entry, driving the LSP `SymbolKind` mapping.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum OutlineSymbol {
39    /// A sectioning command (`\section`, `\subsection`, …).
40    Section,
41    /// A float environment (`figure`, `table`).
42    Float,
43    /// A theorem-like environment (`theorem`, `lemma`, `proof`, …).
44    Theorem,
45    /// A titled Beamer frame.
46    Frame,
47    /// A `\label{…}` definition.
48    Label,
49    /// A documented `.dtx` macro: a `macro` environment or `\DescribeMacro`.
50    Macro,
51    /// A documented `.dtx` environment: an `environment` environment or
52    /// `\DescribeEnv`.
53    Environment,
54}
55
56/// One node in the document-symbol outline tree.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct OutlineItem {
59    /// Display name: a section title, an environment name, or a label key.
60    pub name: String,
61    pub kind: OutlineSymbol,
62    /// The full extent of the symbol (a section spans to where it closes).
63    pub range: TextRange,
64    /// The identifier sub-range to highlight on selection (always `⊆ range`).
65    pub selection_range: TextRange,
66    pub children: Vec<OutlineItem>,
67}
68
69/// Build the outline tree for `root`.
70///
71/// The sectioning/float/label stream from [`collect`] is merged with the `.dtx`
72/// documentation constructs from [`doc_associations`], re-sorted into document
73/// order, then folded into the hierarchy by [`nest_sections`] — so a documented
74/// macro nests under its enclosing section like any other leaf. (A doc construct
75/// nested *inside* a float surfaces as a top-level sibling rather than under that
76/// float, since [`collect_environment`] pre-nests float children from its own walk;
77/// real `.dtx` files don't put doc constructs inside floats.)
78pub fn outline(root: &SyntaxNode) -> Vec<OutlineItem> {
79    let mut raws = collect(root);
80    raws.extend(doc_associations(root).into_iter().map(doc_raw));
81    // Stable sort keeps co-located items (e.g. a section and a label at the same
82    // offset) in their original relative order.
83    raws.sort_by_key(|raw| raw.item.range.start());
84    nest_sections(raws, root.text_range().end())
85}
86
87/// Convert a `.dtx` [`DocAssociation`] into a leaf `Raw` (never a section, so
88/// `level: None`); [`nest_sections`] then attaches it to the deepest open section.
89fn 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
105/// A collected item plus, for a sectioning command, its nesting level.
106struct Raw {
107    level: Option<u8>,
108    item: OutlineItem,
109}
110
111/// Walk `node`'s children in document order, producing the flat `(level, item)`
112/// stream. Recurses into transparent environments and other container nodes so
113/// nested labels/floats/sections surface; outline environments (float/theorem)
114/// become a single item whose own children are nested independently.
115fn 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            // Any other container (PARAGRAPH, GROUP, BEGIN, END, …): recurse so a
122            // command or environment nested inside still reaches the stream.
123            _ => out.extend(collect(&child)),
124        }
125    }
126    out
127}
128
129/// Emit a sectioning, command-form Beamer frame, or `\label` item for a `COMMAND`
130/// node, if it is one.
131fn collect_command(command: &SyntaxNode, out: &mut Vec<Raw>) {
132    let Some(name) = command_name(command) else {
133        return;
134    };
135
136    // `\frame{\frametitle{…} …}` is Beamer's command form. Requiring the nested
137    // title keeps LaTeX's unrelated box-drawing `\frame{…}` command out.
138    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    // Curated [`signature::builtin`] only — never the bulk CWL tier: the symbol
155    // outline is a curated judgment, and CWL's sectioning classifications are not
156    // trustworthy enough to drive it (the CWL tier carries no `sectioning` anyway).
157    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        // Skip an empty or nested-macro key (`\label{\foo}`), matching the
176        // semantic model's conservative collection.
177        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
196/// Emit a frame/float/theorem item for an `ENVIRONMENT` node, or splice its
197/// contents in transparently when it is not outline-worthy.
198fn collect_environment(env: &SyntaxNode, out: &mut Vec<Raw>) {
199    // The name lives in the `\begin{name}` (`BEGIN` node), not on `ENVIRONMENT`.
200    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        // Transparent: hoist any inner sections/floats/labels into the parent.
210        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                // An untitled frame contributes no useful navigation target, but
225                // its labels and other outline constructs must still surface.
226                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            // Inner labels and nested outline environments belong to this
240            // container; nest its body independently against the environment's end.
241            children: nest_sections(collect(env), env.text_range().end()),
242        },
243    });
244}
245
246/// A Beamer frame's long title and its source range. The environment shorthand
247/// (`\begin{frame}[opts]{Title}`) is checked first; an absent or empty shorthand
248/// falls through to an explicit `\frametitle[short]{Long}` in the body.
249fn 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
255/// Find the first `\frametitle` below `node` and return its long-title argument.
256fn 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
263/// Fold the flat `(level, item)` stream into the sectioning hierarchy. `end_bound`
264/// is the closing offset of the enclosing scope (document or environment body),
265/// used to stretch sections still open at the end.
266fn nest_sections(raws: Vec<Raw>, end_bound: TextSize) -> Vec<OutlineItem> {
267    let mut roots: Vec<OutlineItem> = Vec::new();
268    // (level, the section being accumulated) — innermost on top.
269    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                // A section at this level (or a shallower one) closes every open
276                // section it is not nested under; each ends where this one begins.
277                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            // A float/theorem/label sits inside the deepest open section.
285            None => attach(&mut roots, &mut stack, raw.item),
286        }
287    }
288
289    // Drain still-open sections; they run to the end of the enclosing scope.
290    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
297/// Attach `item` to the deepest open section, or to the roots when none is open.
298fn 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
305/// The display title of a sectioning command: the first `{…}` argument (the
306/// `[opt]` short title is skipped by [`nth_group`]). Falls back to the raw inner
307/// source when the title holds nested macros, and to `None` when there is no title
308/// group at all (the caller substitutes the command name).
309fn section_title(command: &SyntaxNode) -> Option<String> {
310    title_argument(command, 0).map(|(title, _)| title)
311}
312
313/// A trimmed, nonempty title from the `n`-th braced argument, retaining nested
314/// macro source when the group is not flat, together with the group range.
315fn 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/// What a `\label` at some offset labels: the classification driving label
325/// hover (kind + nearest heading/caption). Like [`OutlineItem`], LSP-agnostic
326/// and classified from the curated [`signature::builtin`] DB only.
327#[derive(Debug, Clone, PartialEq, Eq)]
328pub enum LabelContext {
329    /// Directly under a sectioning command; `title` is its heading text.
330    Section { title: String },
331    /// Inside a float environment (`figure`, `table`, …); `caption` is the
332    /// text of a `\caption` in that float, when present.
333    Float {
334        env: String,
335        caption: Option<String>,
336    },
337    /// Inside a theorem-like environment; `description` is the optional
338    /// `\begin{thm}[…]` argument, when present.
339    Theorem {
340        env: String,
341        description: Option<String>,
342    },
343    /// Inside math (`$…$`, `\[…\]`, or a math environment).
344    Equation,
345    /// Inside a list environment (`enumerate`, `itemize`, …).
346    Item,
347}
348
349/// Classify the `\label` whose key sits at `offset`: the innermost classifying
350/// ancestor wins (float/theorem/math/list environment), matching how the label
351/// resolves in TeX; a label in plain prose falls back to the nearest *preceding*
352/// sectioning command. `None` in unclassifiable plain text before any section.
353pub 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(&current),
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    // Plain prose: the label belongs to the current sectioning unit — the last
400    // sectioning command starting before the label.
401    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(&section)
422        .or_else(|| command_name(&section).map(|name| name.to_string()))
423        .unwrap_or_default();
424    Some(LabelContext::Section { title })
425}
426
427/// The text of the first `\caption` inside `env`, via the same first-group
428/// extraction as [`section_title`].
429fn 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
436/// The inner text of a `\begin{…}[…]` optional argument (its `OPTIONAL` child,
437/// brackets stripped), trimmed; `None` when absent or empty.
438fn 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}