use super::*;
fn block_md(block: &RoxygenBlock) -> bool {
let mut md = false;
for section in block.sections() {
if let Some(tag) = section.tag()
&& tag.arg().is_none()
&& tag.text().is_none()
{
match tag.name().as_deref() {
Some("md") => md = true,
Some("noMd") => md = false,
_ => {}
}
}
}
md
}
pub(super) fn project_block(block: &RoxygenBlock, out: &mut Vec<String>) {
project_block_impl(block, out, true);
}
pub(super) fn project_block_impl(
block: &RoxygenBlock,
out: &mut Vec<String>,
apply_title_fallback: bool,
) {
let md = block_md(block);
let block_start = out.len();
let mut intro_paras: Vec<Vec<Inline>> = Vec::new();
let mut tag_sections: Vec<(String, Vec<Inline>)> = Vec::new();
let mut slots: Vec<(String, Vec<Inline>)> = Vec::new();
let mut fields: Vec<(String, Vec<Inline>)> = Vec::new();
let mut has_examples = false;
for section in block.sections() {
if let Some(tag) = section.tag() {
let name = tag.name().map(|n| n.to_string()).unwrap_or_default();
let mut body = tag_inlines(&tag);
for part in section_body_parts(§ion) {
if !body.is_empty() && !matches!(part.first(), Some(Inline::MdBlockQuote(_))) {
body.push(Inline::Text("\n".to_string()));
}
body.extend(part);
}
match name.as_str() {
"slot" | "field" => {
if !rd_complete(§ion.syntax().text().to_string()) {
continue;
}
let arg = tag.arg().map(|t| t.text().to_string()).unwrap_or_default();
if name == "slot" {
slots.push((arg, body));
} else {
fields.push((arg, body));
}
}
"section" if !md && !rd_complete(§ion.syntax().text().to_string()) => {
out.push("(\\section (TEXT \"NA\"))".to_string());
}
"examples" | "examplesIf" => has_examples = true,
_ => tag_sections.push((name, body)),
}
} else {
intro_paras.extend(section_body_parts(§ion));
}
}
let has_explicit_title = tag_sections
.iter()
.any(|(n, b)| n == "title" && !is_null_section(b, md));
let has_explicit_desc = tag_sections
.iter()
.any(|(n, b)| n == "description" && !is_null_section(b, md));
let explicit_title_body = tag_sections
.iter()
.find(|(n, b)| n == "title" && !is_null_section(b, md))
.map(|(_, b)| b.clone());
let mut cursor = 0usize;
let intro_title = if has_explicit_title {
None
} else {
intro_paras.get(cursor).inspect(|_| cursor += 1).cloned()
};
let intro_desc = if has_explicit_desc {
None
} else {
intro_paras.get(cursor).inspect(|_| cursor += 1).cloned()
};
let intro_details = &intro_paras[cursor..];
let merge_details = !intro_details.is_empty();
if let Some(title) = &intro_title {
push_section(out, "title", title, md, false);
}
let description = match intro_desc {
Some(d) => Some(d),
None if has_explicit_desc => None, None if !apply_title_fallback => None, None => intro_title.clone().or_else(|| explicit_title_body.clone()),
};
if let Some(description) = description {
emit_section_with_headings(out, "description", &description, md, true);
}
if merge_details {
let mut body = join_paras(intro_details);
for (_, ed) in tag_sections.iter().filter(|(n, _)| n == "details") {
body.push(Inline::Text("\n".to_string()));
body.extend(join_paras(std::slice::from_ref(ed)));
}
emit_section_with_headings(out, "details", &body, md, true);
}
for (name, body) in &tag_sections {
if merge_details && name == "details" {
continue;
}
project_tag_section(name, body, out, md);
}
if apply_title_fallback
&& !out.iter().any(|s| s.starts_with("(\\description"))
&& let Some(title) = intro_title.as_ref().or(explicit_title_body.as_ref())
{
emit_section_with_headings(out, "description", title, md, true);
}
if !slots.is_empty() {
out.push(describe_section("Slots", &slots, md));
}
if !fields.is_empty() {
out.push(describe_section("Fields", &fields, md));
}
if has_examples {
out.push("(\\examples ...)".to_string());
}
if md {
for section in &mut out[block_start..] {
*section = resolve_md_text_braces(section);
}
}
}
const COLLAPSE_HEADS: &[&str] = &[
"description",
"details",
"seealso",
"value",
"note",
"references",
"author",
"format",
"source",
];
pub(super) fn project_merged_topic(blocks: &[RoxygenBlock], out: &mut Vec<String>) {
let per_block: Vec<Vec<String>> = blocks
.iter()
.map(|b| {
let mut v = Vec::new();
project_block_impl(b, &mut v, false);
v
})
.collect();
let mut by_head: Vec<(String, Vec<String>)> = Vec::new();
for s in per_block.iter().flatten() {
let head = section_head(s).to_string();
match by_head.iter_mut().find(|(h, _)| *h == head) {
Some((_, v)) => v.push(s.clone()),
None => by_head.push((head, vec![s.clone()])),
}
}
let mut has_description = false;
for (head, secs) in &by_head {
let bare = head.strip_prefix('\\').unwrap_or(head);
if head == "\\title" {
out.push(secs[0].clone());
} else if COLLAPSE_HEADS.contains(&bare) {
has_description |= head == "\\description";
out.push(collapse_sections(head, secs));
} else {
for s in secs {
if !out.contains(s) {
out.push(s.clone());
}
}
}
}
if !has_description && let Some((_, titles)) = by_head.iter().find(|(h, _)| h == "\\title") {
let inner = collapse_inners(&titles.iter().map(|s| section_inner(s)).collect::<Vec<_>>());
out.push(if inner.is_empty() {
"(\\description)".to_string()
} else {
format!("(\\description {inner})")
});
}
}
fn section_head(s: &str) -> &str {
let body = s.strip_prefix('(').unwrap_or(s);
let end = body.find([' ', ')']).unwrap_or(body.len());
&body[..end]
}
fn section_inner(s: &str) -> &str {
let core = &s[1..s.len().saturating_sub(1)];
core.split_once(' ').map(|(_, inner)| inner).unwrap_or("")
}
fn collapse_sections(head: &str, secs: &[String]) -> String {
let inner = collapse_inners(&secs.iter().map(|s| section_inner(s)).collect::<Vec<_>>());
if inner.is_empty() {
format!("({head})")
} else {
format!("({head} {inner})")
}
}
fn collapse_inners(inners: &[&str]) -> String {
let joined = inners
.iter()
.filter(|i| !i.is_empty())
.copied()
.collect::<Vec<_>>()
.join(" ");
coalesce_text_atoms(&joined)
}
fn coalesce_text_atoms(s: &str) -> String {
let mut out: Vec<String> = Vec::new();
for atom in split_top_level_atoms(s) {
if let Some(content) = text_atom_content(atom)
&& let Some(prev) = out.last().and_then(|l| text_atom_content(l))
{
let merged = format!("(TEXT \"{prev} {content}\")");
*out.last_mut().unwrap() = merged;
continue;
}
out.push(atom.to_string());
}
out.join(" ")
}
fn text_atom_content(atom: &str) -> Option<&str> {
atom.strip_prefix("(TEXT \"")?.strip_suffix("\")")
}
fn split_top_level_atoms(s: &str) -> Vec<&str> {
let bytes = s.as_bytes();
let mut atoms = Vec::new();
let mut i = 0;
while i < bytes.len() {
while i < bytes.len() && bytes[i] == b' ' {
i += 1;
}
if i >= bytes.len() {
break;
}
let start = i;
let mut depth = 0i32;
let mut in_str = false;
while i < bytes.len() {
match bytes[i] {
b'\\' if in_str => {
i += 1;
}
b'"' => in_str = !in_str,
b'(' if !in_str => depth += 1,
b')' if !in_str => {
depth -= 1;
if depth == 0 {
i += 1;
break;
}
}
_ => {}
}
i += 1;
}
atoms.push(&s[start..i]);
}
atoms
}
pub(super) fn describe_section(title: &str, items: &[(String, Vec<Inline>)], md: bool) -> String {
let mut item_atoms: Vec<String> = Vec::new();
for (name, def) in items {
let code_atoms = rcode_atoms(name);
let term = if code_atoms.is_empty() {
"(\\code)".to_string()
} else {
format!("(\\code {})", code_atoms.join(" "))
};
let mut parts = vec![term];
let def_arg = grp_arg(&serialize_prose_with_linkrefs(def, md));
if !def_arg.is_empty() {
parts.push(def_arg);
}
item_atoms.push(format!("(\\item {})", parts.join(" ")));
}
format!(
"(\\section (TEXT {}) (\\describe {}))",
encode_text(title),
item_atoms.join(" ")
)
}
pub(super) fn project_tag_section(name: &str, body: &[Inline], out: &mut Vec<String>, md: bool) {
if NULL_SUPPRESSIBLE.contains(&name) && is_null_section(body, md) {
return;
}
let swallowed = (!matches!(name, "rawRd" | "section"))
.then(|| split_sticky_braceless_swallow(body, md))
.flatten();
let body = swallowed.as_deref().unwrap_or(body);
match name {
"rawRd" => {
for atom in serialize_inlines(body, md) {
out.push(atom);
}
}
"description" => emit_section_with_headings(out, "description", body, md, true),
"details" => emit_section_with_headings(out, "details", body, md, true),
"return" => push_section(out, "value", body, md, false),
"seealso" => push_section(out, "seealso", body, md, false),
"source" => push_section(out, "source", body, md, false),
"format" => push_section(out, "format", body, md, false),
"references" => push_section(out, "references", body, md, false),
"note" => push_section(out, "note", body, md, false),
"author" => push_section(out, "author", body, md, false),
"title" => push_section(out, "title", body, md, false),
"section" => {
let (leaked, leak_defs) = if md {
let leaked = leaked_linkref_text(&leak_source_skeleton(body));
let mut defs = LinkDefs::new();
if !leaked.is_empty() {
collect_user_linkrefs_tree(body, &mut defs);
}
(leaked, defs)
} else {
(Vec::new(), LinkDefs::new())
};
let transformed = md
.then(|| resolve_linkrefs(body, &LinkDefs::new()))
.flatten();
let body = transformed.as_deref().unwrap_or(body);
let (heading, content) = split_section_title(body);
let mut title = serialize_inlines(&heading, md);
let mut content_atoms = serialize_inlines(&content, md);
if !leaked.is_empty() {
append_leaked_defs(&mut content_atoms, &leaked, &leak_defs);
}
trim_field_atoms_start(&mut title);
trim_field_atoms_end(&mut content_atoms);
let mut inner = grp_arg(&title);
if !content_atoms.is_empty() {
if !inner.is_empty() {
inner.push(' ');
}
inner.push_str(&grp_arg(&content_atoms));
}
out.push(format!("(\\section{})", prefix_space(&inner)));
}
_ => {}
}
}
const NULL_SUPPRESSIBLE: &[&str] = &[
"description",
"details",
"return",
"seealso",
"source",
"format",
"references",
"note",
"author",
"title",
];
fn is_null_section(body: &[Inline], md: bool) -> bool {
let atoms = serialize_inlines(body, md);
atoms.len() == 1 && atoms[0] == "(TEXT \"NULL\")"
}
pub(super) fn push_section(
out: &mut Vec<String>,
macro_name: &str,
body: &[Inline],
md: bool,
drop_on_incomplete: bool,
) {
push_section_seeded(
out,
macro_name,
body,
md,
drop_on_incomplete,
&LinkDefs::new(),
);
}
fn push_section_seeded(
out: &mut Vec<String>,
macro_name: &str,
body: &[Inline],
md: bool,
drop_on_incomplete: bool,
seed: &LinkDefs,
) {
let mut atoms = serialize_prose_seeded(body, md, seed);
trim_field_atoms(&mut atoms);
let check_drop = if md { drop_on_incomplete } else { true };
if check_drop && !section_rd_complete_seeded(body, md, seed) {
out.push(format!("(\\{macro_name})"));
return;
}
if atoms.is_empty() {
out.push(format!("(\\{macro_name})"));
} else {
out.push(format!("(\\{macro_name} {})", atoms.join(" ")));
}
}
pub(super) struct HeadingFrame {
pub(super) level: usize,
pub(super) title: Vec<Inline>,
pub(super) body: Vec<Inline>,
pub(super) children: Vec<usize>,
}
pub(super) fn emit_section_with_headings(
out: &mut Vec<String>,
macro_name: &str,
body: &[Inline],
md: bool,
drop_on_incomplete: bool,
) {
if emit_section_with_list_hoist(out, macro_name, body, md, drop_on_incomplete) {
return;
}
if let Some(pos) = body
.iter()
.rposition(|inl| matches!(inl, Inline::MdHeading(_)))
{
let Inline::MdHeading(node) = &body[pos] else {
unreachable!("rposition matched MdHeading");
};
let (level, _) = parse_md_heading(node);
if level == 1
&& !(md && setext_title_strip(node).is_some_and(|s| s.title.is_none()))
&& serialize_prose_with_linkrefs(&body[pos + 1..], md).is_empty()
&& let Some(atoms) = section_raw_fallback_atoms(macro_name, node)
{
out.extend(atoms);
return;
}
}
struct Seg {
head: Option<(usize, Vec<Inline>)>,
run: Vec<Inline>,
}
let mut field_defs = LinkDefs::new();
let mut segments: Vec<Seg> = vec![Seg {
head: None,
run: Vec::new(),
}];
for inl in body {
let Inline::MdHeading(node) = inl else {
segments.last_mut().unwrap().run.push(inl.clone());
continue;
};
collect_user_linkrefs_tree(&segments.last().unwrap().run, &mut field_defs);
if md && let Some(strip) = setext_title_strip(node) {
for (label, def) in strip.defs {
field_defs.entry(label).or_insert(def);
}
match strip.title {
Some(title) => segments.push(Seg {
head: Some((strip.level, title)),
run: Vec::new(),
}),
None => segments
.last_mut()
.unwrap()
.run
.push(Inline::Text(strip.underline)),
}
continue;
}
let (level, title_text) = parse_md_heading(node);
segments.push(Seg {
head: Some((level, resolve_macro_arg_inlines(&title_text))),
run: Vec::new(),
});
}
collect_user_linkrefs_tree(&segments.last().unwrap().run, &mut field_defs);
if segments.len() == 1 {
push_section_seeded(
out,
macro_name,
&segments[0].run,
md,
drop_on_incomplete,
&field_defs,
);
return;
}
let mut frames: Vec<HeadingFrame> = vec![HeadingFrame {
level: 0,
title: Vec::new(),
body: std::mem::take(&mut segments[0].run),
children: Vec::new(),
}];
let mut stack = vec![0usize];
for seg in segments.into_iter().skip(1) {
let (level, title) = seg
.head
.expect("a non-leading segment always carries a heading");
while frames[*stack.last().unwrap()].level >= level {
stack.pop();
}
let parent = *stack.last().unwrap();
let idx = frames.len();
frames.push(HeadingFrame {
level,
title,
body: seg.run,
children: Vec::new(),
});
frames[parent].children.push(idx);
stack.push(idx);
}
let has_section = frames[0].children.iter().any(|&c| frames[c].level == 1);
if drop_on_incomplete && !heading_piece_complete(&frames, 0, md, &field_defs) {
if !has_section {
out.push(format!("(\\{macro_name})"));
}
} else {
let mut inner = serialize_prose_seeded(&frames[0].body, md, &field_defs);
for &c in &frames[0].children {
if frames[c].level >= 2 {
inner.push(render_heading_frame(
&frames,
c,
md,
"subsection",
&field_defs,
));
}
}
trim_field_atoms(&mut inner);
if !inner.is_empty() {
out.push(format!("(\\{macro_name} {})", inner.join(" ")));
}
}
for &c in &frames[0].children {
if frames[c].level != 1 {
continue;
}
if drop_on_incomplete && !heading_piece_complete(&frames, c, md, &field_defs) {
let title = grp_arg(&frame_title_atoms(&frames[c], md, &field_defs, true));
out.push(format!("(\\section{})", prefix_space(&title)));
} else {
out.push(render_heading_frame(&frames, c, md, "section", &field_defs));
}
}
}
struct SetextStrip {
level: usize,
defs: LinkDefs,
title: Option<Vec<Inline>>,
underline: String,
}
fn setext_title_strip(node: &SyntaxNode) -> Option<SetextStrip> {
let text = node.text().to_string();
let lines: Vec<&str> = text.split('\n').map(strip_marker).collect();
if lines.len() < 2 {
return None;
}
let underline = lines.last().unwrap().trim();
let level = setext_underline_level(underline)?;
let title_src = lines[..lines.len() - 1]
.iter()
.map(|l| l.trim())
.collect::<Vec<_>>()
.join(&SOFT_BREAK.to_string());
let inlines = resolve_macro_arg_inlines(&title_src);
let (defs, dropped) = collect_user_linkrefs(&inlines);
if dropped.is_empty() {
return None;
}
let mut rest: Vec<Inline> = Vec::new();
for (i, inl) in inlines.iter().enumerate() {
match dropped.get(&i) {
Some(None) => {}
Some(Some(leftover)) => rest.push(Inline::Text(leftover.clone())),
None => rest.push(inl.clone()),
}
}
let empty = rest
.iter()
.all(|inl| matches!(inl, Inline::Text(t) if t.trim().is_empty()));
if empty && level == 2 {
return None;
}
Some(SetextStrip {
level,
defs,
title: (!empty).then_some(rest),
underline: underline.to_string(),
})
}
fn section_raw_fallback_atoms(macro_name: &str, heading: &SyntaxNode) -> Option<Vec<String>> {
let section = heading
.ancestors()
.find(|n| n.kind() == SyntaxKind::ROXYGEN_SECTION)?;
let text = section.text().to_string();
let mut value_lines: Vec<String> = Vec::new();
for (idx, line) in text.split('\n').enumerate() {
let content = strip_marker(line);
if idx == 0 {
let rest = content.strip_prefix('@')?.strip_prefix(macro_name)?;
if rest.is_empty() {
continue;
}
let rest = rest.strip_prefix([' ', '\t']).unwrap_or(rest);
value_lines.push(rest.to_string());
} else {
value_lines.push(content.to_string());
}
}
while value_lines.last().is_some_and(|l| l.trim().is_empty()) {
value_lines.pop();
}
let mut src = format!("#' @{macro_name}\n");
for line in &value_lines {
if line.is_empty() {
src.push_str("#'\n");
} else {
src.push_str("#' ");
src.push_str(line);
src.push('\n');
}
}
src.push_str("#' @name x\nNULL\n");
let atoms: Vec<String> = project_to_rd(&src)
.lines()
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect();
Some(atoms)
}
pub(super) fn render_heading_frame(
frames: &[HeadingFrame],
idx: usize,
md: bool,
macro_name: &str,
seed: &LinkDefs,
) -> String {
let f = &frames[idx];
let title_atoms = frame_title_atoms(f, md, seed, true);
let mut body_atoms = serialize_prose_seeded(&f.body, md, seed);
for &c in &f.children {
body_atoms.push(render_heading_frame(frames, c, md, "subsection", seed));
}
if f.level == 1 {
trim_field_atoms(&mut body_atoms);
}
let mut inner = grp_arg(&title_atoms);
let body_arg = grp_arg(&body_atoms);
if !body_arg.is_empty() {
if !inner.is_empty() {
inner.push(' ');
}
inner.push_str(&body_arg);
}
format!("(\\{macro_name}{})", prefix_space(&inner))
}
fn frame_title_atoms(f: &HeadingFrame, md: bool, seed: &LinkDefs, group: bool) -> Vec<String> {
let title = (md && !seed.is_empty())
.then(|| apply_user_linkrefs(&f.title, seed, false))
.flatten();
let title = title.as_deref().unwrap_or(&f.title);
let grouped = group.then(|| group_brace_lists(title, md));
serialize_inlines(grouped.as_deref().unwrap_or(title), md)
}
fn heading_piece_complete(frames: &[HeadingFrame], idx: usize, md: bool, seed: &LinkDefs) -> bool {
let mut rd = String::new();
heading_piece_rd(frames, idx, md, seed, &mut rd) && rd_complete(&rd)
}
fn heading_piece_rd(
frames: &[HeadingFrame],
idx: usize,
md: bool,
seed: &LinkDefs,
rd: &mut String,
) -> bool {
let f = &frames[idx];
if body_has_md_drop(&f.body) {
return false;
}
let stripped = strip_scan_percent_comments(&f.body);
let body = stripped.as_deref().unwrap_or(&f.body);
for atom in serialize_prose(body, md, false, seed) {
sexpr_to_rd(&atom, md, rd);
}
for &c in &f.children {
if frames[c].level == 1 {
continue;
}
rd.push_str("\\subsection{");
for atom in frame_title_atoms(&frames[c], md, seed, false) {
sexpr_to_rd(&atom, md, rd);
}
rd.push_str("}{");
if !heading_piece_rd(frames, c, md, seed, rd) {
return false;
}
rd.push('}');
}
true
}
struct HoistCut {
node: SyntaxNode,
chain: Vec<(usize, usize)>,
top_pos: usize,
item_pos: usize,
}
fn hoist_scan_list(
inl: &Inline,
chain: &[(usize, usize)],
top_pos: usize,
lists: &mut Vec<Vec<Vec<Inline>>>,
cuts: &mut Vec<HoistCut>,
) {
let items: Vec<Vec<Inline>> = match inl {
Inline::MdList(node) => node
.children()
.filter(|n| n.kind() == SyntaxKind::ROXYGEN_MD_LIST_ITEM)
.map(|it| md_list_item_inlines(&it))
.collect(),
Inline::MdListResolved { items, .. } => items.clone(),
_ => return,
};
let id = lists.len();
lists.push(items);
for it_idx in 0..lists[id].len() {
for pos in 0..lists[id][it_idx].len() {
let inl2 = lists[id][it_idx][pos].clone();
let mut sub = chain.to_vec();
sub.push((id, it_idx));
match &inl2 {
Inline::MdHeading(node) if parse_md_heading(node).0 == 1 => {
cuts.push(HoistCut {
node: node.clone(),
chain: sub,
top_pos,
item_pos: pos,
});
}
_ => hoist_scan_list(&inl2, &sub, top_pos, lists, cuts),
}
}
}
}
fn emit_section_with_list_hoist(
out: &mut Vec<String>,
macro_name: &str,
body: &[Inline],
md: bool,
drop_on_incomplete: bool,
) -> bool {
let mut lists: Vec<Vec<Vec<Inline>>> = Vec::new();
let mut cuts: Vec<HoistCut> = Vec::new();
for (idx, inl) in body.iter().enumerate() {
match inl {
Inline::MdHeading(node) if parse_md_heading(node).0 == 1 => {
cuts.push(HoistCut {
node: node.clone(),
chain: Vec::new(),
top_pos: idx,
item_pos: 0,
});
}
_ => hoist_scan_list(inl, &[], idx, &mut lists, &mut cuts),
}
}
if !cuts.iter().any(|c| !c.chain.is_empty()) {
return false;
}
if cuts[0].chain.is_empty() && cuts[0].top_pos > 0 {
emit_section_with_headings(
out,
macro_name,
&body[..cuts[0].top_pos],
md,
drop_on_incomplete,
);
}
let lists_of = |c: &HoistCut| c.chain.iter().map(|&(l, _)| l).collect::<Vec<_>>();
for k in 0..cuts.len() {
let (_, title_text) = parse_md_heading(&cuts[k].node);
let title = resolve_macro_arg_inlines(&title_text);
let cur = lists_of(&cuts[k]);
let next = cuts.get(k + 1).map(&lists_of).unwrap_or_default();
if cur != next {
let title_atoms = serialize_inlines(&group_brace_lists(&title, md), md);
out.push(format!(
"(\\section{})",
prefix_space(&grp_arg(&title_atoms))
));
continue;
}
if cur.is_empty() {
let end = cuts.get(k + 1).map(|c| c.top_pos).unwrap_or(body.len());
out.push(hoisted_section_atom(
title,
&body[cuts[k].top_pos + 1..end],
md,
));
continue;
}
let &(list_id, it_k) = cuts[k].chain.last().unwrap();
let &(_, it_next) = cuts[k + 1].chain.last().unwrap();
let items = &lists[list_id];
let mut atoms: Vec<String> = Vec::new();
if it_k == it_next {
let piece = &items[it_k][cuts[k].item_pos + 1..cuts[k + 1].item_pos];
atoms.extend(serialize_inlines(piece, true));
} else {
atoms.extend(serialize_inlines(
&items[it_k][cuts[k].item_pos + 1..],
true,
));
for (j, item) in items.iter().enumerate().take(it_next + 1).skip(it_k + 1) {
atoms.push(format!("(UNKNOWN {})", encode_text("\\item")));
let end = if j == it_next {
cuts[k + 1].item_pos
} else {
item.len()
};
atoms.extend(serialize_inlines(&item[..end], true));
}
}
let title_atoms = serialize_inlines(&group_brace_lists(&title, md), md);
let mut inner = grp_arg(&title_atoms);
let body_arg = grp_arg(&atoms);
if !body_arg.is_empty() {
if !inner.is_empty() {
inner.push(' ');
}
inner.push_str(&body_arg);
}
out.push(format!("(\\section{})", prefix_space(&inner)));
}
true
}
fn hoisted_section_atom(title: Vec<Inline>, content: &[Inline], md: bool) -> String {
let mut segments: Vec<(Option<SyntaxNode>, Vec<Inline>)> = vec![(None, Vec::new())];
for inl in content {
if let Inline::MdHeading(node) = inl {
segments.push((Some(node.clone()), Vec::new()));
} else {
segments.last_mut().unwrap().1.push(inl.clone());
}
}
let mut frames: Vec<HeadingFrame> = vec![HeadingFrame {
level: 1,
title,
body: std::mem::take(&mut segments[0].1),
children: Vec::new(),
}];
let mut stack = vec![0usize];
for (node, run) in segments.into_iter().skip(1) {
let node = node.expect("a non-leading segment always carries a heading");
let (level, title_text) = parse_md_heading(&node);
let title = resolve_macro_arg_inlines(&title_text);
while *stack.last().unwrap() != 0 && frames[*stack.last().unwrap()].level >= level {
stack.pop();
}
let parent = *stack.last().unwrap();
let idx = frames.len();
frames.push(HeadingFrame {
level,
title,
body: run,
children: Vec::new(),
});
frames[parent].children.push(idx);
stack.push(idx);
}
render_heading_frame(&frames, 0, md, "section", &LinkDefs::new())
}
pub(super) fn parse_md_heading(node: &SyntaxNode) -> (usize, String) {
let text = node.text().to_string();
let lines: Vec<&str> = text.split('\n').map(strip_marker).collect();
if lines.len() >= 2
&& let Some(level) = setext_underline_level(lines.last().unwrap())
{
let title = lines[..lines.len() - 1]
.iter()
.map(|l| l.trim())
.collect::<Vec<_>>()
.join(" ");
return (level, title);
}
let from_value = node
.first_token()
.is_some_and(|t| t.kind() != SyntaxKind::ROXYGEN_MARKER);
let line = if from_value {
text.strip_prefix([' ', '\t']).unwrap_or(&text).trim_start()
} else {
strip_marker(&text).trim_start()
};
let level = line.bytes().take_while(|&b| b == b'#').count().clamp(1, 6);
let rest = line.get(level..).unwrap_or("").trim();
(level, strip_atx_closing(rest).to_string())
}
fn setext_underline_level(line: &str) -> Option<usize> {
let s = line.trim();
let ch = s.bytes().next()?;
if (ch == b'=' || ch == b'-') && s.bytes().all(|b| b == ch) {
return Some(if ch == b'=' { 1 } else { 2 });
}
None
}
fn strip_atx_closing(s: &str) -> &str {
let t = s.trim_end();
let hashes = t.len() - t.trim_end_matches('#').len();
if hashes == 0 {
return t;
}
let before = &t[..t.len() - hashes];
if before.is_empty() || before.ends_with([' ', '\t']) {
before.trim_end()
} else {
t
}
}
#[cfg(test)]
pub(super) fn section_rd_complete(body: &[Inline], md: bool) -> bool {
section_rd_complete_seeded(body, md, &LinkDefs::new())
}
fn section_rd_complete_seeded(body: &[Inline], md: bool, seed: &LinkDefs) -> bool {
if md {
if body_has_md_drop(body) {
return false;
}
let stripped = strip_scan_percent_comments(body);
let scan_body = stripped.as_deref().unwrap_or(body);
section_atoms_rd_complete(&serialize_prose(scan_body, md, false, seed), md)
} else {
rd_complete(§ion_raw_rd(body))
}
}
fn body_has_md_drop(body: &[Inline]) -> bool {
body.iter().any(|inl| match inl {
Inline::MdInlineLink { url, display } => {
md_href_dest_drops(url) || body_has_md_drop(display)
}
Inline::MdRefLink { display, .. } | Inline::MdShortcutLink { display } => {
body_has_md_drop(display)
}
Inline::MdEmphasis { children, .. } => body_has_md_drop(children),
Inline::BraceGroup(children) => body_has_md_drop(children),
Inline::MdListResolved { items, .. } => items.iter().any(|it| body_has_md_drop(it)),
Inline::MdCodeBlock(node) => md_fence_info_drops(node),
_ => false,
})
}
pub(super) fn md_href_dest_drops(url: &str) -> bool {
let bytes = url.as_bytes();
let mut depth = 0usize;
let mut close = bytes.len();
for (i, &b) in bytes.iter().enumerate() {
match b {
b'(' => depth += 1,
b')' if depth == 0 => {
close = i;
break;
}
b')' => depth -= 1,
_ => {}
}
}
let mut run = 0usize;
let mut k = close;
while k > 0 && bytes[k - 1] == b'\\' {
run += 1;
k -= 1;
}
run % 2 == 1
}
pub(super) fn section_atoms_rd_complete(atoms: &[String], md: bool) -> bool {
let mut rd = String::new();
for atom in atoms {
sexpr_to_rd(atom, md, &mut rd);
}
rd_complete(&rd)
}
fn section_raw_rd(body: &[Inline]) -> String {
let mut s = String::new();
for inl in body {
match inl {
Inline::Text(t) => {
for c in t.chars() {
s.push(if c == SOFT_BREAK { '\n' } else { c });
}
}
Inline::Macro(n) => s.push_str(&n.text().to_string()),
_ => {}
}
}
s
}
pub(super) fn rd_complete(s: &str) -> bool {
#[derive(PartialEq)]
enum State {
Rd,
RdEscape,
RdComment,
}
let mut state = State::Rd;
let mut braces: i64 = 0;
for c in s.chars() {
match state {
State::Rd => match c {
'{' => braces += 1,
'}' => braces -= 1,
'\\' => state = State::RdEscape,
'%' => state = State::RdComment,
_ => {}
},
State::RdEscape => state = State::Rd,
State::RdComment => {
if c == '\n' {
state = State::Rd;
}
}
}
}
braces == 0 && state != State::RdEscape
}
fn split_section_title(body: &[Inline]) -> (Vec<Inline>, Vec<Inline>) {
let mut title: Vec<Inline> = Vec::new();
let mut content: Vec<Inline> = Vec::new();
let mut split = false;
for inl in body {
if split {
content.push(inl.clone());
continue;
}
if let Inline::Text(t) = inl
&& let Some(idx) = t.find(':')
{
if idx > 0 {
title.push(Inline::Text(t[..idx].to_string()));
}
let after = &t[idx + 1..];
if !after.is_empty() {
content.push(Inline::Text(after.to_string()));
}
split = true;
continue;
}
title.push(inl.clone());
}
(title, content)
}
pub(super) fn item_subsection_atom(frames: &[HeadingFrame], idx: usize) -> String {
let f = &frames[idx];
let title_atoms = serialize_inlines(&group_brace_lists(&f.title, true), true);
let mut body_atoms = serialize_inlines(&f.body, true);
for &c in &f.children {
body_atoms.push(item_subsection_atom(frames, c));
}
let mut inner = grp_arg(&title_atoms);
let body_arg = grp_arg(&body_atoms);
if !body_arg.is_empty() {
if !inner.is_empty() {
inner.push(' ');
}
inner.push_str(&body_arg);
}
format!("(\\subsection{})", prefix_space(&inner))
}