use super::content::parse_content;
use super::utils::{extract_attr, find_closing_tag};
use crate::parser::types::*;
pub(super) fn try_parse_param_field(content: &str) -> Option<(DocNode, &str)> {
if !content.starts_with("<ParamField") {
return None;
}
let tag_end = content.find('>')?;
let tag_content = &content[11..tag_end];
let (name, location) = if let Some(name) = extract_attr(tag_content, "header") {
(name, ParamLocation::Header)
} else if let Some(name) = extract_attr(tag_content, "path") {
(name, ParamLocation::Path)
} else if let Some(name) = extract_attr(tag_content, "query") {
(name, ParamLocation::Query)
} else if let Some(name) = extract_attr(tag_content, "body") {
(name, ParamLocation::Body)
} else {
return None;
};
let param_type = extract_attr(tag_content, "type").unwrap_or_else(|| "string".to_string());
let required = tag_content.contains("required");
let default = extract_attr(tag_content, "default");
if tag_content.trim().ends_with('/') {
return Some((
DocNode::ParamField(ParamFieldNode {
name,
location,
param_type,
required,
default,
content: Vec::new(),
}),
&content[tag_end + 1..],
));
}
let after_open = &content[tag_end + 1..];
let close_idx = find_closing_tag(after_open, "ParamField")?;
let inner = after_open[..close_idx].trim();
let rest = &after_open[close_idx + "</ParamField>".len()..];
let parsed_content = parse_content(inner);
Some((
DocNode::ParamField(ParamFieldNode {
name,
location,
param_type,
required,
default,
content: parsed_content,
}),
rest,
))
}
pub(super) fn try_parse_response_field(content: &str) -> Option<(DocNode, &str)> {
if !content.starts_with("<ResponseField") {
return None;
}
let tag_end = content.find('>')?;
let tag_content = &content[14..tag_end];
if tag_content.trim().ends_with('/') {
let name = extract_attr(tag_content, "name")?;
let field_type = extract_attr(tag_content, "type").unwrap_or_else(|| "any".to_string());
let required = tag_content.contains("required");
return Some((
DocNode::ResponseField(ResponseFieldNode {
name,
field_type,
required,
content: String::new(),
expandable: None,
}),
&content[tag_end + 1..],
));
}
let name = extract_attr(tag_content, "name")?;
let field_type = extract_attr(tag_content, "type").unwrap_or_else(|| "any".to_string());
let required = tag_content.contains("required");
let after_open = &content[tag_end + 1..];
let close_idx = find_closing_tag(after_open, "ResponseField")?;
let inner = &after_open[..close_idx];
let rest = &after_open[close_idx + "</ResponseField>".len()..];
let expandable = parse_nested_expandable(inner);
let content_text = if expandable.is_some() {
if let Some(exp_start) = inner.find("<Expandable") {
inner[..exp_start].trim().to_string()
} else {
inner.trim().to_string()
}
} else {
inner.trim().to_string()
};
Some((
DocNode::ResponseField(ResponseFieldNode {
name,
field_type,
required,
content: content_text,
expandable,
}),
rest,
))
}
pub(super) fn try_parse_expandable(content: &str) -> Option<(DocNode, &str)> {
if !content.starts_with("<Expandable") {
return None;
}
let tag_end = content.find('>')?;
let tag_content = &content[11..tag_end];
let title = extract_attr(tag_content, "title").unwrap_or_else(|| "Details".to_string());
let after_open = &content[tag_end + 1..];
let close_idx = find_closing_tag(after_open, "Expandable")?;
let inner = &after_open[..close_idx];
let rest = &after_open[close_idx + "</Expandable>".len()..];
let fields = parse_response_fields(inner);
Some((DocNode::Expandable(ExpandableNode { title, fields }), rest))
}
fn parse_nested_expandable(content: &str) -> Option<ExpandableNode> {
let start = content.find("<Expandable")?;
let tag_end = content[start..].find('>')? + start;
let tag_content = &content[start + 11..tag_end];
let title = extract_attr(tag_content, "title").unwrap_or_else(|| "Properties".to_string());
let after_open = &content[tag_end + 1..];
let close_idx = find_closing_tag(after_open, "Expandable")?;
let inner = &after_open[..close_idx];
let fields = parse_response_fields(inner);
Some(ExpandableNode { title, fields })
}
fn parse_response_fields(content: &str) -> Vec<ResponseFieldNode> {
let mut fields = Vec::new();
let mut remaining = content.trim();
while !remaining.is_empty() {
remaining = remaining.trim();
if remaining.starts_with("<ResponseField")
&& let Some((DocNode::ResponseField(field), rest)) = try_parse_response_field(remaining)
{
fields.push(field);
remaining = rest;
continue;
}
if let Some(idx) = remaining[1..].find("<ResponseField") {
remaining = &remaining[idx + 1..];
} else {
break;
}
}
fields
}
#[cfg(test)]
mod tests {
use crate::parser::content::parse_mdx;
use crate::parser::types::*;
#[test]
fn test_parse_param_field_path() {
let content = r#"<ParamField path="project_id" type="string" required>
The project identifier.
</ParamField>"#;
let nodes = parse_mdx(content);
assert_eq!(nodes.len(), 1);
if let DocNode::ParamField(f) = &nodes[0] {
assert_eq!(f.name, "project_id");
assert_eq!(f.location, ParamLocation::Path);
assert_eq!(f.param_type, "string");
assert!(f.required);
assert_eq!(f.content.len(), 1);
if let DocNode::Markdown(md) = &f.content[0] {
assert!(md.contains("project identifier"));
} else {
panic!("Expected Markdown node in content");
}
} else {
panic!("Expected ParamField node");
}
}
#[test]
fn test_parse_param_field_header() {
let content = r#"<ParamField header="X-API-Key" type="string" required>
Your API key.
</ParamField>"#;
let nodes = parse_mdx(content);
assert_eq!(nodes.len(), 1);
if let DocNode::ParamField(f) = &nodes[0] {
assert_eq!(f.name, "X-API-Key");
assert_eq!(f.location, ParamLocation::Header);
assert!(f.required);
assert!(!f.content.is_empty());
} else {
panic!("Expected ParamField node");
}
}
#[test]
fn test_parse_param_field_with_default() {
let content = r#"<ParamField query="limit" type="integer" default="10">
Items per page.
</ParamField>"#;
let nodes = parse_mdx(content);
assert_eq!(nodes.len(), 1);
if let DocNode::ParamField(f) = &nodes[0] {
assert_eq!(f.name, "limit");
assert_eq!(f.location, ParamLocation::Query);
assert_eq!(f.default, Some("10".to_string()));
assert!(!f.required);
} else {
panic!("Expected ParamField node");
}
}
#[test]
fn test_parse_response_field_simple() {
let content = r#"<ResponseField name="id" type="string" required>
The unique identifier.
</ResponseField>"#;
let nodes = parse_mdx(content);
assert_eq!(nodes.len(), 1);
if let DocNode::ResponseField(f) = &nodes[0] {
assert_eq!(f.name, "id");
assert_eq!(f.field_type, "string");
assert!(f.required);
assert!(f.expandable.is_none());
} else {
panic!("Expected ResponseField node");
}
}
#[test]
fn test_parse_response_field_with_expandable() {
let content = r#"<ResponseField name="data" type="object">
The response data.
<Expandable title="Data properties">
<ResponseField name="id" type="string">The ID</ResponseField>
<ResponseField name="name" type="string">The name</ResponseField>
</Expandable>
</ResponseField>"#;
let nodes = parse_mdx(content);
assert_eq!(nodes.len(), 1);
if let DocNode::ResponseField(f) = &nodes[0] {
assert_eq!(f.name, "data");
assert_eq!(f.field_type, "object");
assert!(f.expandable.is_some());
let exp = f.expandable.as_ref().unwrap();
assert_eq!(exp.title, "Data properties");
assert_eq!(exp.fields.len(), 2);
assert_eq!(exp.fields[0].name, "id");
assert_eq!(exp.fields[1].name, "name");
} else {
panic!("Expected ResponseField node");
}
}
#[test]
fn test_multiple_param_fields() {
let content = r#"<ParamField header="Authorization" type="string" required>
Bearer token.
</ParamField>
<ParamField path="project_id" type="string" required>
Project ID.
</ParamField>
<ParamField query="page" type="integer" default="1">
Page number.
</ParamField>"#;
let nodes = parse_mdx(content);
assert_eq!(nodes.len(), 3);
assert!(matches!(&nodes[0], DocNode::ParamField(_)));
assert!(matches!(&nodes[1], DocNode::ParamField(_)));
assert!(matches!(&nodes[2], DocNode::ParamField(_)));
}
#[test]
fn test_nested_note_in_param_field() {
let content = r#"<ParamField path="data-button-position" type="string" default="bottom-right">
Control where the feedback button appears.
<Note>
The `icon-only` position is perfect for minimalist designs.
</Note>
</ParamField>"#;
let nodes = parse_mdx(content);
assert_eq!(nodes.len(), 1);
if let DocNode::ParamField(f) = &nodes[0] {
assert_eq!(f.name, "data-button-position");
assert!(f.content.len() >= 2);
let has_note = f
.content
.iter()
.any(|n| matches!(n, DocNode::Callout(c) if c.callout_type == CalloutType::Note));
assert!(
has_note,
"Expected nested Note callout in ParamField content"
);
} else {
panic!("Expected ParamField node");
}
}
#[test]
fn test_nested_card_in_param_field() {
let content = r#"<ParamField path="data-language" type="string" default="auto-detect">
Set the interface language.
<Card title="Multi-language support" icon="globe" href="/widget/i18n">
Learn more about internationalization
</Card>
</ParamField>"#;
let nodes = parse_mdx(content);
assert_eq!(nodes.len(), 1);
if let DocNode::ParamField(f) = &nodes[0] {
assert_eq!(f.name, "data-language");
let has_card = f.content.iter().any(|n| matches!(n, DocNode::CardGroup(_)));
assert!(has_card, "Expected nested Card in ParamField content");
} else {
panic!("Expected ParamField node");
}
}
}