mod tag_parse;
mod text_content;
use tag_parse::{
ParsedAttribute, ParsedAttributeValue, ParsedTag, canonical_group_key, parse_tag,
reorder_attributes,
};
use text_content::{
collapse_whitespace, decode_xml_entities, dedent_block_with_offset, encode_xml_entities,
is_text_content_element, normalize_text_content_with_entities, strip_cdata_wrapper,
};
use tree_sitter::{Node, Parser};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
#[cfg_attr(feature = "cli", value(rename_all = "kebab-case"))]
pub enum AttributeSort {
None,
#[default]
Canonical,
Alphabetical,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
#[cfg_attr(feature = "cli", value(rename_all = "kebab-case"))]
pub enum AttributeLayout {
#[default]
Auto,
SingleLine,
MultiLine,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
#[cfg_attr(feature = "cli", value(rename_all = "kebab-case"))]
pub enum QuoteStyle {
#[default]
Preserve,
Double,
Single,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
#[cfg_attr(feature = "cli", value(rename_all = "kebab-case"))]
pub enum WrappedAttributeIndent {
OneLevel,
#[default]
AlignToTagName,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
#[cfg_attr(feature = "cli", value(rename_all = "kebab-case"))]
pub enum BlankLines {
Remove,
Preserve,
#[default]
Truncate,
Insert,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
#[cfg_attr(feature = "cli", value(rename_all = "kebab-case"))]
pub enum TextContentMode {
Collapse,
#[default]
Maintain,
Prettify,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EmbeddedLanguage {
Css,
JavaScript,
Html,
}
const fn is_script_or_style_language(language: EmbeddedLanguage) -> bool {
matches!(
language,
EmbeddedLanguage::Css | EmbeddedLanguage::JavaScript
)
}
pub struct EmbeddedContent<'a> {
pub language: EmbeddedLanguage,
pub content: &'a str,
pub indent_depth: usize,
pub file_byte_offset: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FormatOptions {
pub indent_width: usize,
pub insert_spaces: bool,
pub max_inline_tag_width: usize,
pub attribute_sort: AttributeSort,
pub attribute_layout: AttributeLayout,
pub attributes_per_line: usize,
pub space_before_self_close: bool,
pub quote_style: QuoteStyle,
pub wrapped_attribute_indent: WrappedAttributeIndent,
pub text_content: TextContentMode,
pub blank_lines: BlankLines,
pub ignore_prefixes: Vec<String>,
}
impl Default for FormatOptions {
fn default() -> Self {
Self {
indent_width: 2,
insert_spaces: true,
max_inline_tag_width: 100,
attribute_sort: AttributeSort::Canonical,
attribute_layout: AttributeLayout::Auto,
attributes_per_line: 1,
space_before_self_close: true,
quote_style: QuoteStyle::Preserve,
wrapped_attribute_indent: WrappedAttributeIndent::AlignToTagName,
text_content: TextContentMode::Maintain,
blank_lines: BlankLines::Truncate,
ignore_prefixes: vec!["svg-format".to_string()],
}
}
}
#[must_use]
pub fn format(source: &str) -> String {
format_with_options(source, FormatOptions::default())
}
#[must_use]
pub fn format_with_options(source: &str, options: FormatOptions) -> String {
format_with_host(source, options, &mut |_| None)
}
#[must_use]
pub fn format_with_host(
source: &str,
options: FormatOptions,
format_embedded: &mut dyn FnMut(EmbeddedContent<'_>) -> Option<String>,
) -> String {
let mut parser = Parser::new();
if parser
.set_language(&tree_sitter_svg::LANGUAGE.into())
.is_err()
{
return normalize_line_endings(source);
}
let Some(tree) = parser.parse(source.as_bytes(), None) else {
return normalize_line_endings(source);
};
if tree.root_node().has_error() {
return normalize_line_endings(source);
}
if has_ignore_file_comment(
tree.root_node(),
source.as_bytes(),
&options.ignore_prefixes,
) {
return normalize_line_endings(source);
}
let mut formatter = Formatter::new(source.as_bytes(), options);
formatter.format_node(tree.root_node(), 0, format_embedded);
formatter.finish(source)
}
fn normalize_line_endings(source: &str) -> String {
if !source.contains('\r') {
return source.to_owned();
}
source.replace("\r\n", "\n").replace('\r', "\n")
}
fn has_ignore_file_comment(node: Node<'_>, source: &[u8], prefixes: &[String]) -> bool {
if node.kind() == "comment" {
let inner = node
.child_by_field_name("text")
.and_then(|t| std::str::from_utf8(&source[t.byte_range()]).ok())
.map_or("", str::trim);
if prefixes.iter().any(|p| inner == format!("{p}-ignore-file")) {
return true;
}
}
let mut cursor = node.walk();
node.named_children(&mut cursor)
.any(|child| has_ignore_file_comment(child, source, prefixes))
}
fn wrap_path_data(name: &str, raw: &str, budget: usize) -> Option<String> {
if budget == 0 || raw.chars().count() <= budget {
return None;
}
if raw.trim().is_empty() {
return None;
}
if raw.contains('\n') {
return None;
}
match name {
"d" => wrap_d_value(raw, budget),
"points" => wrap_points_value(raw, budget),
_ => None,
}
}
fn wrap_d_value(raw: &str, budget: usize) -> Option<String> {
let mut parser = Parser::new();
parser
.set_language(&tree_sitter_svg_path::LANGUAGE.into())
.ok()?;
let tree = parser.parse(raw.as_bytes(), None)?;
if tree.root_node().has_error() {
return None;
}
let mut segments: Vec<(usize, usize, &str)> = Vec::new();
collect_path_segments(tree.root_node(), raw.as_bytes(), &mut segments);
if segments.is_empty() {
return None;
}
let segment_strs: Vec<&str> = segments
.iter()
.map(|&(start, end, _kind)| &raw[start..end])
.collect();
let segment_kinds: Vec<&str> = segments.iter().map(|&(_, _, kind)| kind).collect();
pack_segments(&segment_strs, &segment_kinds, budget, true)
}
fn wrap_points_value(raw: &str, budget: usize) -> Option<String> {
let tokens: Vec<&str> = raw.split_ascii_whitespace().collect();
if tokens.is_empty() {
return None;
}
let mut pairs: Vec<String> = Vec::new();
let mut i = 0;
while i < tokens.len() {
let t = tokens[i];
if t.contains(',') {
pairs.push(t.to_string());
i += 1;
} else if i + 1 < tokens.len() {
pairs.push(format!("{t},{}", tokens[i + 1]));
i += 2;
} else {
pairs.push(t.to_string());
i += 1;
}
}
if pairs.len() <= 1 {
return None;
}
let pair_refs: Vec<&str> = pairs.iter().map(String::as_str).collect();
let kinds = vec!["pair"; pair_refs.len()];
pack_segments(&pair_refs, &kinds, budget, false)
}
fn pack_segments(
segments: &[&str],
kinds: &[&str],
budget: usize,
prefer_subpath_breaks: bool,
) -> Option<String> {
if segments.is_empty() {
return None;
}
let mut out = String::with_capacity(segments.iter().map(|s| s.len() + 1).sum::<usize>());
let mut line_width = 0usize;
let half_budget = budget / 2;
for (i, seg) in segments.iter().enumerate() {
let w = seg.chars().count();
let is_moveto_split = prefer_subpath_breaks
&& i > 0
&& kinds[i] == "moveto_segment"
&& line_width >= half_budget;
if line_width == 0 {
out.push_str(seg);
line_width = w;
} else if !is_moveto_split && line_width + 1 + w <= budget {
out.push(' ');
out.push_str(seg);
line_width += 1 + w;
} else {
out.push('\n');
out.push_str(seg);
line_width = w;
}
}
if out.contains('\n') { Some(out) } else { None }
}
fn collect_path_segments(
node: Node<'_>,
source: &[u8],
segments: &mut Vec<(usize, usize, &'static str)>,
) {
const SEGMENT_KINDS: &[&str] = &[
"moveto_segment",
"lineto_segment",
"closepath_segment",
"curveto_segment",
"smooth_curveto_segment",
"quadratic_bezier_curveto_segment",
"smooth_quadratic_bezier_curveto_segment",
"elliptical_arc_segment",
"horizontal_lineto_segment",
"vertical_lineto_segment",
"implicit_lineto_segment",
];
let kind = node.kind();
if let Some(&matched) = SEGMENT_KINDS.iter().find(|k| **k == kind) {
let range = node.byte_range();
let text = std::str::from_utf8(&source[range.clone()])
.unwrap_or("")
.trim_end();
let end = range.start + text.len();
segments.push((range.start, end, matched));
return;
}
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
collect_path_segments(child, source, segments);
}
}
fn partition_by_canonical_group(attributes: &[ParsedAttribute]) -> Vec<Vec<usize>> {
if attributes.is_empty() {
return Vec::new();
}
let mut groups: Vec<Vec<usize>> = Vec::new();
let mut current: Vec<usize> = vec![0];
let mut current_key = canonical_group_key(&attributes[0].name);
for (i, attr) in attributes.iter().enumerate().skip(1) {
let key = canonical_group_key(&attr.name);
if key == current_key {
current.push(i);
} else {
groups.push(std::mem::take(&mut current));
current.push(i);
current_key = key;
}
}
if !current.is_empty() {
groups.push(current);
}
groups
}
fn split_group_if_overflow(
attributes: &[ParsedAttribute],
group: Vec<usize>,
budget: usize,
) -> Vec<Vec<usize>> {
let width: usize = group
.iter()
.map(|&i| approximate_attribute_width(&attributes[i]))
.sum::<usize>()
+ group.len().saturating_sub(1);
if width <= budget || group.len() <= 1 {
vec![group]
} else {
group.into_iter().map(|i| vec![i]).collect()
}
}
fn approximate_attribute_width(attribute: &ParsedAttribute) -> usize {
let name_width = attribute.name.chars().count();
attribute.value.as_ref().map_or(name_width, |v| {
let quote_width = if v.original_quote.is_some() { 2 } else { 0 };
name_width + 1 + quote_width + v.raw.chars().count()
})
}
fn split_first_chunk_for_tag_line(
attributes: &[ParsedAttribute],
chunks: &mut Vec<Vec<usize>>,
tag_line_prefix_width: usize,
line_budget: usize,
mut render: impl FnMut(&ParsedAttribute) -> String,
) -> (Vec<usize>, Vec<Vec<usize>>) {
if chunks.is_empty() {
return (Vec::new(), Vec::new());
}
let first = chunks.remove(0);
let mut kept: Vec<usize> = Vec::with_capacity(first.len());
let mut popped: Vec<usize> = Vec::new();
for &idx in &first {
let candidate = render(&attributes[idx]);
let tentative = if kept.is_empty() {
tag_line_prefix_width + candidate.chars().count()
} else {
let existing: usize = tag_line_prefix_width
+ kept
.iter()
.map(|&i| render(&attributes[i]).chars().count() + 1)
.sum::<usize>()
- 1;
existing + 1 + candidate.chars().count()
};
if !kept.is_empty() && tentative > line_budget {
popped.push(idx);
} else {
kept.push(idx);
}
}
for &idx in &first {
if !kept.contains(&idx) && !popped.contains(&idx) {
popped.push(idx);
}
}
let rest: Vec<Vec<usize>> = if popped.is_empty() {
std::mem::take(chunks)
} else {
std::iter::once(popped)
.chain(std::mem::take(chunks))
.collect()
};
(kept, rest)
}
struct Formatter<'a> {
source: &'a [u8],
options: FormatOptions,
out: String,
}
impl<'a> Formatter<'a> {
const fn new(source: &'a [u8], options: FormatOptions) -> Self {
Self {
source,
options,
out: String::new(),
}
}
fn finish(mut self, original: &str) -> String {
while self.out.ends_with('\n') {
self.out.pop();
}
if original.ends_with('\n') {
self.out.push('\n');
}
self.out
}
fn format_node(
&mut self,
node: Node<'_>,
depth: usize,
fmt: &mut dyn FnMut(EmbeddedContent<'_>) -> Option<String>,
) {
match node.kind() {
"svg_root_element" | "element" => self.format_element_like(node, depth, fmt),
"start_tag" => self.write_tag_node(node, depth, false),
"self_closing_tag" => self.write_tag_node(node, depth, true),
"style_text_double" | "style_text_single" | "script_text_double"
| "script_text_single" => {
self.write_preserved_block_text(node, depth);
}
"text" | "raw_text" => {
self.write_text_node(node, depth);
}
"end_tag"
| "comment"
| "cdata_section"
| "doctype"
| "processing_instruction"
| "xml_declaration"
| "entity_reference"
| "erroneous_end_tag" => {
let text = self.node_text(node).trim().to_string();
self.write_line(depth, &text);
}
_ => self.format_children(node, depth, fmt),
}
}
fn format_children(
&mut self,
node: Node<'_>,
depth: usize,
fmt: &mut dyn FnMut(EmbeddedContent<'_>) -> Option<String>,
) {
let mut cursor = node.walk();
let mut prev_end: Option<usize> = None;
let mut prev_was_comment = false;
let mut ignore_next = false;
let mut in_ignore_range = false;
for child in node.named_children(&mut cursor) {
if self.handle_ignore(
child,
&mut in_ignore_range,
&mut ignore_next,
&mut prev_was_comment,
&mut prev_end,
) {
continue;
}
if let Some(end) = prev_end {
self.emit_gap(end, child.start_byte(), prev_was_comment);
}
self.format_node(child, depth, fmt);
prev_was_comment = child.kind() == "comment";
prev_end = Some(child.end_byte());
}
}
fn format_element_like(
&mut self,
node: Node<'_>,
depth: usize,
fmt: &mut dyn FnMut(EmbeddedContent<'_>) -> Option<String>,
) {
let mut cursor = node.walk();
let children: Vec<Node<'_>> = node.named_children(&mut cursor).collect();
if children.is_empty() {
return;
}
if children.len() == 1 && children[0].kind() == "self_closing_tag" {
self.format_node(children[0], depth, fmt);
return;
}
let tag_name: String = children
.iter()
.find(|c| c.kind() == "start_tag")
.and_then(|tag| {
let text = self.node_text(*tag).trim();
text.strip_prefix('<')
.and_then(|s| s.split(|c: char| c.is_whitespace() || c == '>').next())
.map(str::to_string)
})
.unwrap_or_default();
let embedded_lang = match tag_name.as_str() {
"style" => Some(EmbeddedLanguage::Css),
"script" => Some(EmbeddedLanguage::JavaScript),
"foreignObject" => Some(EmbeddedLanguage::Html),
_ => None,
};
if embedded_lang == Some(EmbeddedLanguage::Html)
&& self
.try_format_foreign_object(&children, depth, fmt)
.is_some()
{
return;
}
if let Some((start, end)) = text_content_entity_bounds(&children, &tag_name) {
self.format_text_content_element(start, end, depth);
return;
}
self.format_element_children(&children, embedded_lang, depth, fmt);
}
fn format_element_children(
&mut self,
children: &[Node<'_>],
embedded_lang: Option<EmbeddedLanguage>,
depth: usize,
fmt: &mut dyn FnMut(EmbeddedContent<'_>) -> Option<String>,
) {
let mut prev_end: Option<usize> = None;
let mut prev_was_comment = false;
let mut ignore_next = false;
let mut in_ignore_range = false;
for &child in children {
if self.handle_ignore(
child,
&mut in_ignore_range,
&mut ignore_next,
&mut prev_was_comment,
&mut prev_end,
) {
continue;
}
match child.kind() {
"start_tag" | "end_tag" => {
self.format_node(child, depth, fmt);
}
"style_text_double" | "style_text_single" | "script_text_double"
| "script_text_single" => {
if !self.node_text(child).trim().is_empty() {
self.write_preserved_block_text(child, depth + 1);
}
prev_was_comment = false;
prev_end = Some(child.end_byte());
}
"cdata_section" if embedded_lang.is_some_and(is_script_or_style_language) => {
let Some(lang) = embedded_lang else { continue };
self.format_embedded_child(
child,
lang,
depth + 1,
fmt,
(&mut prev_end, &mut prev_was_comment),
true,
);
}
"text" | "raw_text" => {
if let Some(lang) = embedded_lang
&& lang != EmbeddedLanguage::Html
{
self.format_embedded_child(
child,
lang,
depth + 1,
fmt,
(&mut prev_end, &mut prev_was_comment),
false,
);
continue;
}
self.format_plain_text_child(
child,
depth + 1,
&mut prev_end,
&mut prev_was_comment,
);
}
_ => {
if let Some(end) = prev_end {
self.emit_gap(end, child.start_byte(), prev_was_comment);
}
self.format_node(child, depth + 1, fmt);
prev_was_comment = child.kind() == "comment";
prev_end = Some(child.end_byte());
}
}
}
}
fn format_plain_text_child(
&mut self,
child: Node<'_>,
depth: usize,
prev_end: &mut Option<usize>,
prev_was_comment: &mut bool,
) {
if self.node_text(child).trim().is_empty() {
return;
}
if let Some(end) = *prev_end {
self.emit_gap(end, child.start_byte(), *prev_was_comment);
}
self.write_text_node(child, depth);
*prev_was_comment = false;
*prev_end = Some(child.end_byte());
}
fn format_embedded_child(
&mut self,
child: Node<'_>,
language: EmbeddedLanguage,
depth: usize,
fmt: &mut dyn FnMut(EmbeddedContent<'_>) -> Option<String>,
previous: (&mut Option<usize>, &mut bool),
force_preserve: bool,
) {
let (prev_end, prev_was_comment) = previous;
if self.node_text(child).trim().is_empty() {
return;
}
if let Some(end) = *prev_end {
self.emit_gap(end, child.start_byte(), *prev_was_comment);
}
if !self.try_format_embedded_text(child, language, depth, fmt) {
if force_preserve || self.options.text_content == TextContentMode::Maintain {
self.write_preserved_embedded_text(child, depth);
} else {
self.write_text_node(child, depth);
}
}
*prev_was_comment = false;
*prev_end = Some(child.end_byte());
}
fn format_text_content_element(&mut self, start: Node<'_>, end: Node<'_>, depth: usize) {
let raw = std::str::from_utf8(&self.source[start.end_byte()..end.start_byte()])
.unwrap_or_default();
let normalized = normalize_text_content_with_entities(raw);
let end_text = self.node_text(end).trim().to_string();
let out_before = self.out.len();
self.write_tag_node(start, depth, false);
let tag_output = self.out[out_before..].to_string();
if normalized.is_empty() {
self.write_line(depth, &end_text);
return;
}
let tag_str = tag_output.trim_end_matches('\n');
if !tag_str.contains('\n') {
let tag_inline = tag_str.trim_start();
let candidate = format!("{tag_inline}{normalized}{end_text}");
if self.indent(depth).len() + candidate.len() <= self.options.max_inline_tag_width {
self.out.truncate(out_before);
self.write_line(depth, &candidate);
return;
}
}
self.write_line(depth + 1, &normalized);
self.write_line(depth, &end_text);
}
fn write_tag_node(&mut self, node: Node<'_>, depth: usize, self_closing: bool) {
let raw = self.node_text(node).trim().to_string();
let Some(mut tag) = parse_tag(&raw, self_closing) else {
self.write_line(depth, &raw);
return;
};
reorder_attributes(&mut tag.attributes, self.options.attribute_sort);
let rendered_attributes: Vec<String> = tag
.attributes
.iter()
.map(|attribute| self.render_attribute(attribute))
.collect();
let inline = self.render_inline_tag(&tag.name, &rendered_attributes, self_closing);
if !self.should_break_into_multiline(&raw, &inline, &rendered_attributes) {
self.write_line(depth, &inline);
return;
}
if rendered_attributes.is_empty() {
self.emit_attributeless_multiline(depth, &tag.name, self_closing);
return;
}
self.emit_multiline_tag(depth, &mut tag, self_closing);
}
fn render_inline_tag(
&self,
name: &str,
rendered_attributes: &[String],
self_closing: bool,
) -> String {
let mut inline = format!("<{name}");
if !rendered_attributes.is_empty() {
inline.push(' ');
inline.push_str(&rendered_attributes.join(" "));
}
if self_closing {
inline.push_str(self.self_closing_suffix());
} else {
inline.push('>');
}
inline
}
fn should_break_into_multiline(
&self,
raw: &str,
inline: &str,
rendered_attributes: &[String],
) -> bool {
match self.options.attribute_layout {
AttributeLayout::SingleLine => false,
AttributeLayout::MultiLine => !rendered_attributes.is_empty(),
AttributeLayout::Auto => {
raw.contains('\n') || inline.len() > self.options.max_inline_tag_width
}
}
}
fn emit_attributeless_multiline(&mut self, depth: usize, tag_name: &str, self_closing: bool) {
self.write_line(depth, &format!("<{tag_name}"));
if self_closing {
self.write_line(depth, self.self_closing_suffix());
} else {
self.write_line(depth, ">");
}
}
fn emit_multiline_tag(&mut self, depth: usize, tag: &mut ParsedTag, self_closing: bool) {
let wrapped_prefix = self.wrapped_attribute_prefix(depth, &tag.name);
self.auto_wrap_path_data_values(&mut tag.attributes, &wrapped_prefix);
let has_multiline_value = tag
.attributes
.iter()
.any(|a| a.value.as_ref().is_some_and(|v| v.raw.contains('\n')));
let first_inline = matches!(
self.options.wrapped_attribute_indent,
WrappedAttributeIndent::AlignToTagName,
);
let closer = if self_closing {
self.self_closing_suffix()
} else {
">"
};
let chunks = self.build_wrap_chunks(&tag.attributes, &wrapped_prefix, has_multiline_value);
if first_inline {
self.emit_first_inline_layout(depth, tag, chunks, &wrapped_prefix, closer);
} else {
self.emit_one_level_layout(depth, tag, &chunks, &wrapped_prefix, closer);
}
}
fn auto_wrap_path_data_values(&self, attributes: &mut [ParsedAttribute], wrapped_prefix: &str) {
for attribute in attributes {
let Some(value) = attribute.value.as_mut() else {
continue;
};
let name_width = attribute.name.chars().count();
let available = self
.options
.max_inline_tag_width
.saturating_sub(wrapped_prefix.len() + name_width + 2);
if let Some(wrapped) = wrap_path_data(&attribute.name, &value.raw, available) {
value.raw = wrapped;
}
}
}
fn build_wrap_chunks(
&self,
attributes: &[ParsedAttribute],
wrapped_prefix: &str,
has_multiline_value: bool,
) -> Vec<Vec<usize>> {
let budget = self
.options
.max_inline_tag_width
.saturating_sub(wrapped_prefix.len());
if matches!(self.options.attribute_sort, AttributeSort::Canonical) && !has_multiline_value {
partition_by_canonical_group(attributes)
.into_iter()
.flat_map(|group| split_group_if_overflow(attributes, group, budget))
.collect()
} else {
let per_line = if has_multiline_value {
1
} else {
self.options.attributes_per_line.max(1)
};
(0..attributes.len())
.collect::<Vec<_>>()
.chunks(per_line)
.map(<[usize]>::to_vec)
.collect()
}
}
fn emit_first_inline_layout(
&mut self,
depth: usize,
tag: &ParsedTag,
mut chunks: Vec<Vec<usize>>,
wrapped_prefix: &str,
closer: &str,
) {
let (first_chunk_indices, rest_chunks) = split_first_chunk_for_tag_line(
&tag.attributes,
&mut chunks,
self.indent(depth).len() + tag.name.chars().count() + 2, self.options.max_inline_tag_width,
|attr| self.render_attribute_aligned(attr, wrapped_prefix),
);
let first_rendered: Vec<String> = first_chunk_indices
.iter()
.map(|&i| self.render_attribute_aligned(&tag.attributes[i], wrapped_prefix))
.collect();
let mut tag_line = format!(
"{}<{} {}",
self.indent(depth),
tag.name,
first_rendered.join(" ")
);
if rest_chunks.is_empty() {
tag_line.push_str(closer);
}
tag_line.push('\n');
self.out.push_str(&tag_line);
self.emit_wrapped_chunks(tag, &rest_chunks, wrapped_prefix, closer);
}
fn emit_one_level_layout(
&mut self,
depth: usize,
tag: &ParsedTag,
chunks: &[Vec<usize>],
wrapped_prefix: &str,
closer: &str,
) {
self.write_line(depth, &format!("<{}", tag.name));
self.emit_wrapped_chunks(tag, chunks, wrapped_prefix, closer);
}
fn emit_wrapped_chunks(
&mut self,
tag: &ParsedTag,
chunks: &[Vec<usize>],
wrapped_prefix: &str,
closer: &str,
) {
for (index, chunk) in chunks.iter().enumerate() {
let rendered: Vec<String> = chunk
.iter()
.map(|&i| self.render_attribute_aligned(&tag.attributes[i], wrapped_prefix))
.collect();
let mut line = rendered.join(" ");
if index == chunks.len() - 1 {
line.push_str(closer);
}
self.write_prefixed_line(wrapped_prefix, &line);
}
}
const fn self_closing_suffix(&self) -> &'static str {
if self.options.space_before_self_close {
" />"
} else {
"/>"
}
}
fn render_attribute(&self, attribute: &ParsedAttribute) -> String {
attribute.value.as_ref().map_or_else(
|| attribute.name.clone(),
|value| format!("{}={}", attribute.name, self.render_attribute_value(value)),
)
}
fn render_attribute_value(&self, value: &ParsedAttributeValue) -> String {
match self.options.quote_style {
QuoteStyle::Preserve => match value.original_quote {
Some('\'') => format!("'{}'", value.raw),
Some('"') => format!("\"{}\"", value.raw),
Some(other) => format!("{other}{}{other}", value.raw),
None => value.raw.clone(),
},
QuoteStyle::Double => format!("\"{}\"", value.raw.replace('"', """)),
QuoteStyle::Single => format!("'{}'", value.raw.replace('\'', "'")),
}
}
fn render_attribute_aligned(&self, attribute: &ParsedAttribute, prefix: &str) -> String {
let Some(value) = attribute.value.as_ref() else {
return attribute.name.clone();
};
if !value.raw.contains('\n') {
return format!("{}={}", attribute.name, self.render_attribute_value(value));
}
let quote = match self.options.quote_style {
QuoteStyle::Preserve => value.original_quote.unwrap_or('"'),
QuoteStyle::Double => '"',
QuoteStyle::Single => '\'',
};
let name_width = attribute.name.chars().count();
let mut pad = String::with_capacity(prefix.len() + name_width + 2);
pad.push_str(prefix);
for _ in 0..name_width + 2 {
pad.push(' ');
}
let mut result = String::with_capacity(value.raw.len() + pad.len() * 2 + 8);
let mut lines = value.raw.split('\n');
if let Some(first) = lines.next() {
result.push_str(&attribute.name);
result.push('=');
result.push(quote);
result.push_str(first);
}
for line in lines {
result.push('\n');
result.push_str(&pad);
result.push_str(line.trim_start());
}
result.push(quote);
result
}
fn wrapped_attribute_prefix(&self, depth: usize, tag_name: &str) -> String {
match self.options.wrapped_attribute_indent {
WrappedAttributeIndent::OneLevel => self.indent(depth + 1),
WrappedAttributeIndent::AlignToTagName => {
let mut prefix = self.indent(depth);
prefix.push_str(&" ".repeat(tag_name.chars().count() + 2));
prefix
}
}
}
fn write_prefixed_line(&mut self, prefix: &str, text: &str) {
self.out.push_str(prefix);
self.out.push_str(text);
self.out.push('\n');
}
fn write_text_node(&mut self, node: Node<'_>, depth: usize) {
let text = self.node_text(node).to_string();
self.write_text_str(&text, depth);
}
fn write_text_str(&mut self, text: &str, depth: usize) {
if text.trim().is_empty() {
return;
}
match self.options.text_content {
TextContentMode::Collapse => {
for line in text.lines() {
let collapsed = collapse_whitespace(line);
if collapsed.is_empty() {
continue;
}
self.write_line(depth, &collapsed);
}
}
TextContentMode::Maintain => {
self.write_preserved_str(text, depth);
}
TextContentMode::Prettify => {
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
self.write_line(depth, trimmed);
}
}
}
}
fn write_preserved_block_text(&mut self, node: Node<'_>, depth: usize) {
let text = self.node_text(node).to_string();
self.write_preserved_str(&text, depth);
}
fn write_preserved_embedded_text(&mut self, node: Node<'_>, depth: usize) {
let text = self.node_text(node).to_string();
self.write_embedded_preserved_str(&text, depth);
}
fn write_preserved_str(&mut self, text: &str, depth: usize) {
if text.trim().is_empty() {
return;
}
let lines: Vec<&str> = text.lines().collect();
let first_non_empty = lines.iter().position(|line| !line.trim().is_empty());
let last_non_empty = lines.iter().rposition(|line| !line.trim().is_empty());
let (Some(start), Some(end)) = (first_non_empty, last_non_empty) else {
return;
};
let block = &lines[start..=end];
let min_leading = block
.iter()
.filter(|line| !line.trim().is_empty())
.map(|line| line.chars().take_while(|c| c.is_whitespace()).count())
.min()
.unwrap_or(0);
for line in block {
let without_common_indent = line.chars().skip(min_leading).collect::<String>();
self.write_line(depth, without_common_indent.trim_end());
}
}
fn write_embedded_preserved_str(&mut self, text: &str, depth: usize) {
if text.trim().is_empty() {
return;
}
let lines: Vec<&str> = text.lines().collect();
let first_non_empty = lines.iter().position(|line| !line.trim().is_empty());
let last_non_empty = lines.iter().rposition(|line| !line.trim().is_empty());
let (Some(start), Some(end)) = (first_non_empty, last_non_empty) else {
return;
};
let block = &lines[start..=end];
let min_leading = block
.iter()
.filter(|line| !line.trim().is_empty())
.map(|line| line.chars().take_while(|c| c.is_whitespace()).count())
.min()
.unwrap_or(0);
let mut consecutive_blank = 0usize;
for line in block {
let without_common_indent = line.chars().skip(min_leading).collect::<String>();
let trimmed = without_common_indent.trim_end();
if trimmed.is_empty() {
consecutive_blank += 1;
if self.should_emit_embedded_blank(consecutive_blank) {
self.out.push('\n');
}
} else {
consecutive_blank = 0;
self.write_line(depth, trimmed);
}
}
}
fn try_format_embedded_text(
&mut self,
node: Node<'_>,
language: EmbeddedLanguage,
depth: usize,
fmt: &mut dyn FnMut(EmbeddedContent<'_>) -> Option<String>,
) -> bool {
let raw = self.node_text(node);
let raw_start = node.start_byte();
let (payload, cdata_wrapped, payload_offset) = strip_cdata_wrapper(raw).map_or_else(
|| {
let (dedented, offset) = dedent_block_with_offset(raw);
(decode_xml_entities(dedented), false, offset)
},
|(inner_offset, inner)| {
let (dedented, offset) = dedent_block_with_offset(inner);
(dedented, true, offset.map(|value| inner_offset + value))
},
);
let Some(payload_offset) = payload_offset else {
return false;
};
let file_byte_offset = raw_start + payload_offset;
let req = EmbeddedContent {
language,
content: &payload,
indent_depth: depth,
file_byte_offset,
};
fmt(req).is_some_and(|formatted| {
if cdata_wrapped {
self.write_cdata_block(&formatted, depth);
} else {
let encoded = encode_xml_entities(&formatted);
self.write_indented_block(&encoded, depth);
}
true
})
}
fn write_cdata_block(&mut self, text: &str, depth: usize) {
self.write_line(depth, "<![CDATA[");
self.write_indented_block(text, depth + 1);
self.write_line(depth, "]]>");
}
fn try_format_foreign_object(
&mut self,
children: &[Node<'_>],
depth: usize,
fmt: &mut dyn FnMut(EmbeddedContent<'_>) -> Option<String>,
) -> Option<()> {
let start_tag = children.iter().find(|c| c.kind() == "start_tag")?;
let end_tag = children.iter().find(|c| c.kind() == "end_tag")?;
let content_start = start_tag.end_byte();
let content_end = end_tag.start_byte();
if content_start >= content_end {
return None;
}
let raw = std::str::from_utf8(&self.source[content_start..content_end]).ok()?;
let (content, offset) = dedent_block_with_offset(raw);
let offset = offset?;
let file_byte_offset = content_start + offset;
let req = EmbeddedContent {
language: EmbeddedLanguage::Html,
content: &content,
indent_depth: depth + 1,
file_byte_offset,
};
let formatted = fmt(req)?;
self.write_tag_node(*start_tag, depth, false);
self.write_indented_block(&formatted, depth + 1);
let end_text = self.node_text(*end_tag).trim().to_string();
self.write_line(depth, &end_text);
Some(())
}
fn write_indented_block(&mut self, text: &str, depth: usize) {
let lines: Vec<&str> = text.lines().collect();
let Some(first) = lines.iter().position(|l| !l.trim().is_empty()) else {
return;
};
let last = lines
.iter()
.rposition(|l| !l.trim().is_empty())
.unwrap_or(first);
let block = &lines[first..=last];
let indent = self.indent(depth);
let mut consecutive_blank = 0usize;
for line in block {
if line.trim().is_empty() {
consecutive_blank += 1;
if self.should_emit_embedded_blank(consecutive_blank) {
self.out.push('\n');
}
} else {
consecutive_blank = 0;
self.out.push_str(&indent);
self.out.push_str(line);
self.out.push('\n');
}
}
}
fn handle_ignore(
&mut self,
child: Node<'_>,
in_ignore_range: &mut bool,
ignore_next: &mut bool,
prev_was_comment: &mut bool,
prev_end: &mut Option<usize>,
) -> bool {
let mut skip_ignore_self = false;
if child.kind() == "comment" {
if *in_ignore_range {
if self.is_ignore_directive(child, "ignore-end") {
self.write_source_span(*prev_end, child.end_byte());
*in_ignore_range = false;
*prev_was_comment = true;
*prev_end = Some(child.end_byte());
return true;
}
} else {
if self.is_ignore_directive(child, "ignore-start") {
*in_ignore_range = true;
if let Some(end) = *prev_end {
self.emit_gap(end, child.start_byte(), *prev_was_comment);
}
self.write_source_span(Some(child.start_byte()), child.end_byte());
*prev_was_comment = true;
*prev_end = Some(child.end_byte());
return true;
}
if self.is_ignore_directive(child, "ignore") {
*ignore_next = true;
skip_ignore_self = true;
}
}
}
if !*in_ignore_range
&& matches!(child.kind(), "text" | "raw_text")
&& self.node_text(child).trim().is_empty()
{
return true;
}
if !skip_ignore_self && *in_ignore_range {
self.write_source_span(*prev_end, child.end_byte());
*prev_was_comment = child.kind() == "comment";
*prev_end = Some(child.end_byte());
return true;
}
if !skip_ignore_self && *ignore_next {
self.write_source_span(Some(child.start_byte()), child.end_byte());
if !self.out.ends_with('\n') {
self.out.push('\n');
}
*ignore_next = false;
*prev_was_comment = child.kind() == "comment";
*prev_end = Some(child.end_byte());
return true;
}
false
}
fn is_ignore_directive(&self, node: Node<'_>, suffix: &str) -> bool {
let inner = node
.child_by_field_name("text")
.map_or("", |t| self.node_text(t).trim());
self.options
.ignore_prefixes
.iter()
.any(|prefix| inner == format!("{prefix}-{suffix}"))
}
fn write_source_span(&mut self, from: Option<usize>, to: usize) {
let start = from.unwrap_or(to);
if start < to {
self.out
.push_str(std::str::from_utf8(&self.source[start..to]).unwrap_or_default());
}
}
fn source_blank_lines(&self, from: usize, to: usize) -> usize {
if from >= to {
return 0;
}
let gap = std::str::from_utf8(&self.source[from..to]).unwrap_or_default();
let newlines = gap.chars().filter(|&c| c == '\n').count();
newlines.saturating_sub(1)
}
fn emit_gap(&mut self, prev_end: usize, next_start: usize, prev_was_comment: bool) {
let source_gaps = self.source_blank_lines(prev_end, next_start);
let count = match self.options.blank_lines {
BlankLines::Remove => 0,
BlankLines::Preserve => source_gaps,
BlankLines::Truncate => source_gaps.min(1),
BlankLines::Insert => usize::from(!prev_was_comment),
};
for _ in 0..count {
self.out.push('\n');
}
}
const fn should_emit_embedded_blank(&self, consecutive: usize) -> bool {
match self.options.blank_lines {
BlankLines::Remove => false,
BlankLines::Truncate => consecutive <= 1,
BlankLines::Preserve | BlankLines::Insert => true,
}
}
fn node_text(&self, node: Node<'_>) -> &str {
std::str::from_utf8(&self.source[node.byte_range()]).unwrap_or_default()
}
fn write_line(&mut self, depth: usize, text: &str) {
self.out.push_str(&self.indent(depth));
self.out.push_str(text);
self.out.push('\n');
}
fn indent(&self, depth: usize) -> String {
if self.options.insert_spaces {
" ".repeat(depth.saturating_mul(self.options.indent_width))
} else {
"\t".repeat(depth)
}
}
}
fn text_content_entity_bounds<'a>(
children: &[Node<'a>],
tag_name: &str,
) -> Option<(Node<'a>, Node<'a>)> {
if !is_text_content_element(tag_name)
|| !children
.iter()
.any(|child| child.kind() == "entity_reference")
{
return None;
}
let start = children
.iter()
.find(|child| child.kind() == "start_tag")
.copied()?;
let end = children
.iter()
.find(|child| child.kind() == "end_tag")
.copied()?;
let all_inline = children
.iter()
.filter(|child| !matches!(child.kind(), "start_tag" | "end_tag"))
.all(|child| matches!(child.kind(), "text" | "raw_text" | "entity_reference"));
all_inline.then_some((start, end))
}