use crate::config::FormatOptions;
use crate::formatter::expand::BLOCK_HTML_TAGS;
fn is_indent_html_tag(name: &str) -> bool {
BLOCK_HTML_TAGS.contains(&name)
&& !is_void_html_tag(name)
&& name != "hr"
&& name != "br"
&& name != "link"
&& name != "meta"
}
fn is_void_html_tag(name: &str) -> bool {
matches!(
name,
"area"
| "base"
| "br"
| "col"
| "embed"
| "hr"
| "img"
| "input"
| "link"
| "meta"
| "param"
| "source"
| "track"
| "wbr"
)
}
struct IndentState<'a> {
opts: &'a FormatOptions,
level: usize,
in_raw: bool, raw_depth: usize,
multi_line_tag: Option<(String, bool)>,
}
impl<'a> IndentState<'a> {
fn new(opts: &'a FormatOptions) -> Self {
Self {
opts,
level: 0,
in_raw: false,
raw_depth: 0,
multi_line_tag: None,
}
}
fn indent(&self) -> String {
" ".repeat(self.opts.indent * self.level)
}
fn indent_at(&self, level: usize) -> String {
" ".repeat(self.opts.indent * level)
}
fn continuation_indent(&self, tag_name: &str) -> String {
" ".repeat(self.opts.indent * self.level + 1 + tag_name.len() + 1)
}
}
pub fn indent(html: &str, opts: &FormatOptions) -> String {
let mut state = IndentState::new(opts);
let mut out = String::with_capacity(html.len());
let indent_kws = indent_keywords(opts);
let unindent_kws = unindent_keywords(opts);
let unindent_line_kws = unindent_line_keywords(opts);
let no_change_kws = no_change_keywords(opts);
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() {
let cont_indent = state.continuation_indent(tag_name);
if html_open_tag_closes_here(trimmed) {
state.multi_line_tag = None;
out.push_str(&cont_indent);
out.push_str(trimmed);
out.push('\n');
if is_block && !trimmed.trim_end().ends_with("/>") {
state.level += 1;
}
} else {
out.push_str(&cont_indent);
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 {
out.push_str(&state.indent());
}
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;
out.push_str(&state.indent());
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);
out.push_str(&state.indent());
out.push_str(trimmed);
out.push('\n');
continue;
}
}
if let Some(kw) = parse_template_keyword(trimmed) {
if unindent_kws.contains(&kw.as_str().to_string()) {
state.level = state.level.saturating_sub(1);
out.push_str(&state.indent());
out.push_str(trimmed);
out.push('\n');
continue;
}
if unindent_line_kws.contains(&kw.as_str().to_string()) {
let effective = state.level.saturating_sub(1);
out.push_str(&state.indent_at(effective));
out.push_str(trimmed);
out.push('\n');
continue;
}
if no_change_kws.contains(&kw.as_str().to_string()) {
out.push_str(&state.indent());
out.push_str(trimmed);
out.push('\n');
continue;
}
if indent_kws.contains(&kw.as_str().to_string()) {
out.push_str(&state.indent());
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 {
out.push_str(&state.indent());
out.push_str(trimmed);
out.push('\n');
continue;
}
let formatted = format_attributes(trimmed, state.level, opts);
out.push_str(&state.indent());
out.push_str(&formatted);
out.push('\n');
state.level += 1;
continue;
}
}
if let Some((tag_name, is_block)) = parse_unclosed_html_open_tag(trimmed) {
out.push_str(&state.indent());
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);
out.push_str(&state.indent());
out.push_str(&formatted);
out.push('\n');
}
let result = out.trim_end_matches('\n').to_string();
result + "\n"
}
fn indent_keywords(opts: &FormatOptions) -> Vec<String> {
let mut kws: Vec<String> = vec![
"if".into(),
"for".into(),
"macro".into(),
"block".into(),
"filter".into(),
"with".into(),
"raw".into(),
];
for b in &opts.custom_blocks {
kws.push(b.clone());
}
kws
}
fn unindent_keywords(opts: &FormatOptions) -> Vec<String> {
let mut kws: Vec<String> = vec![
"endif".into(),
"endfor".into(),
"endmacro".into(),
"endblock".into(),
"endfilter".into(),
"endwith".into(),
"endraw".into(),
];
for b in &opts.custom_blocks {
kws.push(format!("end{}", b));
}
kws
}
fn unindent_line_keywords(opts: &FormatOptions) -> Vec<String> {
let mut kws: Vec<String> = vec!["else".into(), "else if".into()];
for b in &opts.custom_blocks_unindent_line {
kws.push(b.clone());
}
kws
}
fn no_change_keywords(opts: &FormatOptions) -> Vec<String> {
let mut kws: Vec<String> = vec![
"let".into(),
"import".into(),
"include".into(),
"extends".into(),
];
for b in &opts.ignore_blocks {
kws.push(b.clone());
kws.push(format!("end{}", b));
}
kws
}
fn parse_html_close_tag(line: &str) -> Option<String> {
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].to_lowercase())
}
fn parse_template_keyword(line: &str) -> Option<String> {
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".to_string());
}
let kw: String = inner
.split_whitespace()
.next()
.unwrap_or("")
.trim_end_matches(['-', '+', '~'])
.to_string();
if kw.is_empty() {
None
} else {
Some(kw)
}
}
fn parse_html_open_tag(line: &str) -> Option<(String, 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].to_lowercase();
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 close_tag = format!("</{}", tag);
let has_close = after_open.to_lowercase().contains(&close_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_lowercase();
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
}
fn format_attributes(line: &str, level: usize, opts: &FormatOptions) -> String {
maybe_format_attributes(line, level, opts)
}
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];
let after_name = &s[1 + name_end..];
if !after_name.starts_with(|c: char| c.is_whitespace()) {
return s.to_string();
}
let (tag_only, after_close) = split_tag_from_content(s);
let attrs_start = 1 + name_end; let attrs_str = if attrs_start < tag_only.len() {
&tag_only[attrs_start..]
} else {
""
};
let attrs_only_len = attrs_str.trim_end_matches(['>', '/']).len();
if attrs_only_len < opts.max_attribute_length {
return s.to_string();
}
let attrs = parse_attributes(tag_only);
if attrs.len() < 2 {
return s.to_string();
}
let align_spaces = " ".repeat(opts.indent * level + 1 + tag_name.len() + 1);
let is_self_closing = tag_only.trim_end().ends_with("/>");
let close = if is_self_closing { " />" } else { ">" };
let mut out_lines = Vec::new();
for (i, attr) in attrs.iter().enumerate() {
if i == 0 {
out_lines.push(format!("<{} {}", tag_name, attr));
} else {
out_lines.push(format!("{}{}", align_spaces, attr));
}
}
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 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
}