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_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        // 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        .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/// 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).map(|name| name.to_string()))
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    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}