kirei 25.10.27

Askama formatter
Documentation
use std::borrow::Cow;
use textwrap::{Options, wrap};

use crate::{
    config::Config,
    sakura_tree::{Branch, BranchStyle, Leaf, SakuraTree, Twig},
};

pub fn print(tree: &SakuraTree) -> String {
    let estimated_inked_tree_size = tree.leaves.iter().map(Leaf::chars_count).sum::<usize>()
        + tree.leaves.len() * (tree.config.indent_size * 4);
    let mut inked_tree = String::with_capacity(estimated_inked_tree_size);

    for branch in &tree.branches {
        match branch.style {
            BranchStyle::Inline => ink_inline(&mut inked_tree, tree, branch),
            BranchStyle::OpenClose => ink_open_close(&mut inked_tree, tree, branch),
            BranchStyle::SingleHtmlText => ink_wrapped_text(&mut inked_tree, tree, branch),
            BranchStyle::MultilineComment => ink_multiline_comment(&mut inked_tree, tree, branch),
            BranchStyle::Raw => ink_raw(&mut inked_tree, tree, branch),
        }
    }

    inked_tree
}

fn ink_inline(inked_tree: &mut String, tree: &SakuraTree, branch: &Branch) {
    let indent_str = indent_for(&tree.config, branch.indent);
    let mut line_content = String::new();

    for (i, leaf_idx) in branch.twig.indices().enumerate() {
        if let Some(leaf) = tree.leaves.get(leaf_idx) {
            let content = content_normalized_with_context(tree, leaf, branch.twig, i);

            if i > 0 && should_add_space_before_leaf(tree, leaf_idx, branch.twig, i) {
                line_content.push(' ');
            }
            line_content.push_str(&content);
        }
    }

    push_indented_line(inked_tree, &indent_str, &line_content);
}

fn ink_open_close(inked_tree: &mut String, tree: &SakuraTree, branch: &Branch) {
    let indent_str = indent_for(&tree.config, branch.indent);
    debug_assert!(branch.twig.has_same_idx());
    let leaf_idx = branch.twig.start();

    if let Some(leaf) = tree.leaves.get(leaf_idx) {
        let content = content_normalized(leaf);
        push_indented_line(inked_tree, &indent_str, &content);
    }
}

fn ink_wrapped_text(inked_tree: &mut String, tree: &SakuraTree, branch: &Branch) {
    debug_assert!(branch.twig.has_same_idx());
    let leaf_idx = branch.twig.start();

    if let Some(leaf) = tree.leaves.get(leaf_idx) {
        let content = content_normalized(leaf);
        let wrapped_content = wrap_inline_content(&tree.config, &content, branch.indent);
        inked_tree.push_str(&wrapped_content);
        inked_tree.push('\n');
    }
}

fn ink_multiline_comment(inked_tree: &mut String, tree: &SakuraTree, branch: &Branch) {
    debug_assert!(branch.twig.has_same_idx());
    let leaf_idx = branch.twig.start();

    if let Some(leaf) = tree.leaves.get(leaf_idx) {
        match leaf {
            Leaf::HtmlComment(_) => {
                ink_html_comment(inked_tree, &tree.config, branch, leaf.content());
            }
            _ => {
                ink_askama_comment(inked_tree, &tree.config, branch.indent, leaf);
            }
        }
    }
}

fn ink_askama_comment(inked_tree: &mut String, config: &Config, indent_level: i32, leaf: &Leaf) {
    let indent = indent_for(config, indent_level);

    for line in leaf.content().lines() {
        if !line.is_empty() {
            push_indented_line(inked_tree, &indent, line);
        } else {
            inked_tree.push('\n');
        }
    }
}

fn ink_html_comment(inked_tree: &mut String, config: &Config, branch: &Branch, content: &str) {
    const OPEN: &str = "<!--";
    const CLOSE: &str = "-->";

    let indent = indent_for(config, branch.indent);
    let inner = &content[OPEN.len()..content.len() - CLOSE.len()];
    let trimmed = inner.trim();

    push_indented_line(inked_tree, &indent, OPEN);

    if !trimmed.is_empty() {
        if trimmed.len() <= config.max_width {
            let content_indent = indent_for(config, branch.indent + 1);
            push_indented_line(inked_tree, &content_indent, trimmed);
        } else {
            let wrapped = wrap_inline_content(config, trimmed, branch.indent + 1);
            inked_tree.push_str(&wrapped);
            inked_tree.push('\n');
        }
    }

    push_indented_line(inked_tree, &indent, CLOSE);
}

fn ink_raw(inked_tree: &mut String, tree: &SakuraTree, branch: &Branch) {
    let mut curr_indent = branch.indent;

    for leaf_idx in branch.twig.indices() {
        process_raw_leaf(inked_tree, tree, leaf_idx, &mut curr_indent);
    }
}

