use std::collections::HashMap;
use std::ops::Range;
use crate::error::PageError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedComponent {
pub name: String,
pub attrs: HashMap<String, String>,
pub inner: String,
pub self_closing: bool,
pub span: Range<usize>,
}
pub fn is_component_name(name: &str) -> bool {
let mut chars = name.chars();
let Some(first) = chars.next() else {
return false;
};
if !first.is_ascii_uppercase() {
return false;
}
name.chars().all(|c| c.is_ascii_alphanumeric())
}
pub fn find_next_component(source: &str, from: usize) -> Option<ParsedComponent> {
let bytes = source.as_bytes();
let mut index = from;
while index < bytes.len() {
if bytes[index] != b'<' {
index += 1;
continue;
}
if let Some(component) = try_parse_component(source, index) {
return Some(component);
}
index += 1;
}
None
}
fn try_parse_component(source: &str, open: usize) -> Option<ParsedComponent> {
let rest = &source[open + 1..];
if rest.starts_with('/') || rest.starts_with('!') {
return None;
}
let (name, after_name) = read_identifier(rest)?;
if !is_component_name(name) {
return None;
}
let (attrs, after_attrs) = parse_attributes(after_name)?;
let after_attrs = skip_whitespace(after_attrs);
if after_attrs.starts_with("/>") {
let end = open + 1 + (after_attrs.as_ptr() as usize - rest.as_ptr() as usize) + 2;
return Some(ParsedComponent {
name: name.to_owned(),
attrs,
inner: String::new(),
self_closing: true,
span: open..end,
});
}
if !after_attrs.starts_with('>') {
return None;
}
let content_start = open + 1 + (after_attrs.as_ptr() as usize - rest.as_ptr() as usize) + 1;
let close_tag = format!("</{name}>");
let (inner, close_start) = read_until_matching_close(source, content_start, name).ok()?;
let end = close_start + close_tag.len();
Some(ParsedComponent {
name: name.to_owned(),
attrs,
inner,
self_closing: false,
span: open..end,
})
}
fn read_identifier(input: &str) -> Option<(&str, &str)> {
let mut end = 0;
for (i, ch) in input.char_indices() {
if ch.is_ascii_alphanumeric() {
end = i + ch.len_utf8();
} else {
break;
}
}
if end == 0 {
return None;
}
Some((&input[..end], &input[end..]))
}
fn skip_whitespace(input: &str) -> &str {
input.trim_start()
}
fn parse_attributes(input: &str) -> Option<(HashMap<String, String>, &str)> {
let mut attrs = HashMap::new();
let mut cursor = skip_whitespace(input);
loop {
cursor = skip_whitespace(cursor);
if cursor.is_empty() || cursor.starts_with('>') || cursor.starts_with('/') {
return Some((attrs, cursor));
}
let (key, after_key) = read_attribute_key(cursor)?;
cursor = skip_whitespace(after_key);
if cursor.starts_with('=') {
cursor = skip_whitespace(&cursor[1..]);
let (value, after_value) = read_attribute_value(cursor)?;
attrs.insert(key.to_owned(), value);
cursor = after_value;
} else {
attrs.insert(key.to_owned(), "true".to_owned());
}
}
}
fn read_attribute_key(input: &str) -> Option<(&str, &str)> {
let mut end = 0;
for (i, ch) in input.char_indices() {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
end = i + ch.len_utf8();
} else {
break;
}
}
if end == 0 {
return None;
}
Some((&input[..end], &input[end..]))
}
fn read_attribute_value(input: &str) -> Option<(String, &str)> {
if let Some(rest) = input.strip_prefix('"') {
let end = rest.find('"')?;
return Some((rest[..end].to_owned(), &rest[end + 1..]));
}
if let Some(rest) = input.strip_prefix('\'') {
let end = rest.find('\'')?;
return Some((rest[..end].to_owned(), &rest[end + 1..]));
}
let end = input
.find(|c: char| c.is_whitespace() || c == '>' || c == '/')
.unwrap_or(input.len());
Some((input[..end].to_owned(), &input[end..]))
}
fn read_until_matching_close(
source: &str,
start: usize,
name: &str,
) -> Result<(String, usize), ()> {
let open_needle = format!("<{name}");
let close_tag = format!("</{name}>");
let mut depth = 0;
let mut cursor = start;
while cursor < source.len() {
let Some(next_open) = source[cursor..].find('<') else {
break;
};
let abs = cursor + next_open;
if source[abs..].starts_with(&close_tag) {
if depth == 0 {
let inner = source[start..abs].to_owned();
return Ok((inner, abs));
}
depth -= 1;
cursor = abs + close_tag.len();
continue;
}
if source[abs..].starts_with(&open_needle) {
let after = &source[abs + open_needle.len()..];
let boundary = after
.chars()
.next()
.is_none_or(|c| c.is_whitespace() || c == '>' || c == '/');
if boundary {
depth += 1;
}
}
cursor = abs + 1;
}
Err(())
}
pub fn validate_component(
component: &ParsedComponent,
path: &std::path::Path,
) -> Result<(), PageError> {
if component.name.is_empty() {
return Err(PageError::new(
path,
"encountered empty component tag name".to_owned(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_pascal_case_names() {
assert!(is_component_name("Alert"));
assert!(is_component_name("Button"));
assert!(!is_component_name("div"));
assert!(!is_component_name(""));
}
#[test]
fn parses_self_closing_component() {
let source = r#"Intro <Button href="/docs" label="Go" /> tail"#;
let component = find_next_component(source, 0).unwrap();
assert_eq!(component.name, "Button");
assert!(component.self_closing);
assert_eq!(component.attrs.get("href"), Some(&"/docs".to_owned()));
assert_eq!(component.attrs.get("label"), Some(&"Go".to_owned()));
}
#[test]
fn parses_block_component_with_attributes() {
let source = r#"<Alert type="warning" title="Heads up">Hello **world**</Alert>"#;
let component = find_next_component(source, 0).unwrap();
assert_eq!(component.name, "Alert");
assert!(!component.self_closing);
assert_eq!(component.inner, "Hello **world**");
assert_eq!(component.attrs.get("type"), Some(&"warning".to_owned()));
}
#[test]
fn ignores_lowercase_html_tags() {
let source = "<div class=\"x\">plain</div>";
assert!(find_next_component(source, 0).is_none());
}
#[test]
fn parses_nested_components_inner_first() {
let source = r#"<Card title="Outer"><Alert type="info">Nested</Alert></Card>"#;
let outer = find_next_component(source, 0).unwrap();
assert_eq!(outer.name, "Card");
assert!(outer.inner.contains("<Alert"));
let inner = find_next_component(&outer.inner, 0).unwrap();
assert_eq!(inner.name, "Alert");
}
}