kirei 25.12.29

Askama formatter
Documentation
use tree_sitter::{Point, Range};

use crate::{
    askama::{AskamaNode, ControlTag},
    diagnostics::{Annotation, Diagnostic},
    html::HtmlNode,
    noted::Noted,
};

pub fn element_across_control(
    html_nodes: &[HtmlNode],
    askama_nodes: &[AskamaNode],
    source: &str,
) -> Noted<Vec<(usize, usize)>> {
    let mut diagnostics = Vec::new();
    let mut crossing_indices = Vec::new();

    for i in 0..html_nodes.len() {
        let Some((start_byte, end_byte, end_idx, name)) = (match &html_nodes[i] {
            HtmlNode::Start {
                range,
                end: Some(end_idx),
                name,
                ..
            } => html_nodes.get(*end_idx).and_then(|n| match n {
                HtmlNode::End { start, .. } => Some((range.start, *start, *end_idx, name.clone())),
                _ => None,
            }),
            _ => None,
        }) else {
            continue;
        };

        if !has_crossing_boundary(start_byte, end_byte, askama_nodes) {
            continue;
        }

        let close_tag_end = end_byte + 2 + name.len() + 1;
        let span_range = range_from_bytes(source, start_byte, close_tag_end);

        diagnostics.push(
            Diagnostic::warning("unbalanced HTML across control blocks").with_label(
                span_range,
                "",
                Annotation::Primary,
            ),
        );

        crossing_indices.push((i, end_idx));
    }

    Noted::with_diagnostics(crossing_indices, diagnostics)
}

fn has_crossing_boundary(start: usize, end: usize, askama_nodes: &[AskamaNode]) -> bool {
    if start == end {
        return false;
    }

    let (min_pos, max_pos) = (start.min(end), start.max(end));

    for node in askama_nodes {
        if let AskamaNode::Control {
            range,
            end: close_pos,
            tag,
            ..
        } = node
        {
            if matches!(
                tag,
                ControlTag::Else | ControlTag::ElseIf | ControlTag::When | ControlTag::MatchElse
            ) && range.start > min_pos
                && range.start < max_pos
                && let Some((parent_start, parent_end)) = find_closest_parent(range, askama_nodes)
            {
                let min_inside = min_pos >= parent_start && min_pos < parent_end;
                let max_inside = max_pos >= parent_start && max_pos < parent_end;

                if min_inside && max_inside {
                    return true;
                }
            }

            if let Some(close) = close_pos {
                let start_inside = min_pos >= range.end && min_pos < *close;
                let end_inside = max_pos >= range.end && max_pos < *close;

                if start_inside != end_inside {
                    return true;
                }
            }
        }
    }

    false
}

fn find_closest_parent(
    range: &std::ops::Range<usize>,
    askama_nodes: &[AskamaNode],
) -> Option<(usize, usize)> {
    let mut closest_parent = None;
    let mut smallest_size = usize::MAX;

    for parent in askama_nodes {
        if let AskamaNode::Control {
            range: parent_range,
            end: Some(parent_end),
            tag: parent_tag,
            ..
        } = parent
            && parent_tag.is_opening()
            && parent_range.end <= range.start
            && *parent_end >= range.end
        {
            let block_size = *parent_end - parent_range.end;
            if block_size < smallest_size {
                closest_parent = Some((parent_range.end, *parent_end));
                smallest_size = block_size;
            }
        }
    }

    closest_parent
}

fn range_from_bytes(source: &str, start: usize, end: usize) -> Range {
    Range {
        start_byte: start,
        end_byte: end,
        start_point: byte_to_point(source, start),
        end_point: byte_to_point(source, end),
    }
}

fn byte_to_point(source: &str, byte_pos: usize) -> Point {
    let (mut row, mut col) = (0, 0);
    for (i, c) in source.char_indices() {
        if i >= byte_pos {
            break;
        }
        if c == '\n' {
            row += 1;
            col = 0;
        } else {
            col += 1;
        }
    }
    Point { row, column: col }
}