fn process_raw_leaf(
    inked_tree: &mut String,
    tree: &SakuraTree,
    leaf_idx: usize,
    curr_indent: &mut i32,
) {
    if let Some(leaf) = tree.leaves.get(leaf_idx) {
        let content = leaf.content();

        if !content.is_empty() {
            for line in content.lines() {
                if line.is_empty() {
                    inked_tree.push('\n');
                } else {
                    let temp_indent_decrease = line.starts_with('}');

                    if temp_indent_decrease {
                        *curr_indent = curr_indent.saturating_sub(1);
                    }

                    let indent_str = indent_for(&tree.config, *curr_indent);
                    inked_tree.push_str(&indent_str);
                    inked_tree.push_str(line);
                    inked_tree.push('\n');

                    if line.ends_with('{') {
                        *curr_indent += 1;
                    }

                    let open_braces = line.matches('{').count();
                    let close_braces = line.matches('}').count();
                    let net_change = open_braces as i32 - close_braces as i32;

                    let net_change = if line.ends_with('{') && net_change > 0 {
                        net_change - 1
                    } else {
                        net_change
                    };

                    if net_change > 0 {
                        *curr_indent += net_change;
                    } else if net_change < 0 {
                        let decrease = (-net_change) as usize;
                        if !temp_indent_decrease || decrease > 1 {
                            let actual_decrease = if temp_indent_decrease {
                                decrease - 1
                            } else {
                                decrease
                            };
                            *curr_indent = curr_indent.saturating_sub(actual_decrease as i32);
                        }
                    }
                }
            }
        }
    }
}

fn content_normalized_with_context<'a>(
    tree: &SakuraTree,
    leaf: &'a Leaf,
    twig: Twig,
    position: usize,
) -> Cow<'a, str> {
    let prev_expr = is_askama_expr(tree, twig, position, -1);
    let next_expr = is_askama_expr(tree, twig, position, 1);
    normalize_leaf_content(leaf, prev_expr, next_expr)
}

fn content_normalized(leaf: &Leaf) -> Cow<'_, str> {
    normalize_leaf_content(leaf, false, false)
}

fn normalize_leaf_content(leaf: &Leaf, prev_expr: bool, next_expr: bool) -> Cow<'_, str> {
    let content = leaf.content();

    if !matches!(leaf, Leaf::HtmlText(_)) {
        return Cow::Borrowed(content);
    }

    let has_leading = content.starts_with(char::is_whitespace);
    let has_trailing = content.ends_with(char::is_whitespace);

    let leading = if has_leading && prev_expr { " " } else { "" };
    let trailing = if has_trailing && next_expr { " " } else { "" };
    let normalized = crate::normalize_whitespace(content);

    Cow::Owned(format!("{}{}{}", leading, normalized, trailing))
}

fn should_add_space_before_leaf(
    tree: &SakuraTree,
    curr_leaf_idx: usize,
    twig: Twig,
    position_in_branch: usize,
) -> bool {
    if position_in_branch == 0 {
        return false;
    }

    let current = tree.leaves.get(curr_leaf_idx).unwrap();
    let prev_idx = twig.indices().nth(position_in_branch - 1).unwrap();
    let prev = tree.leaves.get(prev_idx).unwrap();

    match (prev, current) {
        (Leaf::HtmlText(_), Leaf::HtmlEntity(_) | Leaf::HtmlStartTag { .. })
        | (Leaf::HtmlEntity(_), Leaf::HtmlText(_)) => true,
        (Leaf::AskamaControl { tag, .. }, _) => tag.is_match_arm(),
        (Leaf::HtmlEndTag { .. }, _) => {
            current.is_expr() || current.content().starts_with(char::is_alphabetic)
        }
        _ => false,
    }
}

fn wrap_inline_content(config: &Config, content: &str, indent: i32) -> String {
    let prefix = indent_for(config, indent);
    let available_width = config.max_width;

    let options = Options::new(available_width)
        .initial_indent(&prefix)
        .subsequent_indent(&prefix);

    wrap(content, &options).join("\n")
}

fn indent_for(config: &Config, indent: i32) -> String {
    " ".repeat(indent as usize * config.indent_size)
}

fn push_indented_line(inked_tree: &mut String, indent_str: &str, content: &str) {
    inked_tree.push_str(indent_str);
    inked_tree.push_str(content);
    inked_tree.push('\n');
}

fn get_leaf_at_position(tree: &SakuraTree, twig: Twig, position: usize) -> Option<&Leaf> {
    twig.indices()
        .nth(position)
        .and_then(|idx| tree.leaves.get(idx))
}

fn is_askama_expr(tree: &SakuraTree, twig: Twig, position: usize, offset: isize) -> bool {
    let target_pos = position.checked_add_signed(offset);
    target_pos
        .and_then(|pos| get_leaf_at_position(tree, twig, pos))
        .is_some_and(Leaf::is_expr)
}