#![forbid(unsafe_code)]
use crate::util::Verbosity;
use clap::Args;
use std::{collections::HashMap, io::Write as _, path::PathBuf};
#[derive(Args)]
pub struct DocArgs {
#[arg(value_name = "PROTO_FILE", required = true)]
pub protos: Vec<PathBuf>,
#[arg(short = 'I', long = "include", value_name = "DIR")]
pub include: Vec<PathBuf>,
#[arg(short = 'o', long = "output", value_name = "FILE")]
pub output: Option<PathBuf>,
}
type LocationMap = HashMap<Vec<i32>, String>;
pub fn run(args: DocArgs, verbosity: Verbosity) -> Result<(), Box<dyn std::error::Error>> {
for p in &args.protos {
if !p.exists() {
return Err(format!("proto file not found: {}", p.display()).into());
}
}
verbosity.verbose("Compiling proto sources for documentation...");
let fds = oxiproto_build::compile_to_fds(&args.protos, &args.include)?;
let mut buf = String::new();
for file in &fds.file {
render_file(file, &mut buf);
}
match &args.output {
Some(path) => {
let mut f = std::fs::File::create(path)
.map_err(|e| format!("cannot create output file {}: {e}", path.display()))?;
f.write_all(buf.as_bytes())
.map_err(|e| format!("write error for {}: {e}", path.display()))?;
verbosity.info(&format!("Documentation written to {}", path.display()));
}
None => {
print!("{buf}");
}
}
Ok(())
}
fn render_file(file: &prost_types::FileDescriptorProto, buf: &mut String) {
let name = file.name.as_deref().unwrap_or("(unknown)");
if name.starts_with("google/protobuf/") {
return;
}
buf.push_str(&format!("# {name}\n\n"));
let loc_map = build_location_map(file);
for (m, msg) in file.message_type.iter().enumerate() {
render_message(msg, m, &[4, m as i32], &loc_map, buf);
}
for (e, en) in file.enum_type.iter().enumerate() {
render_file_enum(en, e, &loc_map, buf);
}
for (s, svc) in file.service.iter().enumerate() {
render_service(svc, s, &loc_map, buf);
}
}
fn build_location_map(file: &prost_types::FileDescriptorProto) -> LocationMap {
let mut map = LocationMap::new();
if let Some(sci) = &file.source_code_info {
for loc in &sci.location {
let comment = loc.leading_comments.as_deref().unwrap_or("");
if !comment.is_empty() {
map.insert(loc.path.clone(), comment.to_string());
}
}
}
map
}
fn render_message(
msg: &prost_types::DescriptorProto,
_idx: usize,
path: &[i32],
loc_map: &LocationMap,
buf: &mut String,
) {
if msg.options.as_ref().is_some_and(|o| o.map_entry()) {
return;
}
let name = msg.name.as_deref().unwrap_or("(unnamed)");
buf.push_str(&format!("## {name}\n\n"));
if let Some(comment) = loc_map.get(path) {
let cleaned = strip_comment(comment);
if !cleaned.is_empty() {
buf.push_str(&cleaned);
buf.push_str("\n\n");
}
}
if !msg.field.is_empty() {
buf.push_str("### Fields\n\n");
buf.push_str("| Field | Number | Type | Description |\n");
buf.push_str("|-------|--------|------|-------------|\n");
for (f, field) in msg.field.iter().enumerate() {
let mut field_path = path.to_vec();
field_path.push(2);
field_path.push(f as i32);
render_field_row(field, &field_path, loc_map, buf);
}
buf.push('\n');
}
for (e, en) in msg.enum_type.iter().enumerate() {
let mut enum_path = path.to_vec();
enum_path.push(4);
enum_path.push(e as i32);
render_nested_enum(en, &enum_path, loc_map, buf);
}
for (n, nested) in msg.nested_type.iter().enumerate() {
if nested.options.as_ref().is_some_and(|o| o.map_entry()) {
continue;
}
let mut nested_path = path.to_vec();
nested_path.push(3);
nested_path.push(n as i32);
render_message(nested, n, &nested_path, loc_map, buf);
}
}
fn render_field_row(
field: &prost_types::FieldDescriptorProto,
path: &[i32],
loc_map: &LocationMap,
buf: &mut String,
) {
let name = field.name.as_deref().unwrap_or("(unnamed)");
let number = field.number.unwrap_or(0);
let type_str = field_type_str(field);
let comment = loc_map.get(path).map(|c| first_line(c)).unwrap_or_default();
buf.push_str(&format!("| {name} | {number} | {type_str} | {comment} |\n"));
}
fn render_file_enum(
en: &prost_types::EnumDescriptorProto,
e: usize,
loc_map: &LocationMap,
buf: &mut String,
) {
let path = vec![5i32, e as i32];
let name = en.name.as_deref().unwrap_or("(unnamed)");
buf.push_str(&format!("## {name}\n\n"));
if let Some(comment) = loc_map.get(&path) {
let cleaned = strip_comment(comment);
if !cleaned.is_empty() {
buf.push_str(&cleaned);
buf.push_str("\n\n");
}
}
render_enum_values(en, &path, loc_map, buf);
}
fn render_nested_enum(
en: &prost_types::EnumDescriptorProto,
path: &[i32],
loc_map: &LocationMap,
buf: &mut String,
) {
let name = en.name.as_deref().unwrap_or("(unnamed)");
buf.push_str(&format!("## {name}\n\n"));
if let Some(comment) = loc_map.get(path) {
let cleaned = strip_comment(comment);
if !cleaned.is_empty() {
buf.push_str(&cleaned);
buf.push_str("\n\n");
}
}
render_enum_values(en, path, loc_map, buf);
}
fn render_enum_values(
en: &prost_types::EnumDescriptorProto,
enum_path: &[i32],
loc_map: &LocationMap,
buf: &mut String,
) {
if en.value.is_empty() {
return;
}
buf.push_str("### Values\n\n");
buf.push_str("| Value | Number | Description |\n");
buf.push_str("|-------|--------|-------------|\n");
for (v, val) in en.value.iter().enumerate() {
let vname = val.name.as_deref().unwrap_or("(unnamed)");
let vnum = val.number.unwrap_or(0);
let mut val_path = enum_path.to_vec();
val_path.push(2);
val_path.push(v as i32);
let comment = loc_map
.get(&val_path)
.map(|c| first_line(c))
.unwrap_or_default();
buf.push_str(&format!("| {vname} | {vnum} | {comment} |\n"));
}
buf.push('\n');
}
fn render_service(
svc: &prost_types::ServiceDescriptorProto,
s: usize,
loc_map: &LocationMap,
buf: &mut String,
) {
let path = vec![6i32, s as i32];
let name = svc.name.as_deref().unwrap_or("(unnamed)");
buf.push_str(&format!("## {name}\n\n"));
if let Some(comment) = loc_map.get(&path) {
let cleaned = strip_comment(comment);
if !cleaned.is_empty() {
buf.push_str(&cleaned);
buf.push_str("\n\n");
}
}
if svc.method.is_empty() {
return;
}
buf.push_str("### Methods\n\n");
buf.push_str("| Method | Request | Response | Description |\n");
buf.push_str("|--------|---------|----------|-------------|\n");
for (m2, method) in svc.method.iter().enumerate() {
let mname = method.name.as_deref().unwrap_or("(unnamed)");
let input = method
.input_type
.as_deref()
.unwrap_or("?")
.trim_start_matches('.');
let output = method
.output_type
.as_deref()
.unwrap_or("?")
.trim_start_matches('.');
let mut method_path = path.clone();
method_path.push(2);
method_path.push(m2 as i32);
let comment = loc_map
.get(&method_path)
.map(|c| first_line(c))
.unwrap_or_default();
buf.push_str(&format!("| {mname} | {input} | {output} | {comment} |\n"));
}
buf.push('\n');
}
fn field_type_str(field: &prost_types::FieldDescriptorProto) -> String {
use prost_types::field_descriptor_proto::Type;
let t = field.r#type.unwrap_or(Type::String as i32);
if t == Type::Message as i32 || t == Type::Enum as i32 {
return field
.type_name
.as_deref()
.unwrap_or("?")
.trim_start_matches('.')
.to_string();
}
if t == Type::Group as i32 {
return "group".to_string();
}
match t {
x if x == Type::Double as i32 => "double",
x if x == Type::Float as i32 => "float",
x if x == Type::Int64 as i32 => "int64",
x if x == Type::Uint64 as i32 => "uint64",
x if x == Type::Int32 as i32 => "int32",
x if x == Type::Fixed64 as i32 => "fixed64",
x if x == Type::Fixed32 as i32 => "fixed32",
x if x == Type::Bool as i32 => "bool",
x if x == Type::String as i32 => "string",
x if x == Type::Bytes as i32 => "bytes",
x if x == Type::Uint32 as i32 => "uint32",
x if x == Type::Sfixed32 as i32 => "sfixed32",
x if x == Type::Sfixed64 as i32 => "sfixed64",
x if x == Type::Sint32 as i32 => "sint32",
x if x == Type::Sint64 as i32 => "sint64",
_ => "unknown",
}
.to_string()
}
fn strip_comment(s: &str) -> String {
s.lines()
.map(|line| {
let stripped = line.strip_prefix(' ').unwrap_or(line);
stripped.trim_end()
})
.collect::<Vec<_>>()
.join("\n")
.trim_end()
.to_string()
}
fn first_line(s: &str) -> String {
s.lines()
.map(|line| {
let stripped = line.strip_prefix(' ').unwrap_or(line);
stripped.trim()
})
.find(|line| !line.is_empty())
.unwrap_or("")
.to_string()
}