hsml 0.7.2

A pug-inspired HTML preprocessor
Documentation
use nom::bytes::complete::{take_till, take_till1};
use serde::Serialize;

use crate::common::{Location, Position};
use crate::parser::{
    HsmlNode, HsmlProcessContext, HsmlResult, Span, attribute,
    class::node::{ClassNode, class_node},
    comment::node::{comment_dev_node, comment_native_node},
    id::{self, node::IdNode},
    tag::process::process_tag,
    text::{self, node::TextNode},
};

#[derive(Debug, Serialize)]
pub struct TagNode {
    pub tag: String,
    /// Source location of the tag name.
    pub location: Location,
    /// All id selectors on this tag. Only the first is used in compilation;
    /// duplicates are reported as warnings by the validator.
    pub ids: Vec<IdNode>,
    pub classes: Option<Vec<ClassNode>>,
    pub attributes: Option<Vec<HsmlNode>>,
    pub text: Option<TextNode>,
    pub children: Option<Vec<HsmlNode>>,
}

impl TagNode {
    /// Create a TagNode without a meaningful source location.
    /// Useful in tests where location is not relevant.
    #[doc(hidden)]
    pub fn without_location(
        tag: impl Into<String>,
        ids: Vec<IdNode>,
        classes: Option<Vec<ClassNode>>,
        attributes: Option<Vec<HsmlNode>>,
        text: Option<TextNode>,
        children: Option<Vec<HsmlNode>>,
    ) -> Self {
        let zero = Position { line: 0, column: 0 };
        Self {
            tag: tag.into(),
            location: Location {
                start: zero,
                end: zero,
            },
            ids,
            classes,
            attributes,
            text,
            children,
        }
    }
}

// PartialEq excludes location so that tests comparing parsed ASTs
// don't need to specify exact location values for every tag.
impl PartialEq for TagNode {
    fn eq(&self, other: &Self) -> bool {
        self.tag == other.tag
            && self.ids == other.ids
            && self.classes == other.classes
            && self.attributes == other.attributes
            && self.text == other.text
            && self.children == other.children
    }
}

