use std::{process::Stdio, sync::OnceLock};
use proc_macro2::TokenStream;
use quote::{ToTokens, quote};
use tokio::{process::Command, runtime::Handle};
static DOC_FORMAT_ENABLED: OnceLock<bool> = OnceLock::new();
pub fn init_doc_format(enabled: bool) {
DOC_FORMAT_ENABLED.set(enabled).ok();
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
pub struct Documentation {
lines: Vec<String>,
top_level: bool,
}
#[bon::bon]
impl Documentation {
#[builder]
pub(crate) fn documentation(
summary: Option<&str>,
description: Option<&str>,
method: Option<&http::Method>,
path: Option<&str>,
#[builder(default)] top_level: bool,
) -> Self {
let mut docs = Documentation {
lines: vec![],
top_level,
};
if let Some(s) = summary {
for line in s.lines().filter(|l| !l.trim().is_empty()) {
docs.push(line.trim().to_string());
}
}
if let Some(desc) = description {
if summary.is_some() {
docs.push(String::new());
}
for line in desc.lines() {
docs.push(line.trim().to_string());
}
}
if summary.is_some() || description.is_some() {
docs.push(String::new());
}
if let Some(p) = path
&& let Some(method) = method
{
docs.push(format!("* Path: `{} {}`", method.as_str(), p));
}
docs
}
}
impl Documentation {
#[must_use]
pub fn from_optional(desc: Option<&String>) -> Self {
desc.map_or_else(Self::default, |d| {
let formatted = Self::process_doc_text(d);
Self {
lines: formatted.lines().map(String::from).collect(),
top_level: false,
}
})
}
#[must_use]
pub fn from_lines(lines: impl IntoIterator<Item = impl Into<String>>) -> Self {
Self {
lines: lines.into_iter().map(Into::into).collect(),
top_level: false,
}
}
#[must_use]
pub fn with_top_level(self, top: bool) -> Self {
let mut doc = self;
doc.top_level = top;
doc
}
pub fn push(&mut self, line: impl Into<String>) {
self.lines.push(line.into());
}
pub fn clear(&mut self) {
self.lines.clear();
}
fn process_doc_text(input: &str) -> String {
if *DOC_FORMAT_ENABLED.get().unwrap_or(&false) {
Self::wrap_format_with_mdformat(input).replace("\\n", "\n")
} else {
input.replace("\\n", "\n")
}
}
fn wrap_format_with_mdformat(input: &str) -> String {
tokio::task::block_in_place(|| Handle::current().block_on(Self::build_async_format_with_mdformat(input)))
}
pub(crate) async fn build_async_format_with_mdformat(input: &str) -> String {
if input.len() > 100 {
Self::format_with_mdformat(input).await.unwrap_or_default()
} else {
input.to_string()
}
}
async fn format_with_mdformat(input: &str) -> anyhow::Result<String> {
use tokio::io::AsyncWriteExt;
let mut child = Command::new("mdformat")
.args(["--wrap", "100"])
.args(["--end-of-line", "lf"])
.arg("-")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
{
let mut stdin = child.stdin.take().unwrap();
stdin.write_all(input.as_bytes()).await?;
drop(stdin);
};
let output = child.wait_with_output().await?;
if !output.status.success() {
let stderr = String::from_utf8(output.stderr)?;
anyhow::bail!("mdformat failed: {stderr}");
}
let stdout = String::from_utf8(output.stdout)?;
Ok(stdout)
}
}
impl ToTokens for Documentation {
fn to_tokens(&self, tokens: &mut TokenStream) {
if self.lines.is_empty() {
return;
}
let doc_lines: Vec<TokenStream> = self
.lines
.iter()
.map(|line| {
let line = format!(" {line}");
if self.top_level {
quote! { #![doc = #line] }
} else {
quote! { #[doc = #line] }
}
})
.collect();
quote! { #(#doc_lines)* }.to_tokens(tokens);
}
}
impl PartialEq<Vec<String>> for Documentation {
fn eq(&self, other: &Vec<String>) -> bool {
self.lines == *other
}
}
impl std::fmt::Display for Documentation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
for line in &self.lines {
writeln!(f, "{line}")?;
}
Ok(())
}
}