use std::collections::BTreeMap;
use rspyts::ir::{DefinitionId, ParamDef, TypeRef};
#[derive(Clone, Copy)]
pub(crate) enum CallableReturn<'a> {
Omitted,
Contract(&'a TypeRef),
Resource(&'a str),
}
pub(crate) struct CallableDocumentation<'a> {
pub(crate) docs: Option<&'a str>,
pub(crate) fallback_summary: String,
pub(crate) params: &'a [ParamDef],
pub(crate) returns: CallableReturn<'a>,
pub(crate) error: Option<&'a DefinitionId>,
}
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct Documentation {
pub(crate) summary: String,
pub(crate) notes: String,
pub(crate) parameters: BTreeMap<String, String>,
pub(crate) returns: String,
pub(crate) errors: String,
pub(crate) examples: String,
}
impl Documentation {
#[must_use]
pub(crate) fn parse(source: Option<&str>) -> Self {
let Some(source) = source.filter(|source| !source.trim().is_empty()) else {
return Self::default();
};
let mut description = Vec::new();
let mut notes = Vec::new();
let mut arguments = Vec::new();
let mut returns = Vec::new();
let mut errors = Vec::new();
let mut examples = Vec::new();
let mut section = Section::Description;
for line in source.lines() {
if let Some((next, unknown_heading)) = section_heading(line) {
section = next;
if let Some(heading) = unknown_heading {
notes.push(format!("{heading}:"));
}
continue;
}
match section {
Section::Description => description.push(line.to_owned()),
Section::Notes => notes.push(line.to_owned()),
Section::Arguments => arguments.push(line.to_owned()),
Section::Returns => returns.push(line.to_owned()),
Section::Errors => errors.push(line.to_owned()),
Section::Examples => examples.push(line.to_owned()),
}
}
let (summary, trailing_description) = split_summary(&description);
append_block(&mut notes, &trailing_description);
let (parameters, unmatched_arguments) = parse_arguments(&arguments);
append_block(&mut notes, &unmatched_arguments);
Self {
summary: normalize_block(&summary),
notes: normalize_block(¬es),
parameters,
returns: normalize_block(&returns),
errors: normalize_block(&errors),
examples: normalize_block(&examples),
}
}
}
#[derive(Clone, Copy)]
enum Section {
Description,
Notes,
Arguments,
Returns,
Errors,
Examples,
}
fn section_heading(line: &str) -> Option<(Section, Option<String>)> {
let trimmed = line.trim();
let (heading, markdown) = if trimmed.starts_with('#') {
(trimmed.trim_start_matches('#').trim(), true)
} else if let Some(heading) = trimmed.strip_suffix(':') {
(heading.trim(), false)
} else {
return None;
};
let normalized = heading.to_ascii_lowercase();
let section = match normalized.as_str() {
"arguments" | "args" | "parameters" | "params" => Section::Arguments,
"returns" | "return" | "return value" => Section::Returns,
"errors" | "raises" | "throws" => Section::Errors,
"notes" | "note" | "remarks" => Section::Notes,
"examples" | "example" => Section::Examples,
_ if markdown => return Some((Section::Notes, Some(heading.to_owned()))),
_ => return None,
};
Some((section, None))
}
fn split_summary(lines: &[String]) -> (Vec<String>, Vec<String>) {
let lines = trim_blank_lines(lines);
let boundary = lines
.iter()
.position(|line| line.trim().is_empty())
.unwrap_or(lines.len());
let summary = lines[..boundary].to_vec();
let trailing = lines.get(boundary + 1..).unwrap_or_default().to_vec();
(summary, trailing)
}
fn parse_arguments(lines: &[String]) -> (BTreeMap<String, String>, Vec<String>) {
let mut parameters = BTreeMap::<String, String>::new();
let mut unmatched = Vec::new();
let mut current: Option<String> = None;
for line in trim_blank_lines(lines) {
if let Some((name, description)) = parameter_entry(&line) {
parameters
.entry(name.clone())
.and_modify(|existing| append_sentence(existing, &description))
.or_insert(description);
current = Some(name);
} else if line.trim().is_empty() {
current = None;
} else if let Some(name) = ¤t {
append_sentence(
parameters
.get_mut(name)
.expect("the current parameter was inserted"),
line.trim(),
);
} else {
unmatched.push(line);
}
}
(parameters, unmatched)
}
fn parameter_entry(line: &str) -> Option<(String, String)> {
let mut value = line.trim();
let mut bullet = false;
if let Some(rest) = value
.strip_prefix("- ")
.or_else(|| value.strip_prefix("* "))
{
value = rest.trim();
bullet = true;
}
let (name, rest) = if let Some(value) = value.strip_prefix('`') {
let end = value.find('`')?;
(&value[..end], &value[end + 1..])
} else if let Some(end) = value.find(':') {
(&value[..end], &value[end..])
} else if bullet {
let end = value.find([' ', '-'])?;
(&value[..end], &value[end..])
} else {
return None;
};
if name.is_empty()
|| !name
.chars()
.all(|character| character.is_ascii_alphanumeric() || character == '_')
{
return None;
}
let description = rest
.trim_start()
.strip_prefix([':', '-'])
.unwrap_or(rest.trim_start())
.trim()
.to_owned();
Some((name.to_owned(), description))
}
#[must_use]
pub(crate) fn remove_rustdoc_link_brackets(value: &str) -> String {
value.replace("[`", "`").replace("`]", "`")
}
#[must_use]
pub(crate) fn contextualize_error_description(value: &str) -> String {
value.strip_prefix("Returns ").map_or_else(
|| value.to_owned(),
|conditions| format!("The Rust implementation returns {conditions}"),
)
}
fn normalize_block(lines: &[String]) -> String {
trim_blank_lines(lines)
.iter()
.map(|line| line.trim_end())
.collect::<Vec<_>>()
.join("\n")
}
fn trim_blank_lines(lines: &[String]) -> Vec<String> {
let first = lines
.iter()
.position(|line| !line.trim().is_empty())
.unwrap_or(lines.len());
let last = lines
.iter()
.rposition(|line| !line.trim().is_empty())
.map_or(first, |index| index + 1);
lines[first..last].to_vec()
}
fn append_block(target: &mut Vec<String>, block: &[String]) {
let block = trim_blank_lines(block);
if block.is_empty() {
return;
}
if target.iter().any(|line| !line.trim().is_empty()) {
target.push(String::new());
}
target.extend(block);
}
fn append_sentence(target: &mut String, value: &str) {
if value.is_empty() {
return;
}
if !target.is_empty() {
target.push(' ');
}
target.push_str(value);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_rustdoc_into_language_neutral_sections() {
let documentation = Documentation::parse(Some(
"Perform one operation.\n\nAdditional implementation detail.\n\n# Arguments\n\n- `value` - Value to process.\n Continued detail.\n\n# Returns\n\nThe processed value.\n\n# Errors\n\nReturns `ExampleError` when validation fails.\n\n# Examples\n\n```text\nexample\n```",
));
assert_eq!(documentation.summary, "Perform one operation.");
assert_eq!(documentation.notes, "Additional implementation detail.");
assert_eq!(
documentation.parameters.get("value").map(String::as_str),
Some("Value to process. Continued detail.")
);
assert_eq!(documentation.returns, "The processed value.");
assert_eq!(
documentation.errors,
"Returns `ExampleError` when validation fails."
);
assert_eq!(documentation.examples, "```text\nexample\n```");
}
#[test]
fn removes_only_rustdoc_link_brackets() {
assert_eq!(
remove_rustdoc_link_brackets("Call [`decode`] with `bytes`."),
"Call `decode` with `bytes`."
);
}
#[test]
fn contextualizes_conventional_rustdoc_error_prose() {
assert_eq!(
contextualize_error_description("Returns `ExampleError` when validation fails."),
"The Rust implementation returns `ExampleError` when validation fails."
);
}
}