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 float/theorem environments, `\label`s, and a
3//! `.dtx`'s documented macros/environments (via [`doc_associations`]) as leaves.
4//! LSP-agnostic by design (byte ranges, no `lsp_types`) so it is
5//! unit-testable without the language server; the `lsp` module converts the
6//! [`OutlineItem`] tree into `lsp_types::DocumentSymbol`.
7//!
8//! Classification reads the built-in [`signature`] DB: a command's
9//! [`sectioning`](signature::CommandSig::sectioning) level and an environment's
10//! [`outline`](signature::EnvironmentSig::outline) category. User-defined
11//! sectioning commands are out of scope here — sectioning is a static, standard
12//! set — so we consult [`signature::builtin`] directly rather than the scanned
13//! two-tier [`signature::Signatures`].
14//!
15//! Two passes. [`collect`] walks the CST in document order into a flat list of
16//! `(level, item)` pairs: sectioning commands carry their level, while floats,
17//! theorems, and labels carry `None`. Non-outline environments (`document`,
18//! `itemize`, …) are *transparent* — their contents splice into the parent stream
19//! so a `\label` inside `itemize` still surfaces. [`nest_sections`] then folds the
20//! flat list into the hierarchy with a level stack (sectioning commands are CST
21//! siblings; nesting is implied by level, not tree shape), attaching each
22//! non-section item to the deepest open section and stretching each section's
23//! range to where it closes.
24
25use 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/// The kind of an outline entry, driving the LSP `SymbolKind` mapping.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum OutlineSymbol {
38    /// A sectioning command (`\section`, `\subsection`, …).
39    Section,
40    /// A float environment (`figure`, `table`).
41    Float,
42    /// A theorem-like environment (`theorem`, `lemma`, `proof`, …).
43    Theorem,
44    /// A `\label{…}` definition.
45    Label,
46    /// A documented `.dtx` macro: a `macro` environment or `\DescribeMacro`.
47    Macro,
48    /// A documented `.dtx` environment: an `environment` environment or
49    /// `\DescribeEnv`.
50    Environment,
51}
52
53/// One node in the document-symbol outline tree.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct OutlineItem {
56    /// Display name: a section title, an environment name, or a label key.
57    pub name: String,
58    pub kind: OutlineSymbol,
59    /// The full extent of the symbol (a section spans to where it closes).
60    pub range: TextRange,
61    /// The identifier sub-range to highlight on selection (always `⊆ range`).
62    pub selection_range: TextRange,
63    pub children: Vec<OutlineItem>,
64}
65
66/// Build the outline tree for `root`.
67///
68/// The sectioning/float/label stream from [`collect`] is merged with the `.dtx`
69/// documentation constructs from [`doc_associations`], re-sorted into document
70/// order, then folded into the hierarchy by [`nest_sections`] — so a documented
71/// macro nests under its enclosing section like any other leaf. (A doc construct
72/// nested *inside* a float surfaces as a top-level sibling rather than under that
73/// float, since [`collect_environment`] pre-nests float children from its own walk;
74/// real `.dtx` files don't put doc constructs inside floats.)
75pub fn outline(root: &SyntaxNode) -> Vec<OutlineItem> {
76    let mut raws = collect(root);
77    raws.extend(doc_associations(root).into_iter().map(doc_raw));
78    // Stable sort keeps co-located items (e.g. a section and a label at the same
79    // offset) in their original relative order.
80    raws.sort_by_key(|raw| raw.item.range.start());
81    nest_sections(raws, root.text_range().end())
82}
83
84/// Convert a `.dtx` [`DocAssociation`] into a leaf `Raw` (never a section, so
85/// `level: None`); [`nest_sections`] then attaches it to the deepest open section.
86fn 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
102/// A collected item plus, for a sectioning command, its nesting level.
103struct Raw {
104    level: Option<u8>,
105    item: OutlineItem,
106}
107
108/// Walk `node`'s children in document order, producing the flat `(level, item)`
109/// stream. Recurses into transparent environments and other container nodes so
110/// nested labels/floats/sections surface; outline environments (float/theorem)
111/// become a single item whose own children are nested independently.
112fn 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            // Any other container (PARAGRAPH, GROUP, BEGIN, END, …): recurse so a
119            // command or environment nested inside still reaches the stream.
120            _ => out.extend(collect(&child)),
121        }
122    }
123    out
124}
125
126/// Emit a sectioning or `\label` item for a `COMMAND` node, if it is one.
127fn collect_command(command: &SyntaxNode, out: &mut Vec<Raw>) {
128    let Some(name) = command_name(command) else {
129        return;
130    };
131
132    // Curated [`signature::builtin`] only — never the bulk CWL tier: the symbol
133    // outline is a curated judgment, and CWL's sectioning classifications are not
134    // trustworthy enough to drive it (the CWL tier carries no `sectioning` anyway).
135    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        // Skip an empty or nested-macro key (`\label{\foo}`), matching the
154        // semantic model's conservative collection.
155        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
174/// Emit a float/theorem item for an `ENVIRONMENT` node, or splice its contents in
175/// transparently when it is not outline-worthy.
176fn collect_environment(env: &SyntaxNode, out: &mut Vec<Raw>) {
177    // The name lives in the `\begin{name}` (`BEGIN` node), not on `ENVIRONMENT`.
178    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        // Transparent: hoist any inner sections/floats/labels into the parent.
188        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            // An inner `\label`/nested environment nests under the float; nest the
208            // body independently against the environment's end.
209            children: nest_sections(collect(env), env.text_range().end()),
210        },
211    });
212}
213
214/// Fold the flat `(level, item)` stream into the sectioning hierarchy. `end_bound`
215/// is the closing offset of the enclosing scope (document or environment body),
216/// used to stretch sections still open at the end.
217fn nest_sections(raws: Vec<Raw>, end_bound: TextSize) -> Vec<OutlineItem> {
218    let mut roots: Vec<OutlineItem> = Vec::new();
219    // (level, the section being accumulated) — innermost on top.
220    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                // A section at this level (or a shallower one) closes every open
227                // section it is not nested under; each ends where this one begins.
228                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            // A float/theorem/label sits inside the deepest open section.
236            None => attach(&mut roots, &mut stack, raw.item),
237        }
238    }
239
240    // Drain still-open sections; they run to the end of the enclosing scope.
241    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
248/// Attach `item` to the deepest open section, or to the roots when none is open.
249fn 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
256/// The display title of a sectioning command: the first `{…}` argument (the
257/// `[opt]` short title is skipped by [`nth_group`]). Falls back to the raw inner
258/// source when the title holds nested macros, and to `None` when there is no title
259/// group at all (the caller substitutes the command name).
260fn 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/// What a `\label` at some offset labels: the classification driving label
270/// hover (kind + nearest heading/caption). Like [`OutlineItem`], LSP-agnostic
271/// and classified from the curated [`signature::builtin`] DB only.
272#[derive(Debug, Clone, PartialEq, Eq)]
273pub enum LabelContext {
274    /// Directly under a sectioning command; `title` is its heading text.
275    Section { title: String },
276    /// Inside a float environment (`figure`, `table`, …); `caption` is the
277    /// text of a `\caption` in that float, when present.
278    Float {
279        env: String,
280        caption: Option<String>,
281    },
282    /// Inside a theorem-like environment; `description` is the optional
283    /// `\begin{thm}[…]` argument, when present.
284    Theorem {
285        env: String,
286        description: Option<String>,
287    },
288    /// Inside math (`$…$`, `\[…\]`, or a math environment).
289    Equation,
290    /// Inside a list environment (`enumerate`, `itemize`, …).
291    Item,
292}
293
294/// Classify the `\label` whose key sits at `offset`: the innermost classifying
295/// ancestor wins (float/theorem/math/list environment), matching how the label
296/// resolves in TeX; a label in plain prose falls back to the nearest *preceding*
297/// sectioning command. `None` in unclassifiable plain text before any section.
298pub 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(&current),
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    // Plain prose: the label belongs to the current sectioning unit — the last
344    // sectioning command starting before the label.
345    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(&section)
366        .or_else(|| command_name(&section))
367        .unwrap_or_default();
368    Some(LabelContext::Section { title })
369}
370
371/// The text of the first `\caption` inside `env`, via the same first-group
372/// extraction as [`section_title`].
373fn 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
380/// The inner text of a `\begin{…}[…]` optional argument (its `OPTIONAL` child,
381/// brackets stripped), trimmed; `None` when absent or empty.
382fn 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    /// Parse in `.dtx` (docstrip) mode so the leading-`%` ltxdoc lines become real
404    /// `macro`/`environment`/`\DescribeMacro` constructs, then build the outline.
405    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        // `itemize` is transparent — the label surfaces under the section, not
490        // under an itemize node.
491        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    /// Classify the label at the offset of `\label` in `src`.
511    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        // Innermost classifying ancestor wins.
592        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}