use crate::parser::{HsmlNode, RootNode, tag::node::TagNode};
enum AttrItem {
Attr { text: String, end_line: u32 },
Comment { text: String, start_line: u32 },
}
pub struct FormatOptions {
pub indent_size: usize,
pub print_width: usize,
}
impl Default for FormatOptions {
fn default() -> Self {
Self {
indent_size: 2,
print_width: 80,
}
}
}
fn node_start_line(node: &HsmlNode) -> Option<u32> {
match node {
HsmlNode::Tag(tag) => Some(tag.location.start.line),
HsmlNode::Comment(comment) => Some(comment.location.start.line),
_ => None,
}
}
fn node_end_line(node: &HsmlNode) -> Option<u32> {
match node {
HsmlNode::Tag(tag) => {
let mut last = tag.location.start.line;
if let Some(children) = &tag.children
&& let Some(child) = children.last()
&& let Some(child_end) = node_end_line(child)
{
last = last.max(child_end);
}
if let Some(text) = &tag.text
&& text.is_block
{
last += text.text.lines().count() as u32;
}
if let Some(attrs) = &tag.attributes
&& let Some(last_attr) = attrs.last()
{
let attr_end = match last_attr {
HsmlNode::Attribute(a) => a.location.end.line,
HsmlNode::Comment(c) => c.location.end.line,
_ => 0,
};
if attr_end > last {
last = attr_end + 1;
}
}
Some(last)
}
HsmlNode::Comment(comment) => Some(comment.location.end.line),
_ => None,
}
}
pub fn format(ast: &RootNode, options: &FormatOptions) -> String {
let mut output = String::new();
format_nodes(&ast.nodes, 0, options, &mut output);
let lines: Vec<&str> = output.lines().collect();
let mut collapsed = String::new();
let mut prev_blank = false;
for line in &lines {
let is_blank = line.trim().is_empty();
if is_blank && prev_blank {
continue;
}
if !collapsed.is_empty() {
collapsed.push('\n');
}
if !is_blank {
collapsed.push_str(line);
}
prev_blank = is_blank;
}
output = collapsed;
if !output.ends_with('\n') {
output.push('\n');
}
output
}
fn format_nodes(nodes: &[HsmlNode], depth: usize, options: &FormatOptions, output: &mut String) {
for (i, node) in nodes.iter().enumerate() {
if i > 0
&& let (Some(prev_end), Some(current_start)) =
(node_end_line(&nodes[i - 1]), node_start_line(node))
&& current_start > prev_end + 1
{
output.push('\n');
}
format_node(node, depth, options, output);
}
}
fn format_node(node: &HsmlNode, depth: usize, options: &FormatOptions, output: &mut String) {
match node {
HsmlNode::Tag(tag) => format_tag(tag, depth, options, output),
HsmlNode::Comment(comment) => {
let indent = " ".repeat(depth * options.indent_size);
if comment.is_dev {
output.push_str(&format!("{indent}//{}\n", comment.text));
} else {
output.push_str(&format!("{indent}//!{}\n", comment.text));
}
}
HsmlNode::Doctype(doctype) => {
output.push_str(&format!("doctype {}\n", doctype.doctype));
}
_ => {}
}
}
fn format_tag(tag: &TagNode, depth: usize, options: &FormatOptions, output: &mut String) {
let indent = " ".repeat(depth * options.indent_size);
let mut line = String::new();
let has_selectors = !tag.ids.is_empty() || tag.classes.as_ref().is_some_and(|c| !c.is_empty());
if tag.tag != "div" || !has_selectors {
line.push_str(&tag.tag);
}
for id in &tag.ids {
line.push('#');
line.push_str(&id.id);
}
if let Some(classes) = &tag.classes {
for class in classes {
line.push('.');
line.push_str(&class.name);
}
}
if let Some(attributes) = &tag.attributes {
let items: Vec<AttrItem> = attributes
.iter()
.filter_map(|node| match node {
HsmlNode::Attribute(attr) => {
let s = if let Some(ref value) = attr.value {
format!("{}=\"{}\"", attr.key, value)
} else {
attr.key.clone()
};
Some(AttrItem::Attr {
text: s,
end_line: attr.location.end.line,
})
}
HsmlNode::Comment(comment) if comment.is_dev => {
let text = comment.text.trim_end_matches(',');
Some(AttrItem::Comment {
text: format!("//{text}"),
start_line: comment.location.start.line,
})
}
_ => None,
})
.collect();
let has_comments = items.iter().any(|i| matches!(i, AttrItem::Comment { .. }));
let attr_only: Vec<&str> = items
.iter()
.filter_map(|i| match i {
AttrItem::Attr { text, .. } => Some(text.as_str()),
_ => None,
})
.collect();
if !items.is_empty() {
let single_line = format!("({})", attr_only.join(", "));
let full_line_len = indent.len() + line.len() + single_line.len();
if !has_comments && full_line_len <= options.print_width {
line.push_str(&single_line);
} else {
line.push_str("(\n");
let attr_indent = " ".repeat((depth + 1) * options.indent_size);
let mut attr_iter = items.iter().peekable();
while let Some(item) = attr_iter.next() {
line.push_str(&attr_indent);
match item {
AttrItem::Attr { text: s, end_line } => {
line.push_str(s);
let is_trailing = matches!(
attr_iter.peek(),
Some(AttrItem::Comment { start_line, .. }) if *start_line == *end_line
);
let has_more_attrs = if is_trailing {
attr_iter
.clone()
.skip(1)
.any(|i| matches!(i, AttrItem::Attr { .. }))
} else {
attr_iter
.clone()
.any(|i| matches!(i, AttrItem::Attr { .. }))
};
if has_more_attrs {
line.push(',');
}
if is_trailing
&& let Some(AttrItem::Comment { text: c, .. }) = attr_iter.next()
{
line.push(' ');
line.push_str(c);
}
}
AttrItem::Comment { text: s, .. } => {
line.push_str(s);
}
}
line.push('\n');
}
let closing_indent = " ".repeat(depth * options.indent_size);
line.push_str(&closing_indent);
line.push(')');
}
}
}
if let Some(text) = &tag.text {
let use_block =
text.is_block || indent.len() + line.len() + 1 + text.text.len() > options.print_width;
if use_block {
write_text_block(output, &indent, &line, &text.text, depth, options);
} else {
output.push_str(&format!("{indent}{line} {}\n", text.text));
}
} else {
output.push_str(&format!("{indent}{line}\n"));
}
if let Some(children) = &tag.children {
format_nodes(children, depth + 1, options, output);
}
}
fn write_text_block(
output: &mut String,
indent: &str,
line: &str,
text: &str,
depth: usize,
options: &FormatOptions,
) {
output.push_str(&format!("{indent}{line}.\n"));
let text_indent = " ".repeat((depth + 1) * options.indent_size);
let max_width = options.print_width.saturating_sub(text_indent.len());
let wrapped = word_wrap(text, max_width);
for wrapped_line in wrapped.lines() {
output.push_str(&format!("{text_indent}{wrapped_line}\n"));
}
}
fn word_wrap(text: &str, max_width: usize) -> String {
let mut result = String::new();
for (i, paragraph) in text.lines().enumerate() {
if i > 0 {
result.push('\n');
}
wrap_line(paragraph, max_width, &mut result);
}
result
}
fn wrap_line(text: &str, max_width: usize, output: &mut String) {
let words: Vec<&str> = text.split_whitespace().collect();
if words.is_empty() {
return;
}
let mut current_line_len = 0;
for (i, word) in words.iter().enumerate() {
if i == 0 {
output.push_str(word);
current_line_len = word.len();
} else if current_line_len + 1 + word.len() > max_width {
output.push('\n');
output.push_str(word);
current_line_len = word.len();
} else {
output.push(' ');
output.push_str(word);
current_line_len += 1 + word.len();
}
}
}
#[cfg(test)]
mod tests;