use crate::config::FormatOptions;
use crate::formatter::expand::BLOCK_HTML_TAGS;
const VOID_HTML_TAGS: &[&str] = &[
"area", "base", "br", "col", "embed", "hr", "img", "input",
"link", "meta", "param", "source", "track", "wbr",
];
fn is_void_html_tag(name: &str) -> bool {
VOID_HTML_TAGS.iter().any(|&t| t.eq_ignore_ascii_case(name))
}
fn is_indent_html_tag(name: &str) -> bool {
BLOCK_HTML_TAGS.iter().any(|&t| t.eq_ignore_ascii_case(name))
&& !is_void_html_tag(name)
&& !"hr".eq_ignore_ascii_case(name)
&& !"br".eq_ignore_ascii_case(name)
&& !"link".eq_ignore_ascii_case(name)
&& !"meta".eq_ignore_ascii_case(name)
}
struct IndentState<'a> {
opts: &'a FormatOptions,
level: usize,
in_raw: bool, raw_depth: usize,
multi_line_tag: Option<(String, bool)>,
block_base_levels: Vec<usize>,
}
impl<'a> IndentState<'a> {
fn new(opts: &'a FormatOptions) -> Self {
Self {
opts,
level: 0,
in_raw: false,
raw_depth: 0,
multi_line_tag: None,
block_base_levels: Vec::new(),
}
}
fn write_indent(&self, out: &mut String) {
out.extend(std::iter::repeat_n(' ', self.opts.indent * self.level));
}
fn write_indent_at(&self, out: &mut String, level: usize) {
out.extend(std::iter::repeat_n(' ', self.opts.indent * level));
}
fn write_continuation_indent(&self, out: &mut String, tag_name: &str) {
let n = self.opts.indent * self.level + 1 + tag_name.len() + 1;
out.extend(std::iter::repeat_n(' ', n));
}
}
pub fn indent(html: &str, opts: &FormatOptions) -> String {
let mut state = IndentState::new(opts);
let mut out = String::with_capacity(html.len());
for line in html.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
out.push('\n');
continue;
}
if let Some((ref tag_name, is_block)) = state.multi_line_tag.clone() {
if html_open_tag_closes_here(trimmed) {
state.multi_line_tag = None;
state.write_continuation_indent(&mut out, tag_name);
out.push_str(trimmed);
out.push('\n');
if is_block && !trimmed.trim_end().ends_with("/>") {
state.level += 1;
}
} else {
state.write_continuation_indent(&mut out, tag_name);
out.push_str(trimmed);
out.push('\n');
}
continue;
}
if state.in_raw {
if is_raw_block_close(trimmed) {
state.raw_depth = state.raw_depth.saturating_sub(1);
if state.raw_depth == 0 {
state.in_raw = false;
let starts_with_close = trimmed.starts_with("</style>")
|| trimmed.starts_with("</script>")
|| trimmed.starts_with("</pre>")
|| trimmed.starts_with("{%");
if starts_with_close {
state.write_indent(&mut out);
}
out.push_str(trimmed);
out.push('\n');
} else {
out.push_str(trimmed);
out.push('\n');
}
} else {
out.push_str(trimmed);
out.push('\n');
}
continue;
}
if is_raw_block_open(trimmed) {
state.in_raw = true;
state.raw_depth = 1;
state.write_indent(&mut out);
out.push_str(trimmed);
out.push('\n');
continue;
}
if let Some(tag) = parse_html_close_tag(trimmed) {
if is_indent_html_tag(tag) {
state.level = state.level.saturating_sub(1);
state.write_indent(&mut out);
out.push_str(trimmed);
out.push('\n');
continue;
}
}
if let Some(kw) = parse_template_keyword(trimmed) {
if BRANCH_KEYWORDS.contains(&kw) {
if let Some(&base) = state.block_base_levels.last() {
state.level = base + 1;
state.write_indent(&mut out);
out.push_str(trimmed);
out.push('\n');
state.level = base + 2;
} else {
state.write_indent(&mut out);
out.push_str(trimmed);
out.push('\n');
}
continue;
}
if BRANCH_END_KEYWORDS.contains(&kw) {
let base = state
.block_base_levels
.pop()
.unwrap_or_else(|| state.level.saturating_sub(1));
state.level = base;
state.write_indent(&mut out);
out.push_str(trimmed);
out.push('\n');
continue;
}
if UNINDENT_KEYWORDS.contains(&kw) {
state.level = state.level.saturating_sub(1);
state.write_indent(&mut out);
out.push_str(trimmed);
out.push('\n');
continue;
}
if UNINDENT_LINE_KEYWORDS.contains(&kw) {
let effective = state.level.saturating_sub(1);
state.write_indent_at(&mut out, effective);
out.push_str(trimmed);
out.push('\n');
continue;
}
if NO_CHANGE_KEYWORDS.contains(&kw) {
state.write_indent(&mut out);
out.push_str(trimmed);
out.push('\n');
continue;
}
if INDENT_KEYWORDS.contains(&kw) {
if kw == "match" {
state.block_base_levels.push(state.level);
}
state.write_indent(&mut out);
out.push_str(trimmed);
out.push('\n');
state.level += 1;
continue;
}
}
if let Some((open_tag, is_self_closing, has_close_on_same_line)) =
parse_html_open_tag(trimmed)
{
if is_indent_html_tag(open_tag) && !is_self_closing {
if has_close_on_same_line {
state.write_indent(&mut out);
out.push_str(trimmed);
out.push('\n');
continue;
}
let formatted = maybe_format_attributes(trimmed, state.level, opts);
state.write_indent(&mut out);
out.push_str(&formatted);
out.push('\n');
state.level += 1;
continue;
}
}
if let Some((tag_name, is_block)) = parse_unclosed_html_open_tag(trimmed) {
state.write_indent(&mut out);
out.push_str(trimmed);
out.push('\n');
state.multi_line_tag = Some((tag_name, is_block));
continue;
}
let formatted = maybe_format_attributes(trimmed, state.level, opts);
state.write_indent(&mut out);
out.push_str(&formatted);
out.push('\n');
}
let result = out.trim_end_matches('\n').to_string();
result + "\n"
}
const INDENT_KEYWORDS: &[&str] =
&["if", "for", "macro", "block", "filter", "with", "raw", "match"];
const UNINDENT_KEYWORDS: &[&str] =
&["endif", "endfor", "endmacro", "endblock", "endfilter", "endwith", "endraw"];
const UNINDENT_LINE_KEYWORDS: &[&str] = &["else", "else if"];
const BRANCH_KEYWORDS: &[&str] = &["when"];
const BRANCH_END_KEYWORDS: &[&str] = &["endmatch"];
const NO_CHANGE_KEYWORDS: &[&str] = &["let", "call", "import", "include", "extends"];
fn parse_html_close_tag(line: &str) -> Option<&str> {
let s = line.trim_start();
if !s.starts_with("</") {
return None;
}
let rest = &s[2..];
let end = rest
.find(|c: char| !c.is_alphanumeric() && c != '-')
.unwrap_or(rest.len());
if end == 0 {
return None;
}
Some(&rest[..end])
}
fn parse_template_keyword(line: &str) -> Option<&str> {
let s = line.trim();
if !s.starts_with("{%") {
return None;
}
let inner = s[2..].trim_start_matches(['-', '+', '~', ' ', '\t']);
if inner.starts_with("else if") {
return Some("else if");
}
let kw = inner
.split_whitespace()
.next()
.unwrap_or("")
.trim_end_matches(['-', '+', '~']);
if kw.is_empty() { None } else { Some(kw) }
}
fn contains_close_tag(text: &str, tag: &str) -> bool {
let n = tag.len();
if n + 3 > text.len() {
return false;
}
text.as_bytes().windows(n + 3).any(|w| {
w[0] == b'<' && w[1] == b'/'
&& w[n + 2] == b'>'
&& w[2..n + 2].eq_ignore_ascii_case(tag.as_bytes())
})
}
fn parse_html_open_tag(line: &str) -> Option<(&str, bool, bool)> {
let s = line.trim_start();
if !s.starts_with('<') || s.starts_with("</") || s.starts_with("<!") || s.starts_with("<?") {
return None;
}
let rest = &s[1..];
let end = rest
.find(|c: char| !c.is_alphanumeric() && c != '-')
.unwrap_or(rest.len());
if end == 0 {
return None;
}
let tag = &rest[..end];
let close_pos = super::find_html_tag_close(s)?;
let self_closing = close_pos > 0 && s.as_bytes()[close_pos - 1] == b'/';
let after_open = &s[close_pos + 1..];
let has_close = contains_close_tag(after_open, tag);
Some((tag, self_closing, has_close))
}
fn is_raw_block_open(line: &str) -> bool {
let s = line.trim();
for (open, close) in &[
("<pre", "</pre>"),
("<script", "</script>"),
("<style", "</style>"),
] {
if s.starts_with(open) && !s.contains(close) {
return true;
}
}
if let Some(kw) = parse_template_keyword(s) {
return kw == "raw";
}
false
}
fn parse_unclosed_html_open_tag(line: &str) -> Option<(String, bool)> {
let s = line.trim_start();
if !s.starts_with('<') || s.starts_with("</") || s.starts_with("<!") || s.starts_with("<?") {
return None;
}
let rest = &s[1..];
let end = rest
.find(|c: char| !c.is_alphanumeric() && c != '-')
.unwrap_or(rest.len());
if end == 0 {
return None;
}
let tag = rest[..end].to_string();
if super::find_html_tag_close(s).is_some() {
return None;
}
let is_block = is_indent_html_tag(&tag);
Some((tag, is_block))
}
fn html_open_tag_closes_here(line: &str) -> bool {
super::find_html_tag_close(line.trim()).is_some()
}
fn is_raw_block_close(line: &str) -> bool {
let s = line.trim();
if s.contains("</pre>") || s.contains("</script>") || s.contains("</style>") {
return true;
}
if let Some(kw) = parse_template_keyword(s) {
return kw == "endraw";
}
false
}
pub fn maybe_format_attributes(line: &str, level: usize, opts: &FormatOptions) -> String {
let s = line.trim();
if !s.starts_with('<') || s.starts_with("</") || s.starts_with("<!") {
return s.to_string();
}
let rest = &s[1..];
let name_end = rest
.find(|c: char| !c.is_alphanumeric() && c != '-')
.unwrap_or(rest.len());
let tag_name = &rest[..name_end];
if !s[1 + name_end..].starts_with(|c: char| c.is_whitespace()) {
return s.to_string();
}
let (tag_only, after_close) = split_tag_from_content(s);
let attrs = parse_attributes(tag_only);
if attrs.len() < 2 {
return s.to_string();
}
let attrs = if opts.sort_attributes {
sort_attributes(attrs)
} else {
attrs
};
let is_self_closing = tag_only.trim_end().ends_with("/>");
let close = if is_self_closing { " />" } else { ">" };
let tag_sorted = format!("<{} {}{}", tag_name, attrs.join(" "), close);
let indent_len = opts.indent * level;
if indent_len + tag_sorted.len() <= opts.max_line_length {
return if after_close.is_empty() {
tag_sorted
} else {
format!("{}{}", tag_sorted, after_close)
};
}
let align = " ".repeat(indent_len + 1 + tag_name.len() + 1);
let mut out_lines: Vec<String> = attrs
.iter()
.enumerate()
.map(|(i, attr)| {
if i == 0 {
format!("<{} {}", tag_name, attr)
} else {
format!("{}{}", align, attr)
}
})
.collect();
if let Some(last) = out_lines.last_mut() {
last.push_str(close);
if !after_close.is_empty() {
last.push_str(after_close);
}
}
out_lines.join("\n")
}
fn split_tag_from_content(s: &str) -> (&str, &str) {
match super::find_html_tag_close(s) {
Some(pos) => (&s[..pos + 1], &s[pos + 1..]),
None => (s, ""),
}
}
fn parse_attributes(tag: &str) -> Vec<String> {
let start = tag.find(|c: char| c.is_whitespace()).unwrap_or(tag.len());
let end = super::find_html_tag_close(&tag[start..])
.map(|rel| start + rel)
.unwrap_or(tag.len());
let attrs_raw = &tag[start..end];
let attrs_str = attrs_raw.trim_end_matches('/').trim_end();
split_attrs(attrs_str)
}
fn sort_attributes(mut attrs: Vec<String>) -> Vec<String> {
if attrs.iter().any(|a| a.contains("{%") || a.contains("{{")) {
return attrs;
}
attrs.sort_by(|a, b| {
let ka = a.split('=').next().unwrap_or(a).trim();
let kb = b.split('=').next().unwrap_or(b).trim();
ka.to_lowercase().cmp(&kb.to_lowercase())
});
attrs
}
fn split_attrs(s: &str) -> Vec<String> {
let mut attrs = Vec::new();
let mut current = String::new();
let mut in_q: Option<char> = None;
let mut depth_tmpl = 0usize;
let chars: Vec<char> = s.chars().collect();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
match in_q {
Some(q) if c == q => {
current.push(c);
in_q = None;
}
Some(_) => {
current.push(c);
}
None => {
if c == '{'
&& chars
.get(i + 1)
.copied()
.is_some_and(|n| n == '{' || n == '%')
{
depth_tmpl += 1;
current.push(c);
} else if depth_tmpl > 0 && (c == '}' || c == '%') && chars.get(i + 1) == Some(&'}')
{
depth_tmpl -= 1;
current.push(c);
} else if depth_tmpl == 0 && c.is_whitespace() {
let trimmed = current.trim().to_string();
if !trimmed.is_empty() {
attrs.push(trimmed);
}
current.clear();
} else {
if c == '"' || c == '\'' {
in_q = Some(c);
}
current.push(c);
}
}
}
i += 1;
}
let trimmed = current.trim().to_string();
if !trimmed.is_empty() {
attrs.push(trimmed);
}
attrs
}