use std::collections::BTreeMap;
const WIDTH: usize = 76;
const OPENING: &[char] = &['"', '\'', '(', '*'];
const FENCE: &str = "```";
const CLOSING: &[char] = &['"', '\'', '.', ',', ';', ':', '!', '?', '*'];
#[derive(Default)]
pub struct Symbols<'a> {
own: BTreeMap<String, String>,
behind: Option<&'a Symbols<'a>>,
}
impl<'a> Symbols<'a> {
pub fn layered(behind: &'a Symbols<'a>) -> Self {
Symbols {
own: BTreeMap::new(),
behind: Some(behind),
}
}
pub fn insert(&mut self, cpp: String, markdown: String) {
self.own.entry(cpp).or_insert(markdown);
}
fn get(&self, name: &str) -> Option<&str> {
match self.own.get(name) {
Some(markdown) => Some(markdown),
None => self.behind?.get(name),
}
}
fn link(&self, word: &str) -> Option<&str> {
self.get(word.strip_suffix("()").unwrap_or(word))
}
}
pub fn link(display: &str, path: Option<&str>) -> String {
match path {
Some(path) => format!("[`{display}`]({path})"),
None => format!("[`{display}`]"),
}
}
pub fn to_markdown(documentation: &str, symbols: &Symbols<'_>) -> String {
let documentation = documentation
.replace("\r\n", "\n")
.replace('\r', "\n")
.replace('\t', " ");
let documentation = dedent(&documentation);
let mut out = String::with_capacity(documentation.len() + documentation.len() / 4);
for segment in segments(&documentation) {
match segment {
Segment::Code(body) => {
out.push_str(FENCE);
out.push_str("text\n");
out.push_str(body.trim_start_matches('\n').trim_end());
out.push('\n');
out.push_str(FENCE);
out.push('\n');
}
Segment::Prose(text) => {
let mut sample = false;
for line in items(text) {
let blank = line.trim().is_empty();
let nested = marker(line.trim_start()) > 0;
let indented = match sample {
true => line.starts_with(" ") || blank,
false => line.starts_with(" ") && !blank && !nested,
};
if indented != sample {
out.push_str(FENCE);
if indented {
out.push_str("text");
}
out.push('\n');
sample = indented;
}
if sample {
out.push_str(&line);
out.push('\n');
continue;
}
for rendered in render_line(&line, symbols).split('\n') {
out.push_str(&wrap(&balance(rendered)));
out.push('\n');
}
}
if sample {
out.push_str(FENCE);
out.push('\n');
}
}
}
}
out.truncate(out.trim_end().len());
out
}
fn balance(line: &str) -> String {
if line.matches('`').count().is_multiple_of(2) {
return line.to_owned();
}
let mut out = String::with_capacity(line.len() + 1);
let stray = line.rfind('`').expect("an odd count has at least one");
out.push_str(&line[..stray]);
out.push_str("\\`");
out.push_str(&line[stray + 1..]);
out
}
fn items(text: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut item = false;
for line in text.lines() {
let line = html(line);
let trimmed = line.trim_start();
let opens = marker(trimmed) > 0 || trimmed.starts_with(r"\li ");
if item && !opens && !trimmed.is_empty() {
let carried = out.last_mut().expect("an item opened before this continues it");
carried.push(' ');
carried.push_str(trimmed);
} else {
item = opens;
out.push(line.clone());
}
}
out
}
fn dedent(text: &str) -> String {
let common = text
.lines()
.skip(1)
.filter(|line| !line.trim().is_empty())
.map(|line| line.len() - line.trim_start().len())
.min()
.unwrap_or(0);
text.lines()
.enumerate()
.map(|(at, line)| match at {
0 => line,
_ if line.trim().is_empty() => "",
_ => line.get(common..).unwrap_or(""),
})
.collect::<Vec<_>>()
.join("\n")
}
enum Segment<'a> {
Prose(&'a str),
Code(&'a str),
}
fn segments(documentation: &str) -> Vec<Segment<'_>> {
let mut out = Vec::new();
let mut rest = documentation;
loop {
let command = find_command(rest, "code");
let fence = fenced(rest);
match (command, fence) {
(Some((at, after)), _) if fence.is_none_or(|(open, _, _)| at < open) => {
out.push(Segment::Prose(&rest[..at]));
let body = match after.strip_prefix('{') {
Some(braced) => braced.split_once('}').map_or(braced, |(_, tail)| tail),
None => after,
};
let Some((end, tail)) = find_command(body, "endcode") else {
out.push(Segment::Code(body));
return out;
};
out.push(Segment::Code(&body[..end]));
rest = tail;
}
(_, Some((open, body, tail))) => {
out.push(Segment::Prose(&rest[..open]));
out.push(Segment::Code(body));
rest = tail;
}
_ => break,
}
}
out.push(Segment::Prose(rest));
out
}
fn fenced(text: &str) -> Option<(usize, &str, &str)> {
let open = text.find(FENCE)?;
let body = text[open + FENCE.len()..].split_once('\n').map_or("", |(_, body)| body);
match body.find(FENCE) {
Some(close) => Some((open, &body[..close], &body[close + FENCE.len()..])),
None => Some((open, body, "")),
}
}
fn find_command<'a>(text: &'a str, name: &str) -> Option<(usize, &'a str)> {
let mut searched = 0;
while let Some(command) = next_command(&text[searched..]) {
if command.name == name {
return Some((searched + command.at, command.after));
}
searched += command.at + 1;
}
None
}
#[derive(Clone, Copy)]
struct Command<'a> {
at: usize,
marker: char,
name: &'a str,
after: &'a str,
}
fn next_command(text: &str) -> Option<Command<'_>> {
let mut searched = 0;
while let Some(offset) = text[searched..].find(['\\', '@']) {
let at = searched + offset;
let marker = text[at..].chars().next()?;
let after_marker = &text[at + marker.len_utf8()..];
let end = after_marker
.find(|c: char| !c.is_ascii_alphabetic())
.unwrap_or(after_marker.len());
let (name, after) = after_marker.split_at(end);
let opens_word = text[..at]
.chars()
.next_back()
.is_none_or(|c| c.is_whitespace() || OPENING.contains(&c));
let ends_name = after.is_empty() || after.starts_with(char::is_whitespace) || after.starts_with('{');
if opens_word && !name.is_empty() && ends_name {
return Some(Command {
at,
marker,
name,
after,
});
}
searched = at + marker.len_utf8();
}
None
}
fn html(line: &str) -> String {
let mut out = String::with_capacity(line.len());
let mut rest = line;
while let Some(open) = rest.find('<') {
let after = &rest[open + 1..];
let name: String = after
.trim_start_matches('/')
.chars()
.take_while(char::is_ascii_alphanumeric)
.collect();
let opens = after[name.len() + usize::from(after.starts_with('/'))..].trim_start();
let tag = !name.is_empty()
&& (opens.starts_with('>') || opens.starts_with('/') || opens.starts_with(|c: char| c.is_alphabetic()));
let Some(close) = after.find('>').filter(|_| tag) else {
out.push_str(&rest[..open + 1]);
rest = after;
continue;
};
out.push_str(&rest[..open]);
out.push_str(match name.to_ascii_lowercase().as_str() {
"b" | "strong" => "**",
"i" | "em" => "*",
"tt" | "code" => "`",
"li" if !after.starts_with('/') => "- ",
"br" => " ",
_ => "",
});
rest = &after[close + 1..];
}
out.push_str(rest);
out
}
fn render_line(line: &str, symbols: &Symbols<'_>) -> String {
let mut out = String::with_capacity(line.len() + line.len() / 8);
let mut rest = line;
while let Some(command) = next_command(rest) {
mark_up(&rest[..command.at], symbols, &mut out);
rest = render_command(&command, symbols, &mut out);
}
mark_up(rest, symbols, &mut out);
out
}
fn render_command<'a>(command: &Command<'a>, symbols: &Symbols<'_>, out: &mut String) -> &'a str {
match command.name {
"em" | "e" => return wrap_next_word(command, '*', out),
"p" | "a" | "c" => return wrap_next_word(command, '`', out),
"ref" => return reference(command, symbols, out),
"sa" => out.push_str("See also"),
"li" => {
out.push_str("- ");
return command.after.strip_prefix(' ').unwrap_or(command.after);
}
"section" | "subsection" => {
let (_label, title) = next_word(command.after);
out.push_str(match command.name {
"section" => "# ",
_ => "## ",
});
return title.trim_start();
}
"snippet" => return "",
"note" => out.push_str("Note:"),
"todo" => out.push_str("Todo:"),
"deprecated" => out.push_str("Deprecated:"),
"n" => out.push('\n'),
_ => {
out.push(command.marker);
out.push_str(command.name);
}
}
command.after
}
fn wrap_next_word<'a>(command: &Command<'a>, delimiter: char, out: &mut String) -> &'a str {
let (word, rest) = next_word(command.after);
let (leading, core, trailing) = split_word(word);
if core.is_empty() {
out.push(command.marker);
out.push_str(command.name);
return command.after;
}
out.push_str(leading);
out.push(delimiter);
out.push_str(core);
out.push(delimiter);
out.push_str(trailing);
rest
}
fn reference<'a>(command: &Command<'a>, symbols: &Symbols<'_>, out: &mut String) -> &'a str {
let (target, rest) = next_word(command.after);
if target.is_empty() {
out.push(command.marker);
out.push_str(command.name);
return command.after;
}
let quoted = rest.trim_start_matches(' ');
if let Some(text) = quoted.strip_prefix('"')
&& let Some(close) = text.find('"')
{
mark_up(&text[..close], symbols, out);
return &text[close + 1..];
}
let (leading, core, trailing) = split_word(target);
if core.is_empty() {
out.push_str(target);
} else if let Some(link) = symbols.link(core) {
out.push_str(leading);
out.push_str(link);
} else {
out.push_str(leading);
out.push('`');
out.push_str(core);
out.push('`');
out.push_str(trailing);
}
rest
}
fn next_word(after: &str) -> (&str, &str) {
let trimmed = after.trim_start_matches(' ');
let end = trimmed.find(char::is_whitespace).unwrap_or(trimmed.len());
trimmed.split_at(end)
}
fn split_word(word: &str) -> (&str, &str, &str) {
let after_opening = word.trim_start_matches(OPENING);
let leading = &word[..word.len() - after_opening.len()];
let mut core = after_opening;
while let Some(unopened) = core
.trim_end_matches(CLOSING)
.strip_suffix(')')
.filter(|open| open.matches(')').count() >= open.matches('(').count())
{
core = unopened;
}
core = core.trim_end_matches(CLOSING);
(leading, core, &word[leading.len() + core.len()..])
}
fn mark_up(text: &str, symbols: &Symbols<'_>, out: &mut String) {
let links = text.contains("](");
let mut in_span = false;
for piece in text.split_inclusive(char::is_whitespace) {
let word = piece.trim_end();
let spacing = &piece[word.len()..];
let ticks = word.matches('`').count();
if in_span || ticks > 0 {
if ticks % 2 == 1 {
in_span = !in_span;
}
out.push_str(piece);
continue;
}
let (leading, core, trailing) = split_word(word);
out.push_str(leading);
if is_url(core) {
out.push('<');
out.push_str(core);
out.push('>');
} else if let Some(link) = symbols.link(core) {
out.push_str(link);
} else if reads_as_code(core) {
out.push('`');
out.push_str(core);
out.push('`');
} else if !links && core.contains(['[', ']']) {
out.push_str(&core.replace('[', "\\[").replace(']', "\\]"));
} else {
out.push_str(core);
}
out.push_str(trailing);
out.push_str(spacing);
}
}
fn is_url(word: &str) -> bool {
word.starts_with("http://") || word.starts_with("https://")
}
fn reads_as_code(word: &str) -> bool {
if word.len() < 2 {
return false;
}
let bare = word.strip_suffix("'s").unwrap_or(word);
word.contains("::") || word.contains('_') || word.ends_with("()") || is_camel_case(bare)
}
fn is_camel_case(word: &str) -> bool {
word.chars().all(char::is_alphanumeric)
&& word.chars().skip(1).any(char::is_uppercase)
&& word.chars().any(char::is_lowercase)
}
pub fn wrap(line: &str) -> String {
if columns(line) <= WIDTH {
return line.trim_end().to_owned();
}
let body = line.trim_start();
let indent = &line[..line.len() - body.len()];
let hanging = format!("{indent}{}", " ".repeat(marker(body)));
let words = body.split_whitespace().collect::<Vec<_>>().join(" ");
let options = textwrap::Options::new(WIDTH)
.initial_indent(indent)
.subsequent_indent(&hanging)
.break_words(false)
.word_splitter(textwrap::WordSplitter::NoHyphenation);
textwrap::wrap(&words, options).join("\n")
}
fn marker(body: &str) -> usize {
if let Some(rest) = body.strip_prefix(['-', '*', '+'])
&& rest.starts_with(' ')
{
return 2;
}
let digits = body.trim_start_matches(|c: char| c.is_ascii_digit());
let counted = body.len() - digits.len();
match counted > 0 && digits.starts_with(['.', ')']) && digits[1..].starts_with(' ') {
true => counted + 2,
false => 0,
}
}
fn columns(text: &str) -> usize {
textwrap::core::display_width(text)
}
#[cfg(test)]
mod tests {
use super::*;
fn to_markdown(documentation: &str) -> String {
super::to_markdown(documentation, &Symbols::default())
}
#[test]
fn link_strips_call() {
let mut symbols = Symbols::default();
symbols.insert("GetSizeAttr".to_owned(), "[`size_attr`]".to_owned());
symbols.insert("UsdGeomBox::GetSizeAttr".to_owned(), "[`size_attr`]".to_owned());
assert_eq!(symbols.link("GetSizeAttr"), Some("[`size_attr`]"));
assert_eq!(symbols.link("GetSizeAttr()"), Some("[`size_attr`]"));
assert_eq!(symbols.link("UsdGeomBox::GetSizeAttr()"), Some("[`size_attr`]"));
assert_eq!(symbols.link("GetOtherAttr()"), None);
}
#[test]
fn link_needs_the_named_class() {
let mut symbols = Symbols::default();
symbols.insert("Xform".to_owned(), "[`Xform`]".to_owned());
symbols.insert("GetSizeAttr".to_owned(), "[`size_attr`]".to_owned());
assert_eq!(symbols.link("Xform"), Some("[`Xform`]"));
assert_eq!(symbols.link("AbcGeom::Xform"), None);
assert_eq!(symbols.link("UsdGeomGprim::GetSizeAttr()"), None);
}
#[test]
fn link_keeps_first() {
let mut symbols = Symbols::default();
symbols.insert("GetRadiusAttr".to_owned(), "[`near`]".to_owned());
symbols.insert("GetRadiusAttr".to_owned(), "[`far`]".to_owned());
assert_eq!(symbols.link("GetRadiusAttr"), Some("[`near`]"));
}
#[test]
fn whitespace_normalizes() {
assert_eq!(to_markdown("one\r\ntwo"), "one\ntwo");
assert_eq!(to_markdown("one\rtwo"), "one\ntwo");
assert_eq!(
to_markdown("one\n\ttwo\n\t\tthree"),
"one\ntwo\n```text\n three\n```"
);
}
#[test]
fn joins_a_wrapped_item() {
let text = "- one two\nthree four\n\nafter";
assert_eq!(to_markdown(text), "- one two three four\n\nafter");
}
#[test]
fn code_words_backtick() {
assert_eq!(to_markdown("the Field3D format"), "the `Field3D` format");
assert_eq!(to_markdown("from BBox computation"), "from `BBox` computation");
assert_eq!(to_markdown("a 3D scene"), "a 3D scene");
assert_eq!(to_markdown("the USD stage"), "the USD stage");
assert_eq!(to_markdown("a Sphere prim"), "a Sphere prim");
}
#[test]
fn indentation_drops() {
let text = "The radius.\n\n Twice the size of\n the sample below.";
assert_eq!(
to_markdown(text),
"The radius.\n\nTwice the size of\n```text\n the sample below.\n```"
);
}
#[test]
fn html_becomes_markdown() {
assert_eq!(to_markdown("a <b>bold</b> word"), "a **bold** word");
assert_eq!(to_markdown("an <i>italic</i> word"), "an *italic* word");
assert_eq!(
to_markdown(r#"see <A HREF="http://x.com">the page</A>"#),
"see the page"
);
assert_eq!(to_markdown("<ul><li>one</li></ul>"), "- one");
assert_eq!(to_markdown("where x < y and y > z"), "where x < y and y > z");
}
#[test]
fn stray_backtick_escapes() {
assert_eq!(to_markdown("a name 'beauty` here"), "a name 'beauty\\` here");
assert_eq!(to_markdown("a `span` here"), "a `span` here");
}
#[test]
fn blank_line_is_no_sample() {
assert_eq!(to_markdown("one\n \ntwo"), "one\n\ntwo");
}
#[test]
fn nested_items_stay_items() {
assert_eq!(
to_markdown("Values:\n- outer:\n - inner\n - other"),
"Values:\n- outer:\n - inner\n - other"
);
}
#[test]
fn sections_become_headings() {
assert_eq!(
to_markdown(r"\section usd_props Collection Properties"),
"# Collection Properties"
);
assert_eq!(
to_markdown(r"\subsection usd_props_a Implicit Inclusion"),
"## Implicit Inclusion"
);
}
#[test]
fn snippets_drop() {
assert_eq!(
to_markdown("before\n\n\\snippet examples.cpp ApplyCollections"),
"before"
);
}
#[test]
fn indented_blocks_fence() {
let text = "Like this:\n\n def Mesh \"m\" {\n }\n\nand after.";
assert_eq!(
to_markdown(text),
"Like this:\n\n```text\n def Mesh \"m\" {\n }\n\n```\nand after."
);
}
#[test]
fn em_italic() {
assert_eq!(to_markdown("the \\em only one"), "the *only* one");
assert_eq!(to_markdown("the \\e only one"), "the *only* one");
}
#[test]
fn code_words_backticked() {
assert_eq!(to_markdown("pass \\p radius here"), "pass `radius` here");
assert_eq!(to_markdown("see \\c UsdGeomMesh."), "see `UsdGeomMesh`.");
}
#[test]
fn rendered_once() {
assert_eq!(
to_markdown("the default is \\em catmullClark."),
"the default is *catmullClark*."
);
assert_eq!(to_markdown("see \\p faceVertexCounts."), "see `faceVertexCounts`.");
}
#[test]
fn ref_text() {
assert_eq!(to_markdown("see \\ref Usd_Page \"the page\""), "see the page");
assert_eq!(to_markdown("see \\ref UsdGeomMesh"), "see `UsdGeomMesh`");
assert_eq!(
to_markdown("See \\ref UsdGeomMesh_Subdivision."),
"See `UsdGeomMesh_Subdivision`.",
"the full stop ends the sentence, not the code span"
);
}
#[test]
fn punctuation_outside() {
assert_eq!(
to_markdown("values are \"catmullClark\", or drawMode."),
"values are \"`catmullClark`\", or `drawMode`."
);
assert_eq!(
to_markdown("See https://openusd.org. done"),
"See <https://openusd.org>. done"
);
}
#[test]
fn code_fenced() {
let source = "before\n\\code\nlet x = some_name;\n\\endcode\nafter";
let converted = to_markdown(source);
assert!(converted.contains("```text\n"), "{converted}");
assert!(
converted.contains("let x = some_name;"),
"the block is left alone: {converted}"
);
let sample = to_markdown("\\code\nprintf(\"a\\n\");\n// \\p foo\n\\endcode");
assert!(sample.contains("printf(\"a\\n\");"), "the escape survives: {sample}");
assert!(sample.contains("// \\p foo"), "the command survives: {sample}");
}
#[test]
fn asset_paths_kept() {
assert_eq!(to_markdown("see @Foo/bar.usd@ now"), "see @Foo/bar.usd@ now");
assert_eq!(to_markdown("write to me@example.com"), "write to me@example.com");
assert_eq!(
to_markdown("Contact @notes.usda@ or @code.usda@ for more."),
"Contact @notes.usda@ or @code.usda@ for more."
);
let example = "\\code\nprototypes = [@MaleGroupA/usd/MaleGroupA.usd@]\n\\endcode";
let converted = to_markdown(example);
assert!(
converted.contains("[@MaleGroupA/usd/MaleGroupA.usd@]"),
"the asset path lost its delimiters: {converted}"
);
}
#[test]
fn dangling_command() {
assert_eq!(to_markdown("pass \\p"), "pass \\p");
assert_eq!(to_markdown("see \\ref"), "see \\ref");
}
#[test]
fn camel_backticked() {
assert_eq!(
to_markdown("the faceVertexCounts array"),
"the `faceVertexCounts` array"
);
assert_eq!(to_markdown("a snake_case name"), "a `snake_case` name");
assert_eq!(to_markdown("call Compute() now"), "call `Compute()` now");
assert_eq!(to_markdown("plain english prose"), "plain english prose");
}
#[test]
fn links_made_safe() {
assert_eq!(to_markdown("see [1] below"), "see \\[1\\] below");
assert_eq!(
to_markdown("see https://openusd.org here"),
"see <https://openusd.org> here"
);
assert_eq!(
to_markdown("see [docs](https://openusd.org) now"),
"see [docs](https://openusd.org) now"
);
}
#[test]
fn labels_and_bullets() {
assert_eq!(to_markdown("\\note this matters"), "Note: this matters");
assert_eq!(to_markdown("\\deprecated use the other"), "Deprecated: use the other");
assert_eq!(to_markdown("\\li one"), "- one");
assert_eq!(to_markdown("\\sa the other"), "See also the other");
}
#[test]
fn wraps_a_list_item() {
let item = format!("- {}", "word ".repeat(30));
let wrapped = wrap(&item);
let mut lines = wrapped.lines();
assert!(lines.next().is_some_and(|line| line.starts_with("- word")));
assert!(
lines.all(|line| line.starts_with(" word")),
"each continuation is indented under the text: {wrapped}"
);
let numbered = wrap(&format!("10. {}", "word ".repeat(30)));
assert!(
numbered.lines().skip(1).all(|line| line.starts_with(" word")),
"{numbered}"
);
}
#[test]
fn wraps_at_width() {
let long = "word ".repeat(30);
for line in to_markdown(&long).lines() {
assert!(columns(line) <= WIDTH, "{} columns: {line}", columns(line));
}
let single = "a".repeat(100);
assert_eq!(
to_markdown(&single),
single,
"a word longer than the width stands alone"
);
let accented = "é".repeat(70);
assert_eq!(to_markdown(&accented), accented, "140 bytes, but only 70 columns");
}
}