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