use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum FieldType {
Bool,
Float,
Integer,
Str,
Enum(Vec<String>),
Object,
Named(String),
NamedEnum(String),
Array {
elem: Box<FieldType>,
len: Option<usize>,
},
}
pub(super) struct FieldEntry {
pub key: String,
pub ty: FieldType,
pub optional: bool,
pub default: Option<String>,
pub doc: String,
}
pub(super) struct EnumValue {
pub value: String,
pub doc: String,
}
pub(super) fn slug(name: &str) -> String {
name.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-')
.collect::<String>()
.to_lowercase()
}
fn doc_link(name: &str) -> String {
format!("{name}.md")
}
fn one_of(values: &[String]) -> String {
let ticked: Vec<String> = values.iter().map(|v| format!("`{v}`")).collect();
match ticked.as_slice() {
[] => String::new(),
[a] => a.clone(),
[a, b] => format!("{a} or {b}"),
[rest @ .., last] => format!("{}, or {}", rest.join(", "), last),
}
}
fn elem_plural(t: &FieldType) -> String {
match t {
FieldType::Bool => "booleans".to_string(),
FieldType::Float => "floats".to_string(),
FieldType::Integer => "integers".to_string(),
FieldType::Str => "strings".to_string(),
FieldType::Enum(values) => format!("strings (each one of {})", one_of(values)),
FieldType::Object => "objects".to_string(),
FieldType::Named(name) => format!("[{name}]({}) objects", doc_link(name)),
FieldType::NamedEnum(name) => format!("strings (see [{name}]({}))", doc_link(name)),
FieldType::Array { elem, len } => match len {
Some(n) => format!("arrays of {n} {}", elem_plural(elem)),
None => format!("arrays of {}", elem_plural(elem)),
},
}
}
pub(super) fn type_phrase(t: &FieldType) -> String {
match t {
FieldType::Bool => "A boolean".to_string(),
FieldType::Float => "A float".to_string(),
FieldType::Integer => "An integer".to_string(),
FieldType::Str => "A string".to_string(),
FieldType::Enum(values) => format!("A string (one of {})", one_of(values)),
FieldType::Object => "An object".to_string(),
FieldType::Named(name) => format!("A [{name}]({}) object", doc_link(name)),
FieldType::NamedEnum(name) => format!("A string (see [{name}]({}))", doc_link(name)),
FieldType::Array { elem, len } => match len {
Some(n) => format!("An array of {n} {}", elem_plural(elem)),
None => format!("An array of {}", elem_plural(elem)),
},
}
}
fn doc_states_default(doc_lower: &str) -> bool {
doc_lower.contains("default")
}
fn doc_states_optional(doc_lower: &str) -> bool {
[
"default",
"if omitted",
"when absent",
"when no",
"when unset",
"optional",
"unset",
"or null",
"leave",
"omit",
]
.iter()
.any(|needle| doc_lower.contains(needle))
}
pub(super) fn render_field_bullet(f: &FieldEntry) -> String {
let mut s = format!("- `{}`: {}.", f.key, type_phrase(&f.ty));
let doc = f.doc.trim();
if !doc.is_empty() {
s.push(' ');
s.push_str(doc);
if !doc.ends_with(['.', '!', '?', ':']) {
s.push('.');
}
}
let lower = doc.to_lowercase();
match &f.default {
Some(def) if def != "null" && !doc_states_default(&lower) => {
s.push_str(&format!(" Defaults to `{def}`."));
}
_ if f.optional && !doc_states_optional(&lower) => {
s.push_str(" Optional.");
}
_ => {}
}
s
}
pub(super) fn render_parameters(fields: &[FieldEntry]) -> String {
if fields.is_empty() {
return String::new();
}
let mut s = String::from("## Parameters\n\n");
for f in fields {
s.push_str(&render_field_bullet(f));
s.push('\n');
}
s
}
pub(super) fn render_values(values: &[EnumValue]) -> String {
if values.is_empty() {
return String::new();
}
let mut s = String::from("## Values\n\n");
for v in values {
s.push_str(&format!("- `{}`", v.value));
let doc = v.doc.trim();
if !doc.is_empty() {
s.push_str(": ");
s.push_str(doc);
if !doc.ends_with(['.', '!', '?', ':']) {
s.push('.');
}
}
s.push('\n');
}
s
}
pub(super) fn rewrite_doc_links(doc: &str, name_for_slug: &HashMap<String, String>) -> String {
let names: std::collections::HashSet<&str> =
name_for_slug.values().map(String::as_str).collect();
rewrite_shortcut_links(&rewrite_anchor_links(doc, name_for_slug), &names)
}
fn rewrite_anchor_links(doc: &str, name_for_slug: &HashMap<String, String>) -> String {
const NEEDLE: &str = "](#";
let mut out = String::with_capacity(doc.len());
let mut rest = doc;
while let Some(pos) = rest.find(NEEDLE) {
let after = &rest[pos + NEEDLE.len()..];
if let Some(end) = after.find(')') {
let anchor = &after[..end];
if let Some(name) = name_for_slug.get(anchor) {
out.push_str(&rest[..pos]);
out.push_str("](");
out.push_str(&doc_link(name));
out.push(')');
rest = &after[end + 1..];
continue;
}
}
out.push_str(&rest[..pos + NEEDLE.len()]);
rest = &rest[pos + NEEDLE.len()..];
}
out.push_str(rest);
out
}
fn rewrite_shortcut_links(doc: &str, names: &std::collections::HashSet<&str>) -> String {
let mut out = String::with_capacity(doc.len());
let mut rest = doc;
let mut prev = '\0';
while let Some(pos) = rest.find('[') {
out.push_str(&rest[..pos]);
if pos > 0 {
prev = rest[..pos].chars().next_back().unwrap();
}
let after_open = &rest[pos + 1..];
let handled = match after_open.find(']') {
Some(end) => {
let inner = &after_open[..end];
let next = after_open[end + 1..].chars().next().unwrap_or(' ');
if names.contains(inner) && prev != ']' && next != '(' && next != '[' && next != ':'
{
out.push('[');
out.push_str(inner);
out.push_str("](");
out.push_str(&doc_link(inner));
out.push(')');
prev = ')';
rest = &after_open[end + 1..];
true
} else {
false
}
}
None => false,
};
if !handled {
out.push('[');
prev = '[';
rest = after_open;
}
}
out.push_str(rest);
out
}
#[cfg(test)]
mod tests {
use super::*;
fn field(
key: &str,
ty: FieldType,
optional: bool,
default: Option<&str>,
doc: &str,
) -> FieldEntry {
FieldEntry {
key: key.to_string(),
ty,
optional,
default: default.map(str::to_string),
doc: doc.to_string(),
}
}
#[test]
fn type_phrase_covers_scalars_and_enums() {
assert_eq!(type_phrase(&FieldType::Bool), "A boolean");
assert_eq!(type_phrase(&FieldType::Float), "A float");
assert_eq!(type_phrase(&FieldType::Integer), "An integer");
assert_eq!(type_phrase(&FieldType::Str), "A string");
assert_eq!(type_phrase(&FieldType::Object), "An object");
assert_eq!(
type_phrase(&FieldType::Array {
elem: Box::new(FieldType::Object),
len: None,
}),
"An array of objects"
);
assert_eq!(
type_phrase(&FieldType::Enum(vec!["vertex".into(), "fragment".into()])),
"A string (one of `vertex` or `fragment`)"
);
assert_eq!(
type_phrase(&FieldType::Enum(vec![
"quality".into(),
"balanced".into(),
"performance".into()
])),
"A string (one of `quality`, `balanced`, or `performance`)"
);
}
#[test]
fn type_phrase_covers_arrays_and_named() {
let arr4 = FieldType::Array {
elem: Box::new(FieldType::Float),
len: Some(4),
};
assert_eq!(type_phrase(&arr4), "An array of 4 floats");
let vec_str = FieldType::Array {
elem: Box::new(FieldType::Str),
len: None,
};
assert_eq!(type_phrase(&vec_str), "An array of strings");
let vec_named = FieldType::Array {
elem: Box::new(FieldType::Named("WaterWave".into())),
len: None,
};
assert_eq!(
type_phrase(&vec_named),
"An array of [WaterWave](WaterWave.md) objects"
);
assert_eq!(
type_phrase(&FieldType::Named("PropCollider".into())),
"A [PropCollider](PropCollider.md) object"
);
assert_eq!(
type_phrase(&FieldType::NamedEnum("ShaderKind".into())),
"A string (see [ShaderKind](ShaderKind.md))"
);
let nested = FieldType::Array {
elem: Box::new(FieldType::Array {
elem: Box::new(FieldType::Float),
len: Some(2),
}),
len: None,
};
assert_eq!(type_phrase(&nested), "An array of arrays of 2 floats");
}
#[test]
fn slug_matches_lowercase_anchor() {
assert_eq!(slug("PropCollider"), "propcollider");
assert_eq!(slug("Camera3D"), "camera3d");
}
#[test]
fn bullet_appends_default_when_doc_silent() {
let b = render_field_bullet(&field(
"frames_in_flight",
FieldType::Integer,
false,
Some("2"),
"Preferred number of frames in flight",
));
assert_eq!(
b,
"- `frames_in_flight`: An integer. Preferred number of frames in flight. Defaults to `2`."
);
}
#[test]
fn bullet_skips_default_when_doc_mentions_it() {
let b = render_field_bullet(&field(
"validation",
FieldType::Bool,
true,
Some("null"),
"Enable validation. Defaults to true in debug builds.",
));
assert_eq!(
b,
"- `validation`: A boolean. Enable validation. Defaults to true in debug builds."
);
}
#[test]
fn bullet_marks_optional_when_null_default() {
let b = render_field_bullet(&field(
"collider",
FieldType::Named("PropCollider".into()),
true,
Some("null"),
"Collision volume",
));
assert_eq!(
b,
"- `collider`: A [PropCollider](PropCollider.md) object. Collision volume. Optional."
);
}
#[test]
fn parameters_section_empty_for_no_fields() {
assert_eq!(render_parameters(&[]), "");
}
#[test]
fn values_section_renders_each_value() {
let vals = vec![
EnumValue {
value: "left".into(),
doc: "Pack against the left edge".into(),
},
EnumValue {
value: "center".into(),
doc: String::new(),
},
];
let s = render_values(&vals);
assert!(s.starts_with("## Values\n\n"));
assert!(s.contains("- `left`: Pack against the left edge."));
assert!(s.contains("- `center`\n"));
}
#[test]
fn values_section_empty_for_no_values() {
assert_eq!(render_values(&[]), "");
}
#[test]
fn rewrite_doc_links_resolves_known_anchors_only() {
let mut map = HashMap::new();
map.insert("audioemitter".to_string(), "AudioEmitter".to_string());
map.insert("camera3d".to_string(), "Camera3D".to_string());
let doc = "Played by an [AudioEmitter](#audioemitter); see [Camera3D](#camera3d) \
and an [Unknown](#unknown) anchor.";
let out = rewrite_doc_links(doc, &map);
assert!(out.contains("[AudioEmitter](AudioEmitter.md)"));
assert!(out.contains("[Camera3D](Camera3D.md)"));
assert!(out.contains("[Unknown](#unknown)"));
}
#[test]
fn rewrite_doc_links_leaves_relative_links_alone() {
let map = HashMap::new();
let doc = "A [PropCollider](PropCollider.md) object.";
assert_eq!(rewrite_doc_links(doc, &map), doc);
}
#[test]
fn rewrite_doc_links_rewrites_shortcut_type_links() {
let mut map = HashMap::new();
map.insert("shadowupdate".to_string(), "ShadowUpdate".to_string());
let doc = "How often. See [ShadowUpdate]. Also [ShadowUpdate](ShadowUpdate.md) stays.";
let out = rewrite_doc_links(doc, &map);
assert!(out.contains("See [ShadowUpdate](ShadowUpdate.md)."));
assert!(!out.contains(".md.md"));
assert!(!out.contains(".md)](ShadowUpdate.md)"));
}
#[test]
fn rewrite_doc_links_skips_non_type_brackets() {
let mut map = HashMap::new();
map.insert("prop".to_string(), "Prop".to_string());
let doc = "An array [0, 1] and a [Prop] and a label [text][Prop].";
let out = rewrite_doc_links(doc, &map);
assert!(out.contains("and a [Prop](Prop.md) and"));
assert!(out.contains("[0, 1]")); assert!(out.contains("[text][Prop]")); }
}