Skip to main content

svg_format/
lib.rs

1//! Deterministic structural formatting for SVG documents.
2//!
3//! # Examples
4//!
5//! ```rust
6//! let input = r#"<svg><rect x="0"  y="0" /></svg>"#;
7//! let formatted = svg_format::format(input);
8//! assert!(formatted.contains("<rect"));
9//! ```
10
11mod tag_parse;
12mod text_content;
13
14use tag_parse::{
15    ParsedAttribute, ParsedAttributeValue, ParsedTag, canonical_group_key, parse_tag,
16    reorder_attributes,
17};
18use text_content::{
19    collapse_whitespace, decode_xml_entities, dedent_block_with_offset, encode_xml_entities,
20    is_text_content_element, normalize_text_content_with_entities, strip_cdata_wrapper,
21};
22use tree_sitter::{Node, Parser};
23
24/// Attribute ordering mode.
25///
26/// # Examples
27///
28/// ```rust
29/// let mut options = svg_format::FormatOptions::default();
30/// options.attribute_sort = svg_format::AttributeSort::Alphabetical;
31/// let formatted = svg_format::format_with_options(r#"<svg y="2" x="1"/>"#, options);
32/// assert!(formatted.contains(r#"x="1" y="2""#));
33/// ```
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
36#[cfg_attr(feature = "cli", value(rename_all = "kebab-case"))]
37pub enum AttributeSort {
38    /// Keep original source order.
39    None,
40    /// SVG-aware canonical grouping/order.
41    #[default]
42    Canonical,
43    /// Sort attributes alphabetically by name.
44    Alphabetical,
45}
46
47/// Attribute wrapping mode.
48///
49/// # Examples
50///
51/// ```rust
52/// let mut options = svg_format::FormatOptions::default();
53/// options.attribute_layout = svg_format::AttributeLayout::MultiLine;
54/// let formatted = svg_format::format_with_options(r#"<svg><rect x="1" y="2"/></svg>"#, options);
55/// assert!(formatted.contains('\n'));
56/// ```
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
58#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
59#[cfg_attr(feature = "cli", value(rename_all = "kebab-case"))]
60pub enum AttributeLayout {
61    /// Wrap only when inline width exceeds threshold (or source was already multiline).
62    #[default]
63    Auto,
64    /// Always keep attributes in one line.
65    SingleLine,
66    /// Always wrap attributes into multiple lines (if any attributes exist).
67    MultiLine,
68}
69
70/// Quoting strategy for attribute values.
71///
72/// # Examples
73///
74/// ```rust
75/// let mut options = svg_format::FormatOptions::default();
76/// options.quote_style = svg_format::QuoteStyle::Single;
77/// let formatted = svg_format::format_with_options(r#"<svg><rect x="1"/></svg>"#, options);
78/// assert!(formatted.contains("x='1'"));
79/// ```
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
81#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
82#[cfg_attr(feature = "cli", value(rename_all = "kebab-case"))]
83pub enum QuoteStyle {
84    /// Preserve original quote style where present.
85    #[default]
86    Preserve,
87    /// Normalize quoted values to double quotes.
88    Double,
89    /// Normalize quoted values to single quotes.
90    Single,
91}
92
93/// Indentation strategy for wrapped attributes.
94///
95/// # Examples
96///
97/// ```rust
98/// let mut options = svg_format::FormatOptions::default();
99/// options.attribute_layout = svg_format::AttributeLayout::MultiLine;
100/// options.wrapped_attribute_indent = svg_format::WrappedAttributeIndent::OneLevel;
101/// let formatted = svg_format::format_with_options(r#"<svg><rect x="1" y="2"/></svg>"#, options);
102/// assert!(formatted.contains("\n    x="));
103/// ```
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
105#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
106#[cfg_attr(feature = "cli", value(rename_all = "kebab-case"))]
107pub enum WrappedAttributeIndent {
108    /// Add one normal indentation unit.
109    OneLevel,
110    /// Align to the column after `<tag ` so wrapped attributes line up visually.
111    #[default]
112    AlignToTagName,
113}
114
115/// How blank lines between sibling elements are handled.
116///
117/// # Examples
118///
119/// ```rust
120/// let mut options = svg_format::FormatOptions::default();
121/// options.blank_lines = svg_format::BlankLines::Remove;
122/// let formatted = svg_format::format_with_options("<svg><g/>\n\n<g/></svg>", options);
123/// assert!(!formatted.contains("\n\n"));
124/// ```
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
126#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
127#[cfg_attr(feature = "cli", value(rename_all = "kebab-case"))]
128pub enum BlankLines {
129    /// Strip all blank lines between siblings.
130    Remove,
131    /// Keep blank lines from source verbatim.
132    Preserve,
133    /// Collapse 2+ blank lines to exactly 1.
134    #[default]
135    Truncate,
136    /// Force exactly 1 blank line between every sibling.
137    Insert,
138}
139
140/// How the formatter handles whitespace in text nodes.
141///
142/// # Examples
143///
144/// ```rust
145/// let mut options = svg_format::FormatOptions::default();
146/// options.text_content = svg_format::TextContentMode::Collapse;
147/// let formatted = svg_format::format_with_options("<svg><text>a   b</text></svg>", options);
148/// assert!(formatted.contains("a b"));
149/// ```
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
151#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
152#[cfg_attr(feature = "cli", value(rename_all = "kebab-case"))]
153pub enum TextContentMode {
154    /// Collapse runs of whitespace into single spaces, trim lines, skip blanks.
155    Collapse,
156    /// Preserve content structure; dedent then re-indent to SVG depth.
157    #[default]
158    Maintain,
159    /// Trim each line, remove blank lines, re-indent to SVG depth.
160    Prettify,
161}
162
163/// The language of embedded content found within an SVG element.
164///
165/// # Examples
166///
167/// ```rust
168/// let language = svg_format::EmbeddedLanguage::Css;
169/// assert_eq!(language, svg_format::EmbeddedLanguage::Css);
170/// ```
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172pub enum EmbeddedLanguage {
173    /// CSS inside `<style>`.
174    Css,
175    /// JavaScript inside `<script>`.
176    JavaScript,
177    /// HTML/XHTML inside `<foreignObject>`.
178    Html,
179}
180
181const fn is_script_or_style_language(language: EmbeddedLanguage) -> bool {
182    matches!(
183        language,
184        EmbeddedLanguage::Css | EmbeddedLanguage::JavaScript
185    )
186}
187
188/// A request to format embedded content within an SVG document.
189///
190/// # Examples
191///
192/// ```rust
193/// let embedded = svg_format::EmbeddedContent {
194///     language: svg_format::EmbeddedLanguage::Css,
195///     content: ".icon{fill:red}",
196///     indent_depth: 1,
197///     file_byte_offset: 12,
198/// };
199/// assert_eq!(embedded.language, svg_format::EmbeddedLanguage::Css);
200/// ```
201pub struct EmbeddedContent<'a> {
202    /// The language of the embedded content.
203    pub language: EmbeddedLanguage,
204    /// The raw content text (common indent removed).
205    pub content: &'a str,
206    /// The nesting depth in the SVG tree where this content lives.
207    pub indent_depth: usize,
208    /// Byte offset in the original SVG source where the embedded payload
209    /// begins: the first non-whitespace content byte, past any `<![CDATA[`
210    /// prefix and the common leading indentation.
211    ///
212    /// This is a *block anchor*, not a byte-for-byte map. `content` is a
213    /// transformed view of the source — common indentation is stripped per
214    /// line, CRLF is normalized to LF, and (outside CDATA) XML entities are
215    /// decoded — so it is generally not byte-identical to
216    /// `&source[file_byte_offset..]`. A host callback can use this to locate
217    /// where the embedded region starts, but interior `(line, col)` positions
218    /// within `content` cannot be recovered by counting bytes from this
219    /// offset; that would require a full source map.
220    pub file_byte_offset: usize,
221}
222
223/// Formatter configuration for SVG pretty-printing.
224///
225/// # Examples
226///
227/// ```rust
228/// let options = svg_format::FormatOptions::default();
229/// assert_eq!(options.indent_width, 2);
230/// ```
231#[derive(Debug, Clone, PartialEq, Eq)]
232pub struct FormatOptions {
233    /// Number of spaces per indentation level when `insert_spaces` is true.
234    pub indent_width: usize,
235    /// Whether indentation should use spaces (true) or tabs (false).
236    pub insert_spaces: bool,
237    /// Maximum inline tag width before switching to multi-line attributes.
238    pub max_inline_tag_width: usize,
239    /// Attribute ordering mode.
240    pub attribute_sort: AttributeSort,
241    /// Attribute wrapping mode.
242    pub attribute_layout: AttributeLayout,
243    /// Maximum number of attributes emitted per wrapped line.
244    pub attributes_per_line: usize,
245    /// Emit a space before `/>` in self-closing tags.
246    pub space_before_self_close: bool,
247    /// Preferred quote style for attribute values.
248    pub quote_style: QuoteStyle,
249    /// Indentation style for wrapped attributes.
250    pub wrapped_attribute_indent: WrappedAttributeIndent,
251    /// How text-node whitespace is handled.
252    pub text_content: TextContentMode,
253    /// How blank lines between sibling elements are handled.
254    pub blank_lines: BlankLines,
255    /// Comment prefixes that trigger ignore directives.
256    ///
257    /// For each prefix `p`, the formatter recognizes:
258    /// - `<!-- p-ignore -->` — skip formatting the next sibling
259    /// - `<!-- p-ignore-file -->` — skip the entire file (detected anywhere)
260    /// - `<!-- p-ignore-start -->` / `<!-- p-ignore-end -->` — skip a range
261    ///
262    /// Defaults to `["svg-format"]`.
263    pub ignore_prefixes: Vec<String>,
264}
265
266impl Default for FormatOptions {
267    fn default() -> Self {
268        Self {
269            indent_width: 2,
270            insert_spaces: true,
271            max_inline_tag_width: 100,
272            attribute_sort: AttributeSort::Canonical,
273            attribute_layout: AttributeLayout::Auto,
274            attributes_per_line: 1,
275            space_before_self_close: true,
276            quote_style: QuoteStyle::Preserve,
277            wrapped_attribute_indent: WrappedAttributeIndent::AlignToTagName,
278            text_content: TextContentMode::Maintain,
279            blank_lines: BlankLines::Truncate,
280            ignore_prefixes: vec!["svg-format".to_string()],
281        }
282    }
283}
284
285/// Format an SVG source string with default options.
286///
287/// # Examples
288///
289/// ```rust
290/// let formatted = svg_format::format(r#"<svg><rect x="1"/></svg>"#);
291/// assert!(formatted.contains("<rect"));
292/// ```
293#[must_use]
294pub fn format(source: &str) -> String {
295    format_with_options(source, FormatOptions::default())
296}
297
298/// Format an SVG source string with explicit options.
299///
300/// # Examples
301///
302/// ```rust
303/// let mut options = svg_format::FormatOptions::default();
304/// options.space_before_self_close = false;
305/// let formatted = svg_format::format_with_options("<svg />", options);
306/// assert!(formatted.contains("<svg/>"));
307/// ```
308#[must_use]
309pub fn format_with_options(source: &str, options: FormatOptions) -> String {
310    format_with_host(source, options, &mut |_| None)
311}
312
313/// Format an SVG source string, delegating embedded content to a callback.
314///
315/// The callback receives [`EmbeddedContent`] for `<style>`, `<script>`, and
316/// `<foreignObject>` blocks. Return `Some(formatted)` to use the formatted
317/// result, or `None` to fall back to the default text-handling behavior.
318///
319/// # Examples
320///
321/// ```rust
322/// let mut seen_css = false;
323/// let formatted = svg_format::format_with_host(
324///     "<svg><style>.icon{fill:red}</style></svg>",
325///     svg_format::FormatOptions::default(),
326///     &mut |embedded| {
327///         seen_css = embedded.language == svg_format::EmbeddedLanguage::Css;
328///         Some(".icon { fill: red; }".to_owned())
329///     },
330/// );
331/// assert!(seen_css);
332/// assert!(formatted.contains(".icon { fill: red; }"));
333/// ```
334#[must_use]
335pub fn format_with_host(
336    source: &str,
337    options: FormatOptions,
338    format_embedded: &mut dyn FnMut(EmbeddedContent<'_>) -> Option<String>,
339) -> String {
340    let mut parser = Parser::new();
341    if parser
342        .set_language(&tree_sitter_svg::LANGUAGE.into())
343        .is_err()
344    {
345        return normalize_line_endings(source);
346    }
347
348    let Some(tree) = parser.parse(source.as_bytes(), None) else {
349        return normalize_line_endings(source);
350    };
351
352    if tree.root_node().has_error() {
353        return normalize_line_endings(source);
354    }
355
356    // Check for ignore-file directive in actual comment nodes only.
357    if has_ignore_file_comment(
358        tree.root_node(),
359        source.as_bytes(),
360        &options.ignore_prefixes,
361    ) {
362        return normalize_line_endings(source);
363    }
364
365    let mut formatter = Formatter::new(source.as_bytes(), options);
366    formatter.format_node(tree.root_node(), 0, format_embedded);
367    formatter.finish(source)
368}
369
370/// Normalize CRLF and bare CR to LF.
371///
372/// [`format_with_host`] guarantees pure-LF output so callers can safely
373/// translate line endings with a blanket `replace('\n', target)` without
374/// double-counting CRs that the source happened to contain.
375fn normalize_line_endings(source: &str) -> String {
376    if !source.contains('\r') {
377        return source.to_owned();
378    }
379    source.replace("\r\n", "\n").replace('\r', "\n")
380}
381
382/// Walk the AST looking for `<!-- {prefix}-ignore-file -->` in comment nodes.
383fn has_ignore_file_comment(node: Node<'_>, source: &[u8], prefixes: &[String]) -> bool {
384    if node.kind() == "comment" {
385        let inner = node
386            .child_by_field_name("text")
387            .and_then(|t| std::str::from_utf8(&source[t.byte_range()]).ok())
388            .map_or("", str::trim);
389        if prefixes.iter().any(|p| inner == format!("{p}-ignore-file")) {
390            return true;
391        }
392    }
393    let mut cursor = node.walk();
394    node.named_children(&mut cursor)
395        .any(|child| has_ignore_file_comment(child, source, prefixes))
396}
397
398/// Greedily pack SVG path-data segments (or `points` coordinate pairs)
399/// onto wrapped lines separated by `\n`, breaking at segment or pair
400/// boundaries so the value fits within `budget` characters per line
401/// when possible. Returns `Some(value)` with embedded newlines for the
402/// caller to re-emit under continuation alignment, or `None` if the
403/// value fits as-is, is empty, or can't be parsed.
404///
405/// The formatter never reformats minified path-data on its own merit;
406/// `wrap_path_data` is only invoked when the tag would otherwise
407/// overflow `max_inline_tag_width` and we're choosing to break at
408/// semantic boundaries instead of leaving a 12kB single-line blob.
409fn wrap_path_data(name: &str, raw: &str, budget: usize) -> Option<String> {
410    if budget == 0 || raw.chars().count() <= budget {
411        return None;
412    }
413    if raw.trim().is_empty() {
414        return None;
415    }
416    if raw.contains('\n') {
417        // Source-preserved embedded newlines take the existing
418        // continuation path — don't re-wrap.
419        return None;
420    }
421    match name {
422        "d" => wrap_d_value(raw, budget),
423        "points" => wrap_points_value(raw, budget),
424        _ => None,
425    }
426}
427
428fn wrap_d_value(raw: &str, budget: usize) -> Option<String> {
429    // Path data lives in the injected `svg_path` grammar — the host grammar
430    // now exposes the `d`/`path` value as one opaque `path_data_payload`
431    // token. Parse the raw attribute value directly with `svg_path` to
432    // recover structured segments (`source_file` → `path_data` → segments).
433    // Empty/whitespace path data is valid; a parse error means we leave the
434    // value untouched.
435    let mut parser = Parser::new();
436    parser
437        .set_language(&tree_sitter_svg_path::LANGUAGE.into())
438        .ok()?;
439    let tree = parser.parse(raw.as_bytes(), None)?;
440    if tree.root_node().has_error() {
441        return None;
442    }
443
444    let mut segments: Vec<(usize, usize, &str)> = Vec::new();
445    collect_path_segments(tree.root_node(), raw.as_bytes(), &mut segments);
446    if segments.is_empty() {
447        return None;
448    }
449
450    let segment_strs: Vec<&str> = segments
451        .iter()
452        .map(|&(start, end, _kind)| &raw[start..end])
453        .collect();
454    let segment_kinds: Vec<&str> = segments.iter().map(|&(_, _, kind)| kind).collect();
455
456    pack_segments(&segment_strs, &segment_kinds, budget, true)
457}
458
459fn wrap_points_value(raw: &str, budget: usize) -> Option<String> {
460    // `<polyline/polygon points="…">` has no grammar children for the
461    // coordinate pairs — split on whitespace into pairs manually. A pair
462    // is an `x,y` (comma-separated) or two whitespace-separated numbers;
463    // this implementation preserves the inter-pair separator style the
464    // source uses by splitting only on runs of whitespace between pairs.
465    let tokens: Vec<&str> = raw.split_ascii_whitespace().collect();
466    if tokens.is_empty() {
467        return None;
468    }
469    // Re-group tokens into pairs. If a token already contains a comma
470    // (e.g. `10,20`) it's a full pair; otherwise two consecutive tokens
471    // form a pair.
472    let mut pairs: Vec<String> = Vec::new();
473    let mut i = 0;
474    while i < tokens.len() {
475        let t = tokens[i];
476        if t.contains(',') {
477            pairs.push(t.to_string());
478            i += 1;
479        } else if i + 1 < tokens.len() {
480            pairs.push(format!("{t},{}", tokens[i + 1]));
481            i += 2;
482        } else {
483            pairs.push(t.to_string());
484            i += 1;
485        }
486    }
487    if pairs.len() <= 1 {
488        return None;
489    }
490    let pair_refs: Vec<&str> = pairs.iter().map(String::as_str).collect();
491    let kinds = vec!["pair"; pair_refs.len()];
492    pack_segments(&pair_refs, &kinds, budget, false)
493}
494
495/// Greedy packer: emit segments separated by single spaces, wrapping
496/// to a new line at `\n` when adding the next segment would exceed
497/// `budget`. When `prefer_subpath_breaks` is set, a `moveto_segment`
498/// forces a new line if the current line is already half full — this
499/// keeps `M...` subpath starts from dangling at the end of a line.
500fn pack_segments(
501    segments: &[&str],
502    kinds: &[&str],
503    budget: usize,
504    prefer_subpath_breaks: bool,
505) -> Option<String> {
506    if segments.is_empty() {
507        return None;
508    }
509    let mut out = String::with_capacity(segments.iter().map(|s| s.len() + 1).sum::<usize>());
510    let mut line_width = 0usize;
511    let half_budget = budget / 2;
512    for (i, seg) in segments.iter().enumerate() {
513        let w = seg.chars().count();
514        let is_moveto_split = prefer_subpath_breaks
515            && i > 0
516            && kinds[i] == "moveto_segment"
517            && line_width >= half_budget;
518        if line_width == 0 {
519            out.push_str(seg);
520            line_width = w;
521        } else if !is_moveto_split && line_width + 1 + w <= budget {
522            out.push(' ');
523            out.push_str(seg);
524            line_width += 1 + w;
525        } else {
526            out.push('\n');
527            out.push_str(seg);
528            line_width = w;
529        }
530    }
531    if out.contains('\n') { Some(out) } else { None }
532}
533
534fn collect_path_segments(
535    node: Node<'_>,
536    source: &[u8],
537    segments: &mut Vec<(usize, usize, &'static str)>,
538) {
539    const SEGMENT_KINDS: &[&str] = &[
540        "moveto_segment",
541        "lineto_segment",
542        "closepath_segment",
543        "curveto_segment",
544        "smooth_curveto_segment",
545        "quadratic_bezier_curveto_segment",
546        "smooth_quadratic_bezier_curveto_segment",
547        "elliptical_arc_segment",
548        "horizontal_lineto_segment",
549        "vertical_lineto_segment",
550        "implicit_lineto_segment",
551    ];
552    let kind = node.kind();
553    if let Some(&matched) = SEGMENT_KINDS.iter().find(|k| **k == kind) {
554        let range = node.byte_range();
555        // Use the raw slice to drop any trailing whitespace captured
556        // inside the segment — keeps packed output compact.
557        let text = std::str::from_utf8(&source[range.clone()])
558            .unwrap_or("")
559            .trim_end();
560        let end = range.start + text.len();
561        segments.push((range.start, end, matched));
562        return;
563    }
564    let mut cursor = node.walk();
565    for child in node.named_children(&mut cursor) {
566        collect_path_segments(child, source, segments);
567    }
568}
569
570/// Partition attributes into contiguous runs sharing the same canonical
571/// group key. Returned as lists of indices into the input slice so the
572/// caller can emit them without reallocating attribute data.
573fn partition_by_canonical_group(attributes: &[ParsedAttribute]) -> Vec<Vec<usize>> {
574    if attributes.is_empty() {
575        return Vec::new();
576    }
577    let mut groups: Vec<Vec<usize>> = Vec::new();
578    let mut current: Vec<usize> = vec![0];
579    let mut current_key = canonical_group_key(&attributes[0].name);
580    for (i, attr) in attributes.iter().enumerate().skip(1) {
581        let key = canonical_group_key(&attr.name);
582        if key == current_key {
583            current.push(i);
584        } else {
585            groups.push(std::mem::take(&mut current));
586            current.push(i);
587            current_key = key;
588        }
589    }
590    if !current.is_empty() {
591        groups.push(current);
592    }
593    groups
594}
595
596/// If a group's rendered width on a single wrapped line would exceed
597/// `budget`, fall back to one attribute per line within that group.
598/// Otherwise emit the group as a single chunk.
599///
600/// Rendered width here uses the raw `<name>=<quoted-value>` form with a
601/// single space separator. This is an approximation of the final
602/// `render_attribute_aligned` output for single-line values — exact
603/// enough to decide overflow since quote style doesn't change column
604/// width.
605fn split_group_if_overflow(
606    attributes: &[ParsedAttribute],
607    group: Vec<usize>,
608    budget: usize,
609) -> Vec<Vec<usize>> {
610    let width: usize = group
611        .iter()
612        .map(|&i| approximate_attribute_width(&attributes[i]))
613        .sum::<usize>()
614        + group.len().saturating_sub(1);
615    if width <= budget || group.len() <= 1 {
616        vec![group]
617    } else {
618        group.into_iter().map(|i| vec![i]).collect()
619    }
620}
621
622/// Rough column width of `name=(quote)value(quote)`. `value` and quotes
623/// contribute their literal `chars().count()`; a bare attribute has
624/// just the name width.
625fn approximate_attribute_width(attribute: &ParsedAttribute) -> usize {
626    let name_width = attribute.name.chars().count();
627    attribute.value.as_ref().map_or(name_width, |v| {
628        let quote_width = if v.original_quote.is_some() { 2 } else { 0 };
629        name_width + 1 + quote_width + v.raw.chars().count()
630    })
631}
632
633/// Decide which attributes ride the tag line and which become the head
634/// of the wrapped remainder. Pops trailing attributes off the first
635/// chunk until it fits within `line_budget`, with a hard minimum of one
636/// attribute on the tag line (per W3 sample style).
637///
638/// `tag_line_prefix_width` is the column count consumed by `<tagname `
639/// plus leading indent — i.e. everything before the first attribute.
640/// Returns `(first_chunk_indices, rest_chunks)` where `rest_chunks`
641/// contains any popped attrs as a new chunk prepended to the remaining
642/// wrapped lines.
643fn split_first_chunk_for_tag_line(
644    attributes: &[ParsedAttribute],
645    chunks: &mut Vec<Vec<usize>>,
646    tag_line_prefix_width: usize,
647    line_budget: usize,
648    mut render: impl FnMut(&ParsedAttribute) -> String,
649) -> (Vec<usize>, Vec<Vec<usize>>) {
650    if chunks.is_empty() {
651        return (Vec::new(), Vec::new());
652    }
653    let first = chunks.remove(0);
654    let mut kept: Vec<usize> = Vec::with_capacity(first.len());
655    let mut popped: Vec<usize> = Vec::new();
656    for &idx in &first {
657        let candidate = render(&attributes[idx]);
658        let tentative = if kept.is_empty() {
659            tag_line_prefix_width + candidate.chars().count()
660        } else {
661            // existing width + space separator + new attr
662            let existing: usize = tag_line_prefix_width
663                + kept
664                    .iter()
665                    .map(|&i| render(&attributes[i]).chars().count() + 1)
666                    .sum::<usize>()
667                - 1;
668            existing + 1 + candidate.chars().count()
669        };
670        if !kept.is_empty() && tentative > line_budget {
671            popped.push(idx);
672        } else {
673            kept.push(idx);
674        }
675    }
676    // Once we start popping, any later attrs in this group also get
677    // pushed into the remainder — preserve source order by appending.
678    for &idx in &first {
679        if !kept.contains(&idx) && !popped.contains(&idx) {
680            popped.push(idx);
681        }
682    }
683    let rest: Vec<Vec<usize>> = if popped.is_empty() {
684        std::mem::take(chunks)
685    } else {
686        std::iter::once(popped)
687            .chain(std::mem::take(chunks))
688            .collect()
689    };
690    (kept, rest)
691}
692
693struct Formatter<'a> {
694    source: &'a [u8],
695    options: FormatOptions,
696    out: String,
697}
698
699impl<'a> Formatter<'a> {
700    const fn new(source: &'a [u8], options: FormatOptions) -> Self {
701        Self {
702            source,
703            options,
704            out: String::new(),
705        }
706    }
707
708    fn finish(mut self, original: &str) -> String {
709        while self.out.ends_with('\n') {
710            self.out.pop();
711        }
712        if original.ends_with('\n') {
713            self.out.push('\n');
714        }
715        self.out
716    }
717
718    fn format_node(
719        &mut self,
720        node: Node<'_>,
721        depth: usize,
722        fmt: &mut dyn FnMut(EmbeddedContent<'_>) -> Option<String>,
723    ) {
724        match node.kind() {
725            "svg_root_element" | "element" => self.format_element_like(node, depth, fmt),
726            "start_tag" => self.write_tag_node(node, depth, false),
727            "self_closing_tag" => self.write_tag_node(node, depth, true),
728            "style_text_double" | "style_text_single" | "script_text_double"
729            | "script_text_single" => {
730                self.write_preserved_block_text(node, depth);
731            }
732            "text" | "raw_text" => {
733                self.write_text_node(node, depth);
734            }
735            "end_tag"
736            | "comment"
737            | "cdata_section"
738            | "doctype"
739            | "processing_instruction"
740            | "xml_declaration"
741            | "entity_reference"
742            | "erroneous_end_tag" => {
743                let text = self.node_text(node).trim().to_string();
744                self.write_line(depth, &text);
745            }
746            _ => self.format_children(node, depth, fmt),
747        }
748    }
749
750    fn format_children(
751        &mut self,
752        node: Node<'_>,
753        depth: usize,
754        fmt: &mut dyn FnMut(EmbeddedContent<'_>) -> Option<String>,
755    ) {
756        let mut cursor = node.walk();
757        let mut prev_end: Option<usize> = None;
758        let mut prev_was_comment = false;
759        let mut ignore_next = false;
760        let mut in_ignore_range = false;
761        for child in node.named_children(&mut cursor) {
762            if self.handle_ignore(
763                child,
764                &mut in_ignore_range,
765                &mut ignore_next,
766                &mut prev_was_comment,
767                &mut prev_end,
768            ) {
769                continue;
770            }
771
772            if let Some(end) = prev_end {
773                self.emit_gap(end, child.start_byte(), prev_was_comment);
774            }
775            self.format_node(child, depth, fmt);
776            prev_was_comment = child.kind() == "comment";
777            prev_end = Some(child.end_byte());
778        }
779    }
780
781    fn format_element_like(
782        &mut self,
783        node: Node<'_>,
784        depth: usize,
785        fmt: &mut dyn FnMut(EmbeddedContent<'_>) -> Option<String>,
786    ) {
787        let mut cursor = node.walk();
788        let children: Vec<Node<'_>> = node.named_children(&mut cursor).collect();
789        if children.is_empty() {
790            return;
791        }
792
793        if children.len() == 1 && children[0].kind() == "self_closing_tag" {
794            self.format_node(children[0], depth, fmt);
795            return;
796        }
797
798        let tag_name: String = children
799            .iter()
800            .find(|c| c.kind() == "start_tag")
801            .and_then(|tag| {
802                let text = self.node_text(*tag).trim();
803                text.strip_prefix('<')
804                    .and_then(|s| s.split(|c: char| c.is_whitespace() || c == '>').next())
805                    .map(str::to_string)
806            })
807            .unwrap_or_default();
808
809        let embedded_lang = match tag_name.as_str() {
810            "style" => Some(EmbeddedLanguage::Css),
811            "script" => Some(EmbeddedLanguage::JavaScript),
812            "foreignObject" => Some(EmbeddedLanguage::Html),
813            _ => None,
814        };
815
816        if embedded_lang == Some(EmbeddedLanguage::Html)
817            && self
818                .try_format_foreign_object(&children, depth, fmt)
819                .is_some()
820        {
821            return;
822        }
823
824        if let Some((start, end)) = text_content_entity_bounds(&children, &tag_name) {
825            self.format_text_content_element(start, end, depth);
826            return;
827        }
828
829        self.format_element_children(&children, embedded_lang, depth, fmt);
830    }
831
832    fn format_element_children(
833        &mut self,
834        children: &[Node<'_>],
835        embedded_lang: Option<EmbeddedLanguage>,
836        depth: usize,
837        fmt: &mut dyn FnMut(EmbeddedContent<'_>) -> Option<String>,
838    ) {
839        let mut prev_end: Option<usize> = None;
840        let mut prev_was_comment = false;
841        let mut ignore_next = false;
842        let mut in_ignore_range = false;
843        for &child in children {
844            if self.handle_ignore(
845                child,
846                &mut in_ignore_range,
847                &mut ignore_next,
848                &mut prev_was_comment,
849                &mut prev_end,
850            ) {
851                continue;
852            }
853
854            match child.kind() {
855                "start_tag" | "end_tag" => {
856                    self.format_node(child, depth, fmt);
857                }
858                "style_text_double" | "style_text_single" | "script_text_double"
859                | "script_text_single" => {
860                    if !self.node_text(child).trim().is_empty() {
861                        self.write_preserved_block_text(child, depth + 1);
862                    }
863                    prev_was_comment = false;
864                    prev_end = Some(child.end_byte());
865                }
866                "cdata_section" if embedded_lang.is_some_and(is_script_or_style_language) => {
867                    let Some(lang) = embedded_lang else { continue };
868                    self.format_embedded_child(
869                        child,
870                        lang,
871                        depth + 1,
872                        fmt,
873                        (&mut prev_end, &mut prev_was_comment),
874                        true,
875                    );
876                }
877                "text" | "raw_text" => {
878                    if let Some(lang) = embedded_lang
879                        && lang != EmbeddedLanguage::Html
880                    {
881                        self.format_embedded_child(
882                            child,
883                            lang,
884                            depth + 1,
885                            fmt,
886                            (&mut prev_end, &mut prev_was_comment),
887                            false,
888                        );
889                        continue;
890                    }
891                    self.format_plain_text_child(
892                        child,
893                        depth + 1,
894                        &mut prev_end,
895                        &mut prev_was_comment,
896                    );
897                }
898                _ => {
899                    if let Some(end) = prev_end {
900                        self.emit_gap(end, child.start_byte(), prev_was_comment);
901                    }
902                    self.format_node(child, depth + 1, fmt);
903                    prev_was_comment = child.kind() == "comment";
904                    prev_end = Some(child.end_byte());
905                }
906            }
907        }
908    }
909
910    fn format_plain_text_child(
911        &mut self,
912        child: Node<'_>,
913        depth: usize,
914        prev_end: &mut Option<usize>,
915        prev_was_comment: &mut bool,
916    ) {
917        if self.node_text(child).trim().is_empty() {
918            return;
919        }
920        if let Some(end) = *prev_end {
921            self.emit_gap(end, child.start_byte(), *prev_was_comment);
922        }
923        self.write_text_node(child, depth);
924        *prev_was_comment = false;
925        *prev_end = Some(child.end_byte());
926    }
927
928    fn format_embedded_child(
929        &mut self,
930        child: Node<'_>,
931        language: EmbeddedLanguage,
932        depth: usize,
933        fmt: &mut dyn FnMut(EmbeddedContent<'_>) -> Option<String>,
934        previous: (&mut Option<usize>, &mut bool),
935        force_preserve: bool,
936    ) {
937        let (prev_end, prev_was_comment) = previous;
938        if self.node_text(child).trim().is_empty() {
939            return;
940        }
941        if let Some(end) = *prev_end {
942            self.emit_gap(end, child.start_byte(), *prev_was_comment);
943        }
944        if !self.try_format_embedded_text(child, language, depth, fmt) {
945            if force_preserve || self.options.text_content == TextContentMode::Maintain {
946                self.write_preserved_embedded_text(child, depth);
947            } else {
948                self.write_text_node(child, depth);
949            }
950        }
951        *prev_was_comment = false;
952        *prev_end = Some(child.end_byte());
953    }
954
955    /// Format a text-content element whose children include entity
956    /// references mixed with text. Extracts the raw source between start
957    /// and end tags, normalizes whitespace into a single line, and inlines
958    /// with the tags when it fits.
959    ///
960    /// Called only when `entity_reference` nodes are present — the whitespace
961    /// around them is a formatting artifact, not meaningful content, so we
962    /// always normalize regardless of [`TextContentMode`].
963    fn format_text_content_element(&mut self, start: Node<'_>, end: Node<'_>, depth: usize) {
964        let raw = std::str::from_utf8(&self.source[start.end_byte()..end.start_byte()])
965            .unwrap_or_default();
966        let normalized = normalize_text_content_with_entities(raw);
967        let end_text = self.node_text(end).trim().to_string();
968
969        // Render the start tag (capture output for potential inline rewrite).
970        let out_before = self.out.len();
971        self.write_tag_node(start, depth, false);
972        let tag_output = self.out[out_before..].to_string();
973
974        if normalized.is_empty() {
975            self.write_line(depth, &end_text);
976            return;
977        }
978
979        // Try to keep everything on one line: <tag>content</tag>
980        let tag_str = tag_output.trim_end_matches('\n');
981        if !tag_str.contains('\n') {
982            let tag_inline = tag_str.trim_start();
983            let candidate = format!("{tag_inline}{normalized}{end_text}");
984            if self.indent(depth).len() + candidate.len() <= self.options.max_inline_tag_width {
985                self.out.truncate(out_before);
986                self.write_line(depth, &candidate);
987                return;
988            }
989        }
990
991        // Doesn't fit inline — write normalized content on its own line.
992        self.write_line(depth + 1, &normalized);
993        self.write_line(depth, &end_text);
994    }
995
996    fn write_tag_node(&mut self, node: Node<'_>, depth: usize, self_closing: bool) {
997        let raw = self.node_text(node).trim().to_string();
998        let Some(mut tag) = parse_tag(&raw, self_closing) else {
999            self.write_line(depth, &raw);
1000            return;
1001        };
1002        reorder_attributes(&mut tag.attributes, self.options.attribute_sort);
1003        let rendered_attributes: Vec<String> = tag
1004            .attributes
1005            .iter()
1006            .map(|attribute| self.render_attribute(attribute))
1007            .collect();
1008        let inline = self.render_inline_tag(&tag.name, &rendered_attributes, self_closing);
1009
1010        if !self.should_break_into_multiline(&raw, &inline, &rendered_attributes) {
1011            self.write_line(depth, &inline);
1012            return;
1013        }
1014        if rendered_attributes.is_empty() {
1015            self.emit_attributeless_multiline(depth, &tag.name, self_closing);
1016            return;
1017        }
1018        self.emit_multiline_tag(depth, &mut tag, self_closing);
1019    }
1020
1021    /// Build the one-line rendering of a tag with all attributes inline.
1022    /// Used as the candidate both for the Auto-layout width check and as
1023    /// the direct output when no wrapping is needed.
1024    fn render_inline_tag(
1025        &self,
1026        name: &str,
1027        rendered_attributes: &[String],
1028        self_closing: bool,
1029    ) -> String {
1030        let mut inline = format!("<{name}");
1031        if !rendered_attributes.is_empty() {
1032            inline.push(' ');
1033            inline.push_str(&rendered_attributes.join(" "));
1034        }
1035        if self_closing {
1036            inline.push_str(self.self_closing_suffix());
1037        } else {
1038            inline.push('>');
1039        }
1040        inline
1041    }
1042
1043    /// Decide whether the attribute layout forces (or permits) breaking
1044    /// a tag across multiple lines. `SingleLine` never breaks;
1045    /// `MultiLine` always does when any attributes exist; `Auto` breaks
1046    /// on source-side newlines or inline-width overflow.
1047    fn should_break_into_multiline(
1048        &self,
1049        raw: &str,
1050        inline: &str,
1051        rendered_attributes: &[String],
1052    ) -> bool {
1053        match self.options.attribute_layout {
1054            AttributeLayout::SingleLine => false,
1055            AttributeLayout::MultiLine => !rendered_attributes.is_empty(),
1056            AttributeLayout::Auto => {
1057                raw.contains('\n') || inline.len() > self.options.max_inline_tag_width
1058            }
1059        }
1060    }
1061
1062    /// Emit an attributeless tag that still needs breaking onto its own
1063    /// line (e.g. forced by `MultiLine` layout). The open bracket goes
1064    /// on one line, closer on the next — mirrors the W3 convention.
1065    fn emit_attributeless_multiline(&mut self, depth: usize, tag_name: &str, self_closing: bool) {
1066        self.write_line(depth, &format!("<{tag_name}"));
1067        if self_closing {
1068            self.write_line(depth, self.self_closing_suffix());
1069        } else {
1070            self.write_line(depth, ">");
1071        }
1072    }
1073
1074    /// Full multi-line tag emission: apply the path-data auto-wrap pass,
1075    /// choose a chunking strategy (canonical groups vs fixed chunks),
1076    /// then either ride the first chunk on the tag line
1077    /// (`AlignToTagName`) or place the opening bracket alone on line 1
1078    /// (`OneLevel`) and emit each remaining chunk on its own wrapped
1079    /// line.
1080    fn emit_multiline_tag(&mut self, depth: usize, tag: &mut ParsedTag, self_closing: bool) {
1081        let wrapped_prefix = self.wrapped_attribute_prefix(depth, &tag.name);
1082        self.auto_wrap_path_data_values(&mut tag.attributes, &wrapped_prefix);
1083
1084        // Force one-attribute-per-line when any value carries internal
1085        // newlines (typical of `d="..."` path data in W3 samples). The
1086        // continuation-alignment logic in `render_attribute_aligned`
1087        // assumes a known start column for the attribute name; packing
1088        // multiple such attributes per wrapped line would break that.
1089        let has_multiline_value = tag
1090            .attributes
1091            .iter()
1092            .any(|a| a.value.as_ref().is_some_and(|v| v.raw.contains('\n')));
1093
1094        // AlignToTagName pairs naturally with "first attribute inline on
1095        // the tag line": the wrapped-prefix width equals the column
1096        // where the first attribute would sit inline (indent + "<tag "),
1097        // so `render_attribute_aligned` produces correct continuation
1098        // alignment for the first attr's multi-line values *and* the
1099        // same prefix aligns subsequent wrapped attrs under the first.
1100        // OneLevel cannot do this cleanly — its column math differs —
1101        // so we keep the existing "<tag alone on line 1" layout there.
1102        let first_inline = matches!(
1103            self.options.wrapped_attribute_indent,
1104            WrappedAttributeIndent::AlignToTagName,
1105        );
1106        let closer = if self_closing {
1107            self.self_closing_suffix()
1108        } else {
1109            ">"
1110        };
1111
1112        let chunks = self.build_wrap_chunks(&tag.attributes, &wrapped_prefix, has_multiline_value);
1113
1114        if first_inline {
1115            self.emit_first_inline_layout(depth, tag, chunks, &wrapped_prefix, closer);
1116        } else {
1117            self.emit_one_level_layout(depth, tag, &chunks, &wrapped_prefix, closer);
1118        }
1119    }
1120
1121    /// Auto-wrap minified `d="…"` path data and `<polyline/polygon>
1122    /// points="…"` values at semantic boundaries (M/L/C segments,
1123    /// coordinate pairs) when a single inline line would overflow
1124    /// `max_inline_tag_width`. Values that already carry source
1125    /// newlines skip this pass and keep their preserved layout.
1126    fn auto_wrap_path_data_values(&self, attributes: &mut [ParsedAttribute], wrapped_prefix: &str) {
1127        for attribute in attributes {
1128            let Some(value) = attribute.value.as_mut() else {
1129                continue;
1130            };
1131            let name_width = attribute.name.chars().count();
1132            let available = self
1133                .options
1134                .max_inline_tag_width
1135                .saturating_sub(wrapped_prefix.len() + name_width + 2);
1136            if let Some(wrapped) = wrap_path_data(&attribute.name, &value.raw, available) {
1137                value.raw = wrapped;
1138            }
1139        }
1140    }
1141
1142    /// Partition attributes into chunks that will each occupy one
1143    /// wrapped line.
1144    ///
1145    /// - Canonical sort + no multiline values: one wrapped line per
1146    ///   canonical group (identity / geometry / drawing / refs /
1147    ///   presentation / other / namespaces / version). If a group's
1148    ///   rendered width exceeds budget, fall back to one-per-line
1149    ///   within that group.
1150    /// - Otherwise: chunks of `attributes_per_line` (existing
1151    ///   behavior; multiline values already force `per_line=1` because
1152    ///   the continuation-alignment helper assumes a known column).
1153    fn build_wrap_chunks(
1154        &self,
1155        attributes: &[ParsedAttribute],
1156        wrapped_prefix: &str,
1157        has_multiline_value: bool,
1158    ) -> Vec<Vec<usize>> {
1159        let budget = self
1160            .options
1161            .max_inline_tag_width
1162            .saturating_sub(wrapped_prefix.len());
1163        if matches!(self.options.attribute_sort, AttributeSort::Canonical) && !has_multiline_value {
1164            partition_by_canonical_group(attributes)
1165                .into_iter()
1166                .flat_map(|group| split_group_if_overflow(attributes, group, budget))
1167                .collect()
1168        } else {
1169            let per_line = if has_multiline_value {
1170                1
1171            } else {
1172                self.options.attributes_per_line.max(1)
1173            };
1174            (0..attributes.len())
1175                .collect::<Vec<_>>()
1176                .chunks(per_line)
1177                .map(<[usize]>::to_vec)
1178                .collect()
1179        }
1180    }
1181
1182    /// `AlignToTagName` layout: the first chunk rides the tag line
1183    /// with `<tag ` prefix. If it would overflow the budget, pop
1184    /// attributes off its tail until it fits; popped attrs become a
1185    /// new wrapped chunk at the front of the rest. At minimum the
1186    /// first attribute stays on the tag line — matching W3 SVG sample
1187    /// style.
1188    fn emit_first_inline_layout(
1189        &mut self,
1190        depth: usize,
1191        tag: &ParsedTag,
1192        mut chunks: Vec<Vec<usize>>,
1193        wrapped_prefix: &str,
1194        closer: &str,
1195    ) {
1196        let (first_chunk_indices, rest_chunks) = split_first_chunk_for_tag_line(
1197            &tag.attributes,
1198            &mut chunks,
1199            self.indent(depth).len() + tag.name.chars().count() + 2, // "<" + name + " "
1200            self.options.max_inline_tag_width,
1201            |attr| self.render_attribute_aligned(attr, wrapped_prefix),
1202        );
1203        let first_rendered: Vec<String> = first_chunk_indices
1204            .iter()
1205            .map(|&i| self.render_attribute_aligned(&tag.attributes[i], wrapped_prefix))
1206            .collect();
1207        let mut tag_line = format!(
1208            "{}<{} {}",
1209            self.indent(depth),
1210            tag.name,
1211            first_rendered.join(" ")
1212        );
1213        if rest_chunks.is_empty() {
1214            tag_line.push_str(closer);
1215        }
1216        tag_line.push('\n');
1217        self.out.push_str(&tag_line);
1218        self.emit_wrapped_chunks(tag, &rest_chunks, wrapped_prefix, closer);
1219    }
1220
1221    /// `OneLevel` layout: the opening bracket sits alone on line 1 at
1222    /// `depth` indent; every chunk wraps onto its own line at
1223    /// `depth + 1` indent (the `wrapped_prefix`).
1224    fn emit_one_level_layout(
1225        &mut self,
1226        depth: usize,
1227        tag: &ParsedTag,
1228        chunks: &[Vec<usize>],
1229        wrapped_prefix: &str,
1230        closer: &str,
1231    ) {
1232        self.write_line(depth, &format!("<{}", tag.name));
1233        self.emit_wrapped_chunks(tag, chunks, wrapped_prefix, closer);
1234    }
1235
1236    /// Emit pre-computed chunks one-per-line at `wrapped_prefix`,
1237    /// appending `closer` to the final chunk's line.
1238    fn emit_wrapped_chunks(
1239        &mut self,
1240        tag: &ParsedTag,
1241        chunks: &[Vec<usize>],
1242        wrapped_prefix: &str,
1243        closer: &str,
1244    ) {
1245        for (index, chunk) in chunks.iter().enumerate() {
1246            let rendered: Vec<String> = chunk
1247                .iter()
1248                .map(|&i| self.render_attribute_aligned(&tag.attributes[i], wrapped_prefix))
1249                .collect();
1250            let mut line = rendered.join(" ");
1251            if index == chunks.len() - 1 {
1252                line.push_str(closer);
1253            }
1254            self.write_prefixed_line(wrapped_prefix, &line);
1255        }
1256    }
1257
1258    const fn self_closing_suffix(&self) -> &'static str {
1259        if self.options.space_before_self_close {
1260            " />"
1261        } else {
1262            "/>"
1263        }
1264    }
1265
1266    fn render_attribute(&self, attribute: &ParsedAttribute) -> String {
1267        attribute.value.as_ref().map_or_else(
1268            || attribute.name.clone(),
1269            |value| format!("{}={}", attribute.name, self.render_attribute_value(value)),
1270        )
1271    }
1272
1273    fn render_attribute_value(&self, value: &ParsedAttributeValue) -> String {
1274        match self.options.quote_style {
1275            QuoteStyle::Preserve => match value.original_quote {
1276                Some('\'') => format!("'{}'", value.raw),
1277                Some('"') => format!("\"{}\"", value.raw),
1278                Some(other) => format!("{other}{}{other}", value.raw),
1279                None => value.raw.clone(),
1280            },
1281            QuoteStyle::Double => format!("\"{}\"", value.raw.replace('"', "&quot;")),
1282            QuoteStyle::Single => format!("'{}'", value.raw.replace('\'', "&apos;")),
1283        }
1284    }
1285
1286    /// Render an attribute, aligning any continuation lines of a multi-line
1287    /// value to the column directly under the first value character (i.e.
1288    /// just after the opening quote).
1289    ///
1290    /// `prefix` is the whitespace string that will precede this attribute
1291    /// on its line (the wrapped-attribute indent, which may mix tabs and
1292    /// spaces). Continuation lines are emitted as `prefix` plus spaces
1293    /// spanning `name=` and the opening quote, so the visual alignment
1294    /// holds regardless of the caller's tab-width setting — mixing pure
1295    /// spaces with a tab-indented prefix would misalign at any tab width
1296    /// other than one.
1297    ///
1298    /// For values without embedded newlines this delegates to
1299    /// [`Self::render_attribute_value`]; for multi-line values it strips
1300    /// each continuation line's original leading whitespace and re-indents
1301    /// it, matching W3 SVG sample style where `<path d="M … " ` wrapping
1302    /// preserves logical path-command groupings under a stable column.
1303    fn render_attribute_aligned(&self, attribute: &ParsedAttribute, prefix: &str) -> String {
1304        let Some(value) = attribute.value.as_ref() else {
1305            return attribute.name.clone();
1306        };
1307        if !value.raw.contains('\n') {
1308            return format!("{}={}", attribute.name, self.render_attribute_value(value));
1309        }
1310
1311        let quote = match self.options.quote_style {
1312            QuoteStyle::Preserve => value.original_quote.unwrap_or('"'),
1313            QuoteStyle::Double => '"',
1314            QuoteStyle::Single => '\'',
1315        };
1316        let name_width = attribute.name.chars().count();
1317        let mut pad = String::with_capacity(prefix.len() + name_width + 2);
1318        pad.push_str(prefix);
1319        // `name=` + opening quote → name_width + 2 spaces of alignment.
1320        for _ in 0..name_width + 2 {
1321            pad.push(' ');
1322        }
1323
1324        let mut result = String::with_capacity(value.raw.len() + pad.len() * 2 + 8);
1325        let mut lines = value.raw.split('\n');
1326        if let Some(first) = lines.next() {
1327            result.push_str(&attribute.name);
1328            result.push('=');
1329            result.push(quote);
1330            result.push_str(first);
1331        }
1332        for line in lines {
1333            result.push('\n');
1334            result.push_str(&pad);
1335            result.push_str(line.trim_start());
1336        }
1337        result.push(quote);
1338        result
1339    }
1340
1341    fn wrapped_attribute_prefix(&self, depth: usize, tag_name: &str) -> String {
1342        match self.options.wrapped_attribute_indent {
1343            WrappedAttributeIndent::OneLevel => self.indent(depth + 1),
1344            WrappedAttributeIndent::AlignToTagName => {
1345                let mut prefix = self.indent(depth);
1346                // Align to the column right after `<tag ` in a hypothetical one-line form.
1347                prefix.push_str(&" ".repeat(tag_name.chars().count() + 2));
1348                prefix
1349            }
1350        }
1351    }
1352
1353    fn write_prefixed_line(&mut self, prefix: &str, text: &str) {
1354        self.out.push_str(prefix);
1355        self.out.push_str(text);
1356        self.out.push('\n');
1357    }
1358
1359    fn write_text_node(&mut self, node: Node<'_>, depth: usize) {
1360        let text = self.node_text(node).to_string();
1361        self.write_text_str(&text, depth);
1362    }
1363
1364    fn write_text_str(&mut self, text: &str, depth: usize) {
1365        if text.trim().is_empty() {
1366            return;
1367        }
1368
1369        match self.options.text_content {
1370            TextContentMode::Collapse => {
1371                for line in text.lines() {
1372                    let collapsed = collapse_whitespace(line);
1373                    if collapsed.is_empty() {
1374                        continue;
1375                    }
1376                    self.write_line(depth, &collapsed);
1377                }
1378            }
1379            TextContentMode::Maintain => {
1380                self.write_preserved_str(text, depth);
1381            }
1382            TextContentMode::Prettify => {
1383                for line in text.lines() {
1384                    let trimmed = line.trim();
1385                    if trimmed.is_empty() {
1386                        continue;
1387                    }
1388                    self.write_line(depth, trimmed);
1389                }
1390            }
1391        }
1392    }
1393
1394    fn write_preserved_block_text(&mut self, node: Node<'_>, depth: usize) {
1395        let text = self.node_text(node).to_string();
1396        self.write_preserved_str(&text, depth);
1397    }
1398
1399    fn write_preserved_embedded_text(&mut self, node: Node<'_>, depth: usize) {
1400        let text = self.node_text(node).to_string();
1401        self.write_embedded_preserved_str(&text, depth);
1402    }
1403
1404    fn write_preserved_str(&mut self, text: &str, depth: usize) {
1405        if text.trim().is_empty() {
1406            return;
1407        }
1408
1409        let lines: Vec<&str> = text.lines().collect();
1410        let first_non_empty = lines.iter().position(|line| !line.trim().is_empty());
1411        let last_non_empty = lines.iter().rposition(|line| !line.trim().is_empty());
1412        let (Some(start), Some(end)) = (first_non_empty, last_non_empty) else {
1413            return;
1414        };
1415
1416        let block = &lines[start..=end];
1417        let min_leading = block
1418            .iter()
1419            .filter(|line| !line.trim().is_empty())
1420            .map(|line| line.chars().take_while(|c| c.is_whitespace()).count())
1421            .min()
1422            .unwrap_or(0);
1423
1424        for line in block {
1425            let without_common_indent = line.chars().skip(min_leading).collect::<String>();
1426            self.write_line(depth, without_common_indent.trim_end());
1427        }
1428    }
1429
1430    fn write_embedded_preserved_str(&mut self, text: &str, depth: usize) {
1431        if text.trim().is_empty() {
1432            return;
1433        }
1434
1435        let lines: Vec<&str> = text.lines().collect();
1436        let first_non_empty = lines.iter().position(|line| !line.trim().is_empty());
1437        let last_non_empty = lines.iter().rposition(|line| !line.trim().is_empty());
1438        let (Some(start), Some(end)) = (first_non_empty, last_non_empty) else {
1439            return;
1440        };
1441
1442        let block = &lines[start..=end];
1443        let min_leading = block
1444            .iter()
1445            .filter(|line| !line.trim().is_empty())
1446            .map(|line| line.chars().take_while(|c| c.is_whitespace()).count())
1447            .min()
1448            .unwrap_or(0);
1449
1450        let mut consecutive_blank = 0usize;
1451        for line in block {
1452            let without_common_indent = line.chars().skip(min_leading).collect::<String>();
1453            let trimmed = without_common_indent.trim_end();
1454            if trimmed.is_empty() {
1455                consecutive_blank += 1;
1456                if self.should_emit_embedded_blank(consecutive_blank) {
1457                    self.out.push('\n');
1458                }
1459            } else {
1460                consecutive_blank = 0;
1461                self.write_line(depth, trimmed);
1462            }
1463        }
1464    }
1465
1466    /// Try to format embedded text (style/script `raw_text`) via the callback.
1467    /// Returns `true` if the callback produced a result.
1468    ///
1469    /// Handles CDATA-wrapped payloads specially: the `<![CDATA[`/`]]>` markers
1470    /// are stripped before the host formatter sees the content (the CSS/JS
1471    /// parsers reject them at column 0 as syntax errors — see W3 SVG path
1472    /// samples). When CDATA was present, XML entity decoding/encoding is
1473    /// skipped — inside a CDATA section, `&amp;` is literal, not escaped —
1474    /// and the wrapper is re-emitted on output to preserve the XML-safety
1475    /// semantics the author chose.
1476    fn try_format_embedded_text(
1477        &mut self,
1478        node: Node<'_>,
1479        language: EmbeddedLanguage,
1480        depth: usize,
1481        fmt: &mut dyn FnMut(EmbeddedContent<'_>) -> Option<String>,
1482    ) -> bool {
1483        let raw = self.node_text(node);
1484        let raw_start = node.start_byte();
1485        let (payload, cdata_wrapped, payload_offset) = strip_cdata_wrapper(raw).map_or_else(
1486            || {
1487                let (dedented, offset) = dedent_block_with_offset(raw);
1488                (decode_xml_entities(dedented), false, offset)
1489            },
1490            |(inner_offset, inner)| {
1491                // `inner_offset` is the byte position of `inner` within `raw`
1492                // (CDATA prefix + any leading whitespace stripped by trim),
1493                // both UTF-8 boundaries. Add the dedent offset within `inner`.
1494                let (dedented, offset) = dedent_block_with_offset(inner);
1495                (dedented, true, offset.map(|value| inner_offset + value))
1496            },
1497        );
1498        // `dedent_block_with_offset` yields `None` exactly when the block is
1499        // blank (no non-whitespace line), which also means `payload` is empty.
1500        // The `Some(offset)` gate below therefore subsumes an empty-payload
1501        // check — no separate `payload.is_empty()` guard is needed.
1502        let Some(payload_offset) = payload_offset else {
1503            return false;
1504        };
1505        let file_byte_offset = raw_start + payload_offset;
1506        let req = EmbeddedContent {
1507            language,
1508            content: &payload,
1509            indent_depth: depth,
1510            file_byte_offset,
1511        };
1512        fmt(req).is_some_and(|formatted| {
1513            if cdata_wrapped {
1514                self.write_cdata_block(&formatted, depth);
1515            } else {
1516                let encoded = encode_xml_entities(&formatted);
1517                self.write_indented_block(&encoded, depth);
1518            }
1519            true
1520        })
1521    }
1522
1523    /// Write a formatted host block wrapped in a CDATA section.
1524    ///
1525    /// The opening `<![CDATA[` sits at `depth`, payload lines are indented
1526    /// at `depth + 1` via [`Self::write_indented_block`], and the closing
1527    /// `]]>` returns to `depth`. Matches the nested-tag indentation scheme
1528    /// used by surrounding element children.
1529    fn write_cdata_block(&mut self, text: &str, depth: usize) {
1530        self.write_line(depth, "<![CDATA[");
1531        self.write_indented_block(text, depth + 1);
1532        self.write_line(depth, "]]>");
1533    }
1534
1535    /// Try to format `foreignObject` inner content via the callback.
1536    /// On success, writes the full element (start tag, formatted content, end tag).
1537    fn try_format_foreign_object(
1538        &mut self,
1539        children: &[Node<'_>],
1540        depth: usize,
1541        fmt: &mut dyn FnMut(EmbeddedContent<'_>) -> Option<String>,
1542    ) -> Option<()> {
1543        let start_tag = children.iter().find(|c| c.kind() == "start_tag")?;
1544        let end_tag = children.iter().find(|c| c.kind() == "end_tag")?;
1545
1546        let content_start = start_tag.end_byte();
1547        let content_end = end_tag.start_byte();
1548        if content_start >= content_end {
1549            return None;
1550        }
1551
1552        let raw = std::str::from_utf8(&self.source[content_start..content_end]).ok()?;
1553        let (content, offset) = dedent_block_with_offset(raw);
1554        // `dedent_block_with_offset` returns `None` exactly for blank blocks,
1555        // which is also the only case where `content` is empty — so the
1556        // `Some(offset)` gate alone rules out empty content.
1557        let offset = offset?;
1558        let file_byte_offset = content_start + offset;
1559
1560        let req = EmbeddedContent {
1561            language: EmbeddedLanguage::Html,
1562            content: &content,
1563            indent_depth: depth + 1,
1564            file_byte_offset,
1565        };
1566        let formatted = fmt(req)?;
1567
1568        // Write start tag, formatted content, end tag.
1569        self.write_tag_node(*start_tag, depth, false);
1570        self.write_indented_block(&formatted, depth + 1);
1571        let end_text = self.node_text(*end_tag).trim().to_string();
1572        self.write_line(depth, &end_text);
1573        Some(())
1574    }
1575
1576    /// Write pre-formatted text, re-indented to the given depth.
1577    /// Preserves the content's internal indentation structure.
1578    /// Leading/trailing blank lines are stripped; interior consecutive
1579    /// blank lines are normalized per the `blank_lines` option.
1580    fn write_indented_block(&mut self, text: &str, depth: usize) {
1581        let lines: Vec<&str> = text.lines().collect();
1582        let Some(first) = lines.iter().position(|l| !l.trim().is_empty()) else {
1583            return;
1584        };
1585        let last = lines
1586            .iter()
1587            .rposition(|l| !l.trim().is_empty())
1588            .unwrap_or(first);
1589        let block = &lines[first..=last];
1590
1591        let indent = self.indent(depth);
1592        let mut consecutive_blank = 0usize;
1593        for line in block {
1594            if line.trim().is_empty() {
1595                consecutive_blank += 1;
1596                if self.should_emit_embedded_blank(consecutive_blank) {
1597                    self.out.push('\n');
1598                }
1599            } else {
1600                consecutive_blank = 0;
1601                self.out.push_str(&indent);
1602                self.out.push_str(line);
1603                self.out.push('\n');
1604            }
1605        }
1606    }
1607
1608    /// Process ignore directives and whitespace-skip logic for a child node.
1609    ///
1610    /// Returns `true` if the child was fully handled (caller should `continue`).
1611    fn handle_ignore(
1612        &mut self,
1613        child: Node<'_>,
1614        in_ignore_range: &mut bool,
1615        ignore_next: &mut bool,
1616        prev_was_comment: &mut bool,
1617        prev_end: &mut Option<usize>,
1618    ) -> bool {
1619        let mut skip_ignore_self = false;
1620
1621        if child.kind() == "comment" {
1622            // Inside an ignore range, only look for the end marker.
1623            // All other directives are preserved verbatim.
1624            if *in_ignore_range {
1625                if self.is_ignore_directive(child, "ignore-end") {
1626                    self.write_source_span(*prev_end, child.end_byte());
1627                    *in_ignore_range = false;
1628                    *prev_was_comment = true;
1629                    *prev_end = Some(child.end_byte());
1630                    return true;
1631                }
1632                // Not an end marker — fall through to the in_ignore_range
1633                // raw-write below. Don't set ignore_next or skip_ignore_self.
1634            } else {
1635                if self.is_ignore_directive(child, "ignore-start") {
1636                    *in_ignore_range = true;
1637                    if let Some(end) = *prev_end {
1638                        self.emit_gap(end, child.start_byte(), *prev_was_comment);
1639                    }
1640                    self.write_source_span(Some(child.start_byte()), child.end_byte());
1641                    *prev_was_comment = true;
1642                    *prev_end = Some(child.end_byte());
1643                    return true;
1644                }
1645                if self.is_ignore_directive(child, "ignore") {
1646                    *ignore_next = true;
1647                    skip_ignore_self = true;
1648                }
1649            }
1650        }
1651
1652        // Skip whitespace-only text — but not inside an ignore range,
1653        // where we need to preserve everything verbatim.
1654        if !*in_ignore_range
1655            && matches!(child.kind(), "text" | "raw_text")
1656            && self.node_text(child).trim().is_empty()
1657        {
1658            return true;
1659        }
1660
1661        if !skip_ignore_self && *in_ignore_range {
1662            // Inside an ignore range: write from prev_end through this node,
1663            // preserving the original gap + content verbatim.
1664            self.write_source_span(*prev_end, child.end_byte());
1665            *prev_was_comment = child.kind() == "comment";
1666            *prev_end = Some(child.end_byte());
1667            return true;
1668        }
1669
1670        if !skip_ignore_self && *ignore_next {
1671            // Single-element ignore: write only the node bytes.
1672            // The gap before it was already emitted by the previous
1673            // write_line/emit_gap, so don't re-emit it.
1674            self.write_source_span(Some(child.start_byte()), child.end_byte());
1675            if !self.out.ends_with('\n') {
1676                self.out.push('\n');
1677            }
1678            *ignore_next = false;
1679            *prev_was_comment = child.kind() == "comment";
1680            *prev_end = Some(child.end_byte());
1681            return true;
1682        }
1683
1684        false
1685    }
1686
1687    /// Check if a comment node matches `<!-- {prefix}-{suffix} -->`.
1688    ///
1689    /// Uses the tree-sitter `text` field on the comment node to get
1690    /// the inner content without manual `<!--`/`-->` stripping.
1691    fn is_ignore_directive(&self, node: Node<'_>, suffix: &str) -> bool {
1692        let inner = node
1693            .child_by_field_name("text")
1694            .map_or("", |t| self.node_text(t).trim());
1695        self.options
1696            .ignore_prefixes
1697            .iter()
1698            .any(|prefix| inner == format!("{prefix}-{suffix}"))
1699    }
1700
1701    /// Write a source span verbatim, from `from` (or start of node if None)
1702    /// through `to`. Preserves original whitespace, gaps, and content exactly.
1703    fn write_source_span(&mut self, from: Option<usize>, to: usize) {
1704        let start = from.unwrap_or(to);
1705        if start < to {
1706            self.out
1707                .push_str(std::str::from_utf8(&self.source[start..to]).unwrap_or_default());
1708        }
1709    }
1710
1711    /// Count blank lines in the source gap between two byte positions.
1712    fn source_blank_lines(&self, from: usize, to: usize) -> usize {
1713        if from >= to {
1714            return 0;
1715        }
1716        let gap = std::str::from_utf8(&self.source[from..to]).unwrap_or_default();
1717        let newlines = gap.chars().filter(|&c| c == '\n').count();
1718        newlines.saturating_sub(1)
1719    }
1720
1721    /// Emit blank lines between siblings based on the `blank_lines` option.
1722    ///
1723    /// When `prev_was_comment` is true and mode is `Insert`, the gap is
1724    /// skipped — comments attach downward to the element they annotate.
1725    fn emit_gap(&mut self, prev_end: usize, next_start: usize, prev_was_comment: bool) {
1726        let source_gaps = self.source_blank_lines(prev_end, next_start);
1727        let count = match self.options.blank_lines {
1728            BlankLines::Remove => 0,
1729            BlankLines::Preserve => source_gaps,
1730            BlankLines::Truncate => source_gaps.min(1),
1731            BlankLines::Insert => usize::from(!prev_was_comment),
1732        };
1733        for _ in 0..count {
1734            self.out.push('\n');
1735        }
1736    }
1737
1738    /// Whether an embedded-content blank line should be emitted.
1739    ///
1740    /// `consecutive` is how many blank lines in a row have been seen so far
1741    /// (1 = first blank, 2 = second, etc.). `Truncate` collapses runs of 2+
1742    /// to 1, `Remove` strips all, and `Preserve`/`Insert` pass through
1743    /// (those modes only meaningfully apply to sibling element gaps).
1744    const fn should_emit_embedded_blank(&self, consecutive: usize) -> bool {
1745        match self.options.blank_lines {
1746            BlankLines::Remove => false,
1747            BlankLines::Truncate => consecutive <= 1,
1748            BlankLines::Preserve | BlankLines::Insert => true,
1749        }
1750    }
1751
1752    fn node_text(&self, node: Node<'_>) -> &str {
1753        std::str::from_utf8(&self.source[node.byte_range()]).unwrap_or_default()
1754    }
1755
1756    fn write_line(&mut self, depth: usize, text: &str) {
1757        self.out.push_str(&self.indent(depth));
1758        self.out.push_str(text);
1759        self.out.push('\n');
1760    }
1761
1762    fn indent(&self, depth: usize) -> String {
1763        if self.options.insert_spaces {
1764            " ".repeat(depth.saturating_mul(self.options.indent_width))
1765        } else {
1766            "\t".repeat(depth)
1767        }
1768    }
1769}
1770
1771fn text_content_entity_bounds<'a>(
1772    children: &[Node<'a>],
1773    tag_name: &str,
1774) -> Option<(Node<'a>, Node<'a>)> {
1775    if !is_text_content_element(tag_name)
1776        || !children
1777            .iter()
1778            .any(|child| child.kind() == "entity_reference")
1779    {
1780        return None;
1781    }
1782
1783    let start = children
1784        .iter()
1785        .find(|child| child.kind() == "start_tag")
1786        .copied()?;
1787    let end = children
1788        .iter()
1789        .find(|child| child.kind() == "end_tag")
1790        .copied()?;
1791    let all_inline = children
1792        .iter()
1793        .filter(|child| !matches!(child.kind(), "start_tag" | "end_tag"))
1794        .all(|child| matches!(child.kind(), "text" | "raw_text" | "entity_reference"));
1795
1796    all_inline.then_some((start, end))
1797}