pub fn tag_node<'a>(input: Span<'a>, context: &mut HsmlProcessContext) -> HsmlResult<'a, TagNode> {
    // tag node starts with a tag name or a dot/hash
    // if it starts with a dot/hash, the tag name is div

    let tag_start_span = input;

    let (mut input, tag_name) = if input.starts_with('.') || input.starts_with('#') {
        // Implicit div — location is the dot/hash position (zero-width)
        (input, "div")
    } else {
        let (rest, name) = process_tag(input)?;
        (rest, *name.fragment())
    };

    let tag_location = Location::from_spans(&tag_start_span, &input);

    // if the next char is a dot, we have a id node
    // if the next char is a dot, we have a class node
    // collect id and class nodes until we hit a whitespace, newline, start of attributes or single dot without trailing alphabetical char

    let mut id_nodes: Vec<IdNode> = vec![];
    let mut class_nodes: Vec<ClassNode> = vec![];
    let mut attribute_nodes: Option<Vec<HsmlNode>> = None;
    let mut text_node: Option<TextNode> = None;
    let mut child_nodes: Vec<HsmlNode> = vec![];

    loop {
        let first_char = input.fragment().get(..1);
        let first_two_chars = input.fragment().get(..2);

        if first_char == Some("#") {
            // Collect all id nodes — duplicates are detected post-parse by the validator.
            let (rest, node) = id::node::id_node(input)?;
            id_nodes.push(node);
            input = rest;

            continue;
        }

        if first_char == Some(".") {
            if first_two_chars == Some(".\n") {
                // we hit piped text
                let (rest, node) = text::node::text_block_node(input, context)?;
                text_node = Some(node);
                input = rest;

                break;
            }

            // we hit a class node
            let (rest, node) = class_node(input)?;
            class_nodes.push(node);
            input = rest;

            continue;
        }

        if first_char == Some("(") {
            // we hit the start of attributes

            let (rest, nodes) = attribute::node::attribute_nodes(input, context)?;
            attribute_nodes = Some(nodes);
            input = rest;

            continue;
        }

        if first_char == Some(" ") {
            // we hit a whitespace and there should be text

            let (rest, node) = text::node::text_node(input)?;
            text_node = Some(node);
            input = rest;

            // Inline comments after text are intentionally not supported because
            // text content can contain sequences like "//" (e.g. URLs: https://example.com).

            break;
        }

        if first_char == Some("\n") || first_two_chars == Some("\r\n") {
            // we hit a newline and the tag ended but could have child tag nodes

            // consume the newline
            let (mut rest, _) = take_till1(|c| c != '\r' && c != '\n')(input)?;

            // skip whitespace-only lines (blank lines between tags)
            loop {
                let (after_ws, ws) =
                    take_till(|c: char| c == '\n' || c == '\r' || !c.is_whitespace())(rest)?;

                // EOF after whitespace — nothing more to parse
                if !ws.fragment().is_empty() && after_ws.fragment().is_empty() {
                    break;
                }

                // If we consumed only whitespace and hit a newline, this is a blank line — skip it
                if !ws.fragment().is_empty()
                    && (after_ws.starts_with('\n') || after_ws.starts_with("\r\n"))
                {
                    let (after_nl, _) = take_till1(|c| c != '\r' && c != '\n')(after_ws)?;
                    rest = after_nl;
                    continue;
                }
                break;
            }

            // If we've reached EOF (possibly with trailing whitespace), stop
            if rest.fragment().trim().is_empty() {
                break;
            }

            // check if the next char is a tab or whitespace
            // if yes, check for indentation level
            // if no, we have no child tag nodes and can break the loop

            let (remaining, indentation) = take_till(|c: char| !c.is_whitespace())(rest)?;

            let indentation_str = *indentation.fragment();

            if !indentation_str.is_empty() {
                // Mixed tabs and spaces are detected post-parse by the validator (W003).

                // persist the indentation level so we can restore it later
                let nested_tag_level = context.nested_tag_level;
                let indent_string = context.indent_string.clone();

                // check that we are at the correct indentation level, otherwise break out of the loop
                if !indentation_str.starts_with(&context.indent_string)
                    || indentation_str.len() <= context.indent_string.len()
                {
                    // dbg!("break out of loop");
                    break;
                }

                context.nested_tag_level += 1;
                context.indent_string = indentation_str.to_string();

                // we are at the correct indentation level, so we can continue parsing the child tag nodes

                // there could be a comment (dev or native) node
                if let Ok((rest, node)) = comment_native_node(remaining) {
                    child_nodes.push(HsmlNode::Comment(node));
                    input = rest;
                } else if let Ok((rest, node)) = comment_dev_node(remaining) {
                    child_nodes.push(HsmlNode::Comment(node));
                    input = rest;
                }
                // or we have now a child tag node
                else {
                    match tag_node(remaining, context) {
                        Ok((rest, node)) => {
                            child_nodes.push(HsmlNode::Tag(node));
                            input = rest;
                        }
                        Err(err) => {
                            context.nested_tag_level = nested_tag_level;
                            context.indent_string = indent_string;
                            return Err(err);
                        }
                    }
                }

                // restore the nested_tag_level level
                context.nested_tag_level = nested_tag_level;
                context.indent_string = indent_string;

                continue;
            }

            // we have no child tag nodes
            break;
        }

        break;
    }

    Ok((
        input,
        TagNode {
            tag: tag_name.to_string(),
            location: tag_location,
            ids: id_nodes,
            classes: (!class_nodes.is_empty()).then_some(class_nodes),
            attributes: attribute_nodes,
            text: text_node,
            children: (!child_nodes.is_empty()).then_some(child_nodes),
        },
    ))
}