use buffa::ExtensionSet;
use buffa_descriptor::generated::descriptor::{
DescriptorProto, FieldDescriptorProto, FileDescriptorProto,
};
pub use buffa_descriptor::generated::descriptor::FileDescriptorSet;
const OPTIONS_PROTO: &str = "markdown/options.proto";
const OPTIONS_PROTO_SUFFIX: &str = "/markdown/options.proto";
const WKT_PACKAGE: &str = "google.protobuf";
const WKT_PACKAGE_PREFIX: &str = "google.protobuf.";
const BODY_NUMBER: u32 = 20_260_917;
const BODY: buffa::Extension<buffa::extension::codecs::Bool> =
buffa::Extension::new(BODY_NUMBER, "google.protobuf.FieldOptions");
const MESSAGE_ATTR: &str = "#[derive(::serde_markdown::Markdown)]";
const FIELD_ATTR: &str = "#[markdown(body)]";
pub struct MarkdownBodyAttributes {
message_attributes: Vec<(String, String)>,
field_attributes: Vec<(String, String)>,
}
impl MarkdownBodyAttributes {
pub fn message_attributes(&self) -> &[(String, String)] {
&self.message_attributes
}
pub fn field_attributes(&self) -> &[(String, String)] {
&self.field_attributes
}
}
pub fn annotate_markdown_body(fds: &FileDescriptorSet) -> MarkdownBodyAttributes {
let mut out = MarkdownBodyAttributes {
message_attributes: Vec::new(),
field_attributes: Vec::new(),
};
for file in &fds.file {
if skip_well_known_file(file) || !file_needs_annotation(file) {
continue;
}
let package = file.package.as_deref().unwrap_or("");
for msg in &file.message_type {
walk_message(package, "", msg, &mut out);
}
}
out
}
fn skip_well_known_file(file: &FileDescriptorProto) -> bool {
let package = file.package.as_deref().unwrap_or("");
package == WKT_PACKAGE || package.starts_with(WKT_PACKAGE_PREFIX)
}
fn file_needs_annotation(file: &FileDescriptorProto) -> bool {
file_imports_options(file) || file_has_body_option(file)
}
fn file_imports_options(file: &FileDescriptorProto) -> bool {
file.dependency
.iter()
.chain(file.option_dependency.iter())
.any(|dep| is_markdown_options_import(dep))
}
fn is_markdown_options_import(dep: &str) -> bool {
dep == OPTIONS_PROTO || dep.ends_with(OPTIONS_PROTO_SUFFIX)
}
fn file_has_body_option(file: &FileDescriptorProto) -> bool {
file.message_type.iter().any(message_has_body_option)
}
fn message_has_body_option(msg: &DescriptorProto) -> bool {
msg.field.iter().any(field_has_body) || msg.nested_type.iter().any(message_has_body_option)
}
fn field_has_body(field: &FieldDescriptorProto) -> bool {
field
.options
.as_option()
.is_some_and(|opts| opts.extension(&BODY) == Some(true))
}
fn walk_message(
package: &str,
parent_type: &str,
msg: &DescriptorProto,
out: &mut MarkdownBodyAttributes,
) {
let Some(name) = msg.name.as_deref().filter(|n| !n.is_empty()) else {
return;
};
let type_path = if parent_type.is_empty() {
name.to_owned()
} else {
let mut path = String::with_capacity(parent_type.len() + 1 + name.len());
path.push_str(parent_type);
path.push('.');
path.push_str(name);
path
};
out.message_attributes
.push((qualified_path(package, &type_path), MESSAGE_ATTR.to_owned()));
for field in &msg.field {
if !field_has_body(field) {
continue;
}
let Some(field_name) = field.name.as_deref().filter(|n| !n.is_empty()) else {
continue;
};
let mut field_path = qualified_path(package, &type_path);
field_path.push('.');
field_path.push_str(field_name);
out.field_attributes
.push((field_path, FIELD_ATTR.to_owned()));
}
for nested in &msg.nested_type {
walk_message(package, &type_path, nested, out);
}
}
fn qualified_path(package: &str, type_path: &str) -> String {
let mut path = String::from(".");
if !package.is_empty() {
path.push_str(package);
path.push('.');
}
path.push_str(type_path);
path
}
#[cfg(test)]
mod tests {
use buffa::ExtensionSet;
use buffa_descriptor::generated::descriptor::FieldOptions;
use super::*;
fn field(name: &str, json_name: Option<&str>, body: bool) -> FieldDescriptorProto {
let mut field = FieldDescriptorProto {
name: Some(name.into()),
json_name: json_name.map(str::to_owned),
..Default::default()
};
if body {
let mut opts = FieldOptions::default();
opts.set_extension(&BODY, true);
field.options = opts.into();
}
field
}
fn message(
name: &str,
fields: Vec<FieldDescriptorProto>,
nested: Vec<DescriptorProto>,
) -> DescriptorProto {
DescriptorProto {
name: Some(name.into()),
field: fields,
nested_type: nested,
..Default::default()
}
}
fn testdata_fds() -> FileDescriptorSet {
FileDescriptorSet {
file: vec![
FileDescriptorProto {
name: Some("markdown/testdata/names.proto".into()),
package: Some("markdown.testdata".into()),
dependency: vec![OPTIONS_PROTO.into()],
message_type: vec![
message(
"JsonNames",
vec![
field("published_at", Some("publishedAt"), false),
field("body_note", Some("bodyNote"), true),
],
vec![],
),
message(
"NestedFields",
vec![
field("meta", None, false),
field("draft", None, false),
field("body", None, true),
],
vec![message("Meta", vec![field("author", None, false)], vec![])],
),
],
..Default::default()
},
FileDescriptorProto {
name: Some("google/protobuf/timestamp.proto".into()),
package: Some("google.protobuf".into()),
message_type: vec![message(
"Timestamp",
vec![field("seconds", None, true)],
vec![],
)],
..Default::default()
},
FileDescriptorProto {
name: Some("plain.proto".into()),
package: Some("other.pkg".into()),
message_type: vec![message("Plain", vec![field("x", None, false)], vec![])],
..Default::default()
},
],
..Default::default()
}
}
fn contains_pair(pairs: &[(String, String)], path: &str, attr: &str) -> bool {
pairs.iter().any(|(p, a)| p == path && a == attr)
}
#[test]
fn annotate_marks_owned_messages_and_proto_field_names() {
let attrs = annotate_markdown_body(&testdata_fds());
let messages = attrs.message_attributes();
let fields = attrs.field_attributes();
assert!(contains_pair(
messages,
".markdown.testdata.JsonNames",
MESSAGE_ATTR
));
assert!(contains_pair(
messages,
".markdown.testdata.NestedFields",
MESSAGE_ATTR
));
assert!(contains_pair(
messages,
".markdown.testdata.NestedFields.Meta",
MESSAGE_ATTR
));
assert!(
!messages.iter().any(|(p, _)| p.contains("View")),
"views are not Markdown: {messages:?}"
);
assert!(
!messages.iter().any(|(p, _)| p.contains("google.protobuf")),
"well-known files must not be annotated: {messages:?}"
);
assert!(
!messages.iter().any(|(p, _)| p.contains("Plain")),
"files without options import or body option stay unmarked: {messages:?}"
);
assert!(contains_pair(
fields,
".markdown.testdata.JsonNames.body_note",
FIELD_ATTR
));
assert!(contains_pair(
fields,
".markdown.testdata.NestedFields.body",
FIELD_ATTR
));
assert!(
!fields.iter().any(|(p, _)| p.ends_with(".published_at")
|| p.ends_with(".publishedAt")
|| p.ends_with(".bodyNote")),
"front-matter and json_name must not appear as body field paths: {fields:?}"
);
assert!(
!fields.iter().any(|(p, _)| p.contains("View")),
"views are not Markdown: {fields:?}"
);
assert!(
!fields.iter().any(|(p, _)| p.contains("google.protobuf")),
"well-known body options must not be annotated: {fields:?}"
);
}
#[test]
fn option_dependency_and_empty_package_and_body_without_import() {
let fds = FileDescriptorSet {
file: vec![
FileDescriptorProto {
name: Some("via_option_dep.proto".into()),
package: Some("opt.dep".into()),
option_dependency: vec!["vendor/markdown/options.proto".into()],
message_type: vec![message("Doc", vec![field("n", None, false)], vec![])],
..Default::default()
},
FileDescriptorProto {
name: Some("no_package.proto".into()),
package: None,
message_type: vec![message("Root", vec![field("body", None, true)], vec![])],
..Default::default()
},
],
..Default::default()
};
let attrs = annotate_markdown_body(&fds);
assert!(contains_pair(
attrs.message_attributes(),
".opt.dep.Doc",
MESSAGE_ATTR
));
assert!(contains_pair(
attrs.message_attributes(),
".Root",
MESSAGE_ATTR
));
assert!(contains_pair(
attrs.field_attributes(),
".Root.body",
FIELD_ATTR
));
}
}