use rowan::TextRange;
use crate::ast::{
command_name, environment_name, first_group_range, group_command_name, nth_group,
};
use crate::syntax::{SyntaxKind, SyntaxNode};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DocKind {
Macro,
Environment,
DescribeMacro,
DescribeEnv,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DocAssociation {
pub name: String,
pub kind: DocKind,
pub range: TextRange,
pub name_range: TextRange,
pub code: Vec<TextRange>,
}
pub fn doc_associations(root: &SyntaxNode) -> Vec<DocAssociation> {
let mut out = Vec::new();
collect(root, &mut out);
out
}
fn collect(node: &SyntaxNode, out: &mut Vec<DocAssociation>) {
for child in node.children() {
match child.kind() {
SyntaxKind::COMMAND => collect_command(&child, out),
SyntaxKind::ENVIRONMENT => collect_environment(&child, out),
_ => collect(&child, out),
}
}
}
fn collect_environment(env: &SyntaxNode, out: &mut Vec<DocAssociation>) {
let begin = env.children().find(|c| c.kind() == SyntaxKind::BEGIN);
let kind = begin
.as_ref()
.and_then(environment_name)
.and_then(|name| match name.as_str() {
"macro" => Some(DocKind::Macro),
"environment" => Some(DocKind::Environment),
_ => None,
});
if let (Some(kind), Some(begin)) = (kind, begin.as_ref())
&& let Some(group) = nth_group(begin, 0)
&& let Some(name) = documented_name(&group, kind)
{
let mut code = Vec::new();
collect_code(env, &mut code);
out.push(DocAssociation {
name,
kind,
range: env.text_range(),
name_range: group.text_range(),
code,
});
}
collect(env, out);
}
fn collect_command(command: &SyntaxNode, out: &mut Vec<DocAssociation>) {
let Some(cmd) = command_name(command) else {
return;
};
let kind = match cmd.as_str() {
"DescribeMacro" => DocKind::DescribeMacro,
"DescribeEnv" => DocKind::DescribeEnv,
_ => return,
};
if let Some(group) = nth_group(command, 0) {
if let Some(name) = documented_name(&group, kind) {
out.push(DocAssociation {
name,
kind,
range: first_group_range(command),
name_range: group.text_range(),
code: Vec::new(),
});
}
} else if kind == DocKind::DescribeMacro
&& let Some(sib) = command.next_sibling()
&& sib.kind() == SyntaxKind::COMMAND
&& let Some(name) = command_name(&sib)
{
out.push(DocAssociation {
name: format!("\\{name}"),
kind,
range: TextRange::new(command.text_range().start(), sib.text_range().end()),
name_range: sib.text_range(),
code: Vec::new(),
});
}
}
fn documented_name(group: &SyntaxNode, kind: DocKind) -> Option<String> {
match kind {
DocKind::Macro | DocKind::DescribeMacro => {
group_command_name(group).map(|name| format!("\\{name}"))
}
DocKind::Environment | DocKind::DescribeEnv => {
let text = group_inner_text(group)?;
let text = text.trim();
(!text.is_empty()).then(|| text.to_owned())
}
}
}
fn group_inner_text(group: &SyntaxNode) -> Option<String> {
let mut text = String::new();
for element in group.children_with_tokens() {
match element {
rowan::NodeOrToken::Token(token) => match token.kind() {
SyntaxKind::L_BRACE | SyntaxKind::R_BRACE => {}
_ => text.push_str(token.text()),
},
rowan::NodeOrToken::Node(_) => return None,
}
}
Some(text)
}
fn collect_code(node: &SyntaxNode, out: &mut Vec<TextRange>) {
for child in node.children() {
if child.kind() == SyntaxKind::ENVIRONMENT {
let name = child
.children()
.find(|c| c.kind() == SyntaxKind::BEGIN)
.and_then(|b| environment_name(&b));
match name.as_deref() {
Some("macrocode" | "macrocode*") => out.push(child.text_range()),
Some("macro" | "environment") => {}
_ => collect_code(&child, out),
}
} else {
collect_code(&child, out);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::{LatexFlavor, LexConfig, parse_with_flavor};
fn assoc_of(src: &str) -> Vec<DocAssociation> {
let config = LexConfig {
flavor: LatexFlavor::Document,
dtx: true,
};
let parsed = parse_with_flavor(src, config);
assert_eq!(parsed.syntax().to_string(), src, "losslessness violated");
doc_associations(&parsed.syntax())
}
#[test]
fn macro_env_documents_its_name() {
let items = assoc_of("% \\begin{macro}{\\foo}\n% docs.\n% \\end{macro}\n");
assert_eq!(items.len(), 1);
assert_eq!(items[0].name, "\\foo");
assert_eq!(items[0].kind, DocKind::Macro);
assert!(items[0].code.is_empty());
}
#[test]
fn nested_macrocode_is_the_code() {
let src = "% \\begin{macro}{\\foo}\n% docs.\n% \\begin{macrocode}\n\\def\\foo{x}\n% \\end{macrocode}\n% \\end{macro}\n";
let items = assoc_of(src);
assert_eq!(items.len(), 1);
assert_eq!(items[0].name, "\\foo");
assert_eq!(items[0].code.len(), 1);
let code = &src[items[0].code[0]];
assert!(code.starts_with("\\begin{macrocode}"));
assert!(code.contains("\\def\\foo{x}"));
}
#[test]
fn environment_env_documents_a_plain_name() {
let items = assoc_of("% \\begin{environment}{myenv}\n% docs.\n% \\end{environment}\n");
assert_eq!(items.len(), 1);
assert_eq!(items[0].name, "myenv");
assert_eq!(items[0].kind, DocKind::Environment);
}
#[test]
fn describe_macro_braced() {
let items = assoc_of("% \\DescribeMacro{\\foo} does foo.\n");
assert_eq!(items.len(), 1);
assert_eq!(items[0].name, "\\foo");
assert_eq!(items[0].kind, DocKind::DescribeMacro);
assert!(items[0].code.is_empty());
}
#[test]
fn describe_macro_braceless() {
let items = assoc_of("% \\DescribeMacro\\foo does foo.\n");
assert_eq!(items.len(), 1);
assert_eq!(items[0].name, "\\foo");
assert_eq!(items[0].kind, DocKind::DescribeMacro);
}
#[test]
fn describe_env() {
let items = assoc_of("% \\DescribeEnv{myenv} is an env.\n");
assert_eq!(items.len(), 1);
assert_eq!(items[0].name, "myenv");
assert_eq!(items[0].kind, DocKind::DescribeEnv);
}
#[test]
fn nested_macro_envs_both_surface_with_own_code() {
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";
let items = assoc_of(src);
let names: Vec<_> = items.iter().map(|i| i.name.as_str()).collect();
assert_eq!(names, vec!["\\outer", "\\inner"]);
let outer = &items[0];
assert_eq!(outer.code.len(), 1);
assert!(src[outer.code[0]].contains("\\def\\outer{o}"));
let inner = &items[1];
assert_eq!(inner.code.len(), 1);
assert!(src[inner.code[0]].contains("\\def\\inner{i}"));
}
#[test]
fn empty_or_nested_macro_name_is_skipped() {
assert!(assoc_of("% \\begin{macro}{}\n% \\end{macro}\n").is_empty());
assert!(assoc_of("% \\DescribeEnv{\\foo}\n").is_empty());
}
#[test]
fn non_ltxdoc_constructs_are_ignored() {
let items =
assoc_of("% \\section{Intro}\n% \\begin{itemize}\n% \\item x\n% \\end{itemize}\n");
assert!(items.is_empty());
}
}