Skip to main content

badness_parser/semantic/
doc.rs

1//! Associate `.dtx` documentation prose with the macro/environment it documents.
2//!
3//! A `.dtx` brackets each documented entity with ltxdoc vocabulary — a `macro` or
4//! `environment` environment, or a `\DescribeMacro`/`\DescribeEnv` command — while
5//! the implementation lives in a (usually nested) `macrocode` block. The parser
6//! keeps these apart on purpose: a documentation margin is `DOC_MARGIN` trivia that
7//! never binds into a `DOC_COMMENT` (AGENTS.md decision #9 forbids a signature
8//! lookup in the trivia-binding decision). Connecting prose to code is therefore a
9//! *semantic*-layer job — this query.
10//!
11//! It mirrors [`outline`](super::outline): a single CST walk producing LSP-agnostic
12//! [`DocAssociation`]s (byte ranges, no `lsp_types`), unit-testable without the
13//! language server. The ltxdoc set is static and standard, so — like the sectioning
14//! commands and `\label` in [`outline`](super::outline) — the constructs are
15//! recognized by name rather than through a per-document signature scan.
16//!
17//! The implementation a documented macro brackets is found *structurally*: the code
18//! is the `macrocode`/`macrocode*` block(s) nested inside the documenting
19//! environment, the conventional `.dtx` idiom. Descent stops at a nested `macro`/
20//! `environment` so its `macrocode` is attributed to it, not the outer construct.
21//! `\DescribeMacro`/`\DescribeEnv` carry no nested code (the definition lives
22//! elsewhere; file-wide def-site linking is a deferred follow-up).
23
24use rowan::TextRange;
25
26use crate::ast::{
27    AstNode, Begin, Environment, command_name, first_group_range, group_command_name, nth_group,
28};
29use crate::syntax::{SyntaxKind, SyntaxNode};
30
31/// Which ltxdoc construct introduced the association.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum DocKind {
34    /// A `\begin{macro}{\foo}…\end{macro}` documentation environment.
35    Macro,
36    /// A `\begin{environment}{name}…\end{environment}` documentation environment.
37    Environment,
38    /// A `\DescribeMacro{\foo}` (or `\DescribeMacro\foo`) command.
39    DescribeMacro,
40    /// A `\DescribeEnv{name}` command.
41    DescribeEnv,
42}
43
44/// One documented entity: the documenting construct, the name it documents, and any
45/// code it brackets.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct DocAssociation {
48    /// The documented name: `\foo` (backslash kept) for a macro, `tabular` for an
49    /// environment.
50    pub name: String,
51    pub kind: DocKind,
52    /// The full extent of the documenting construct.
53    pub range: TextRange,
54    /// The name-argument sub-range to highlight on selection (always `⊆ range`).
55    pub name_range: TextRange,
56    /// Ranges of the `macrocode`/`macrocode*` blocks nested inside the construct —
57    /// the implementation it documents. Empty for `\DescribeMacro`/`\DescribeEnv`
58    /// and for a prose-only `macro`/`environment` environment.
59    pub code: Vec<TextRange>,
60}
61
62/// Collect every documentation↔code association in `root`, in document order.
63pub fn doc_associations(root: &SyntaxNode) -> Vec<DocAssociation> {
64    let mut out = Vec::new();
65    collect(root, &mut out);
66    out
67}
68
69/// Walk `node`'s children in document order. `COMMAND`/`ENVIRONMENT` are classified;
70/// every other container is transparent (recursed into) so a documented construct
71/// nested inside still surfaces.
72fn collect(node: &SyntaxNode, out: &mut Vec<DocAssociation>) {
73    for child in node.children() {
74        match child.kind() {
75            SyntaxKind::COMMAND => collect_command(&child, out),
76            SyntaxKind::ENVIRONMENT => collect_environment(&child, out),
77            _ => collect(&child, out),
78        }
79    }
80}
81
82/// Emit an association for a `macro`/`environment` documentation environment, then
83/// recurse into its body so a nested documented construct also surfaces. A
84/// non-ltxdoc environment is transparent.
85fn collect_environment(env: &SyntaxNode, out: &mut Vec<DocAssociation>) {
86    // The name and the documented-entity argument both live on the `\begin` node.
87    let begin = Environment::cast(env.clone()).and_then(|e| e.begin());
88    let kind = begin
89        .as_ref()
90        .and_then(Begin::name)
91        .and_then(|name| match name.as_str() {
92            "macro" => Some(DocKind::Macro),
93            "environment" => Some(DocKind::Environment),
94            _ => None,
95        });
96
97    if let (Some(kind), Some(begin)) = (kind, begin.as_ref())
98        && let Some(group) = nth_group(begin.syntax(), 0)
99        && let Some(name) = documented_name(&group, kind)
100    {
101        let mut code = Vec::new();
102        collect_code(env, &mut code);
103        out.push(DocAssociation {
104            name,
105            kind,
106            range: env.text_range(),
107            name_range: group.text_range(),
108            code,
109        });
110    }
111
112    // Recurse regardless: nested `macro` envs surface as their own (flat)
113    // associations, and a non-ltxdoc environment is transparent.
114    collect(env, out);
115}
116
117/// Emit an association for a `\DescribeMacro`/`\DescribeEnv` command.
118fn collect_command(command: &SyntaxNode, out: &mut Vec<DocAssociation>) {
119    let Some(cmd) = command_name(command) else {
120        return;
121    };
122    let kind = match cmd.as_str() {
123        "DescribeMacro" => DocKind::DescribeMacro,
124        "DescribeEnv" => DocKind::DescribeEnv,
125        _ => return,
126    };
127
128    // The braced form `\DescribeMacro{\foo}` carries the name as the first group;
129    // the conventional braceless form `\DescribeMacro\foo` carries it as the next
130    // sibling command (control words are not greedily attached as arguments, so the
131    // macro is a sibling — AGENTS.md decision #8).
132    if let Some(group) = nth_group(command, 0) {
133        if let Some(name) = documented_name(&group, kind) {
134            out.push(DocAssociation {
135                name,
136                kind,
137                range: first_group_range(command),
138                name_range: group.text_range(),
139                code: Vec::new(),
140            });
141        }
142    } else if kind == DocKind::DescribeMacro
143        && let Some(sib) = command.next_sibling()
144        && sib.kind() == SyntaxKind::COMMAND
145        && let Some(name) = command_name(&sib)
146    {
147        out.push(DocAssociation {
148            name: format!("\\{name}"),
149            kind,
150            range: TextRange::new(command.text_range().start(), sib.text_range().end()),
151            name_range: sib.text_range(),
152            code: Vec::new(),
153        });
154    }
155}
156
157/// The documented name carried by a braced argument `group`: a macro's control word
158/// (re-prefixed with `\`) for the macro forms, or the trimmed literal for the
159/// environment forms. `None` when the argument is empty or holds nested macros —
160/// matching [`outline`](super::outline)'s conservative `\label` handling.
161fn documented_name(group: &SyntaxNode, kind: DocKind) -> Option<String> {
162    match kind {
163        DocKind::Macro | DocKind::DescribeMacro => {
164            group_command_name(group).map(|name| format!("\\{name}"))
165        }
166        DocKind::Environment | DocKind::DescribeEnv => {
167            // The env name is a flat literal. `crate::ast::nth_group_text` does this
168            // but keys on a parent + index; we already hold the group node, so read
169            // it directly via the group-level twin below.
170            let text = group_inner_text(group)?;
171            let text = text.trim();
172            (!text.is_empty()).then(|| text.to_owned())
173        }
174    }
175}
176
177/// The flat literal text inside `group`, braces dropped. `None` if it holds a nested
178/// node (a `\macro`) or a parameter token (`#1`) — not a flat literal. Mirrors
179/// [`crate::ast::nth_group_text`], which operates on a parent + index rather than a
180/// group node.
181fn group_inner_text(group: &SyntaxNode) -> Option<String> {
182    let mut text = String::new();
183    for element in group.children_with_tokens() {
184        match element {
185            rowan::NodeOrToken::Token(token) => match token.kind() {
186                SyntaxKind::L_BRACE | SyntaxKind::R_BRACE => {}
187                SyntaxKind::HASH => return None,
188                _ => text.push_str(token.text()),
189            },
190            rowan::NodeOrToken::Node(_) => return None,
191        }
192    }
193    Some(text)
194}
195
196/// Collect the ranges of `macrocode`/`macrocode*` environments nested in `node`,
197/// not descending into a nested `macro`/`environment` (whose code belongs to it).
198fn collect_code(node: &SyntaxNode, out: &mut Vec<TextRange>) {
199    for child in node.children() {
200        if child.kind() == SyntaxKind::ENVIRONMENT {
201            let name = Environment::cast(child.clone())
202                .and_then(|e| e.begin())
203                .and_then(|b| b.name());
204            match name.as_deref() {
205                Some("macrocode" | "macrocode*") => out.push(child.text_range()),
206                // A nested documented construct owns its own code; stop here.
207                Some("macro" | "environment") => {}
208                _ => collect_code(&child, out),
209            }
210        } else {
211            collect_code(&child, out);
212        }
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use crate::parser::{LatexFlavor, LexConfig, parse_with_flavor};
220
221    fn assoc_of(src: &str) -> Vec<DocAssociation> {
222        let config = LexConfig {
223            flavor: LatexFlavor::Document,
224            dtx: true,
225        };
226        let parsed = parse_with_flavor(src, config);
227        assert_eq!(parsed.syntax().to_string(), src, "losslessness violated");
228        doc_associations(&parsed.syntax())
229    }
230
231    #[test]
232    fn macro_env_documents_its_name() {
233        let items = assoc_of("% \\begin{macro}{\\foo}\n% docs.\n% \\end{macro}\n");
234        assert_eq!(items.len(), 1);
235        assert_eq!(items[0].name, "\\foo");
236        assert_eq!(items[0].kind, DocKind::Macro);
237        assert!(items[0].code.is_empty());
238    }
239
240    #[test]
241    fn nested_macrocode_is_the_code() {
242        let src = "% \\begin{macro}{\\foo}\n% docs.\n%    \\begin{macrocode}\n\\def\\foo{x}\n%    \\end{macrocode}\n% \\end{macro}\n";
243        let items = assoc_of(src);
244        assert_eq!(items.len(), 1);
245        assert_eq!(items[0].name, "\\foo");
246        assert_eq!(items[0].code.len(), 1);
247        // The recorded range is the `macrocode` environment.
248        let code = &src[items[0].code[0]];
249        assert!(code.starts_with("\\begin{macrocode}"));
250        assert!(code.contains("\\def\\foo{x}"));
251    }
252
253    #[test]
254    fn environment_env_documents_a_plain_name() {
255        let items = assoc_of("% \\begin{environment}{myenv}\n% docs.\n% \\end{environment}\n");
256        assert_eq!(items.len(), 1);
257        assert_eq!(items[0].name, "myenv");
258        assert_eq!(items[0].kind, DocKind::Environment);
259    }
260
261    #[test]
262    fn describe_macro_braced() {
263        let items = assoc_of("% \\DescribeMacro{\\foo} does foo.\n");
264        assert_eq!(items.len(), 1);
265        assert_eq!(items[0].name, "\\foo");
266        assert_eq!(items[0].kind, DocKind::DescribeMacro);
267        assert!(items[0].code.is_empty());
268    }
269
270    #[test]
271    fn describe_macro_braceless() {
272        // The conventional doctools form takes its argument without braces.
273        let items = assoc_of("% \\DescribeMacro\\foo does foo.\n");
274        assert_eq!(items.len(), 1);
275        assert_eq!(items[0].name, "\\foo");
276        assert_eq!(items[0].kind, DocKind::DescribeMacro);
277    }
278
279    #[test]
280    fn describe_env() {
281        let items = assoc_of("% \\DescribeEnv{myenv} is an env.\n");
282        assert_eq!(items.len(), 1);
283        assert_eq!(items[0].name, "myenv");
284        assert_eq!(items[0].kind, DocKind::DescribeEnv);
285    }
286
287    #[test]
288    fn nested_macro_envs_both_surface_with_own_code() {
289        let src = "% \\begin{macro}{\\outer}\n%    \\begin{macrocode}\n\\def\\outer{o}\n%    \\end{macrocode}\n% \\begin{macro}{\\inner}\n%    \\begin{macrocode}\n\\def\\inner{i}\n%    \\end{macrocode}\n% \\end{macro}\n% \\end{macro}\n";
290        let items = assoc_of(src);
291        let names: Vec<_> = items.iter().map(|i| i.name.as_str()).collect();
292        assert_eq!(names, vec!["\\outer", "\\inner"]);
293        // The outer construct's code stops at the nested `macro`: it owns only its
294        // own `macrocode`, not the inner one.
295        let outer = &items[0];
296        assert_eq!(outer.code.len(), 1);
297        assert!(src[outer.code[0]].contains("\\def\\outer{o}"));
298        let inner = &items[1];
299        assert_eq!(inner.code.len(), 1);
300        assert!(src[inner.code[0]].contains("\\def\\inner{i}"));
301    }
302
303    #[test]
304    fn empty_or_nested_macro_name_is_skipped() {
305        assert!(assoc_of("% \\begin{macro}{}\n% \\end{macro}\n").is_empty());
306        // `{\foo\bar}` is not a single flat control word; environment name with a
307        // nested macro is likewise skipped.
308        assert!(assoc_of("% \\DescribeEnv{\\foo}\n").is_empty());
309    }
310
311    #[test]
312    fn non_ltxdoc_constructs_are_ignored() {
313        let items =
314            assoc_of("% \\section{Intro}\n% \\begin{itemize}\n% \\item x\n% \\end{itemize}\n");
315        assert!(items.is_empty());
316    }
317}