1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum DocKind {
32 Macro,
34 Environment,
36 DescribeMacro,
38 DescribeEnv,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct DocAssociation {
46 pub name: String,
49 pub kind: DocKind,
50 pub range: TextRange,
52 pub name_range: TextRange,
54 pub code: Vec<TextRange>,
58}
59
60pub fn doc_associations(root: &SyntaxNode) -> Vec<DocAssociation> {
62 let mut out = Vec::new();
63 collect(root, &mut out);
64 out
65}
66
67fn 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
80fn collect_environment(env: &SyntaxNode, out: &mut Vec<DocAssociation>) {
84 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 collect(env, out);
113}
114
115fn 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 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
155fn 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 let text = group_inner_text(group)?;
169 let text = text.trim();
170 (!text.is_empty()).then(|| text.to_owned())
171 }
172 }
173}
174
175fn 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
194fn 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 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 let code = &src[items[0].code[0]];
246 assert!(code.starts_with("\\begin{macrocode}"));
247 assert!(code.contains("\\def\\foo{x}"));
248 }
249
250 #[test]
251 fn environment_env_documents_a_plain_name() {
252 let items = assoc_of("% \\begin{environment}{myenv}\n% docs.\n% \\end{environment}\n");
253 assert_eq!(items.len(), 1);
254 assert_eq!(items[0].name, "myenv");
255 assert_eq!(items[0].kind, DocKind::Environment);
256 }
257
258 #[test]
259 fn describe_macro_braced() {
260 let items = assoc_of("% \\DescribeMacro{\\foo} does foo.\n");
261 assert_eq!(items.len(), 1);
262 assert_eq!(items[0].name, "\\foo");
263 assert_eq!(items[0].kind, DocKind::DescribeMacro);
264 assert!(items[0].code.is_empty());
265 }
266
267 #[test]
268 fn describe_macro_braceless() {
269 let items = assoc_of("% \\DescribeMacro\\foo does foo.\n");
270 assert_eq!(items.len(), 1);
271 assert_eq!(items[0].name, "\\foo");
272 assert_eq!(items[0].kind, DocKind::DescribeMacro);
273 }
274
275 #[test]
276 fn describe_env() {
277 let items = assoc_of("% \\DescribeEnv{myenv} is an env.\n");
278 assert_eq!(items.len(), 1);
279 assert_eq!(items[0].name, "myenv");
280 assert_eq!(items[0].kind, DocKind::DescribeEnv);
281 }
282
283 #[test]
284 fn nested_macro_envs_both_surface_with_own_code() {
285 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";
286 let items = assoc_of(src);
287 let names: Vec<_> = items.iter().map(|i| i.name.as_str()).collect();
288 assert_eq!(names, vec!["\\outer", "\\inner"]);
289 let outer = &items[0];
290 assert_eq!(outer.code.len(), 1);
291 assert!(src[outer.code[0]].contains("\\def\\outer{o}"));
292 let inner = &items[1];
293 assert_eq!(inner.code.len(), 1);
294 assert!(src[inner.code[0]].contains("\\def\\inner{i}"));
295 }
296
297 #[test]
298 fn empty_or_nested_macro_name_is_skipped() {
299 assert!(assoc_of("% \\begin{macro}{}\n% \\end{macro}\n").is_empty());
300 assert!(assoc_of("% \\DescribeEnv{\\foo}\n").is_empty());
301 }
302
303 #[test]
304 fn non_ltxdoc_constructs_are_ignored() {
305 let items =
306 assoc_of("% \\section{Intro}\n% \\begin{itemize}\n% \\item x\n% \\end{itemize}\n");
307 assert!(items.is_empty());
308 }
309}