tree-sitter-htmlx 0.1.13

Tree-sitter grammar for HTMLX (expression-enhanced HTML)
Documentation
//! Tests for HTMLX attributes

mod utils;
use utils::parse;

// =============================================================================
// Standard attributes
// =============================================================================

#[test]
fn test_attribute_standard() {
    assert_eq!(
        parse(r#"<div class="foo"></div>"#),
        "(document (element (start_tag name: (tag_name) (attribute name: (attribute_name) value: (quoted_attribute_value (attribute_value)))) (end_tag name: (tag_name))))"
    );
}

#[test]
fn test_attribute_css_custom_property_name() {
    assert_eq!(
        parse(r#"<div --rail-color="rgb(0, 0, 0)"></div>"#),
        r#"(document (element (start_tag name: (tag_name) (attribute name: (attribute_name) value: (quoted_attribute_value (attribute_value)))) (end_tag name: (tag_name))))"#
    );
}

#[test]
fn test_multiline_attribute_css_custom_property_name() {
    assert_eq!(
        parse("<div\n\tid=\"x\"\n\t--rail-color=\"rgb(0, 0, 0)\"\n/>"),
        r#"(document (element (self_closing_tag name: (tag_name) (attribute name: (attribute_name) value: (quoted_attribute_value (attribute_value))) (attribute name: (attribute_name) value: (quoted_attribute_value (attribute_value))))))"#
    );
}

#[test]
fn test_multiline_attributes_after_blank_line_whitespace_matrix() {
    let cases = [
        ("lf_spaces", "\n      \n"),
        ("lf_tabs", "\n\t\t\n"),
        ("lf_mixed", "\n \t  \t\n"),
        ("lf_multiple_blank_lines", "\n      \n\t\t\n   \n"),
        ("crlf_spaces", "\r\n      \r\n"),
        ("crlf_tabs", "\r\n\t\t\r\n"),
        ("cr_only_spaces", "\r      \r"),
        ("space_before_lf_spaces", "    \n      \n"),
        ("form_feed", "\n\x0c\n"),
        ("vertical_tab", "\n\x0b\n"),
        ("nbsp_around_lf", "\u{00A0}\n\u{00A0}\n"),
        ("unicode_line_separator_tabs", "\u{2028}\t\t\u{2028}"),
        ("unicode_paragraph_separator_tabs", "\u{2029}\t\t\u{2029}"),
        ("unicode_next_line_tabs", "\u{0085}\t\t\u{0085}"),
        ("zero_width_space_around_lf", "\u{200B}\n\u{200B}\n"),
        ("bom_around_lf", "\u{FEFF}\n\u{FEFF}\n"),
    ];

    let expected = r#"(document (element (start_tag name: (tag_name) (attribute name: (attribute_name) value: (quoted_attribute_value (attribute_value))) (attribute name: (attribute_name) value: (quoted_attribute_value (attribute_value)))) (end_tag name: (tag_name))))"#;

    for (name, gap) in cases {
        let source =
            format!("<button{gap}        type=\"button\"\n        class=\"x\"\n      ></button>");
        assert_eq!(parse(&source), expected, "{name}");
    }
}

#[test]
fn test_attribute_expression_value() {
    assert_eq!(
        parse("<input value={text} />"),
        "(document (element (self_closing_tag name: (tag_name) (attribute name: (attribute_name) value: (expression content: (js))))))"
    );
}

#[test]
fn test_tag_line_comment_between_attributes() {
    assert_eq!(
        parse("<div // comment\n class=\"x\" />"),
        "(document (element (self_closing_tag name: (tag_name) (tag_comment kind: (line_comment)) (attribute name: (attribute_name) value: (quoted_attribute_value (attribute_value))))))"
    );
}

#[test]
fn test_tag_block_comment_between_attributes() {
    assert_eq!(
        parse("<span /* inline */ data-one=\"1\"></span>"),
        "(document (element (start_tag name: (tag_name) (tag_comment kind: (block_comment)) (attribute name: (attribute_name) value: (quoted_attribute_value (attribute_value)))) (end_tag name: (tag_name))))"
    );
}

#[test]
fn test_tag_comment_in_namespaced_tag() {
    assert_eq!(
        parse("<svelte:head // note\n data-x=\"1\"></svelte:head>"),
        "(document (element (start_tag name: (tag_name namespace: (tag_namespace) name: (tag_local_name)) (tag_comment kind: (line_comment)) (attribute name: (attribute_name) value: (quoted_attribute_value (attribute_value)))) (end_tag name: (tag_name namespace: (tag_namespace) name: (tag_local_name)))))"
    );
}

#[test]
fn test_tag_comment_in_member_tag() {
    assert_eq!(
        parse("<UI.Button /* note */ data-x=\"1\" />"),
        "(document (element (self_closing_tag name: (tag_name object: (tag_member) property: (tag_member)) (tag_comment kind: (block_comment)) (attribute name: (attribute_name) value: (quoted_attribute_value (attribute_value))))))"
    );
}

#[test]
fn test_tag_line_comment_stops_before_tag_close() {
    assert_eq!(
        parse("<div // note></div>"),
        "(document (element (start_tag name: (tag_name) (tag_comment kind: (line_comment))) (end_tag name: (tag_name))))"
    );
}

// =============================================================================
// Shorthand attributes
// =============================================================================

#[test]
fn test_shorthand_attribute() {
    // shorthand_attribute now uses expression structure with content field
    assert_eq!(
        parse("<div {hidden}></div>"),
        "(document (element (start_tag name: (tag_name) (attribute (shorthand_attribute content: (js)))) (end_tag name: (tag_name))))"
    );
}

#[test]
fn test_shorthand_attribute_multiple() {
    assert_eq!(
        parse("<div {id} {hidden}></div>"),
        "(document (element (start_tag name: (tag_name) (attribute (shorthand_attribute content: (js))) (attribute (shorthand_attribute content: (js)))) (end_tag name: (tag_name))))"
    );
}

#[test]
fn test_multiline_shorthand_attribute() {
    assert_eq!(
        parse("<div\n {hidden}\n></div>"),
        "(document (element (start_tag name: (tag_name) (attribute (shorthand_attribute content: (js)))) (end_tag name: (tag_name))))"
    );
}

// =============================================================================
// Spread attributes
// =============================================================================

#[test]
fn test_spread_attribute() {
    // Spread attributes parse as shorthand_attribute — '...props' is JS content
    assert_eq!(
        parse("<Component {...props} />"),
        "(document (element (self_closing_tag name: (tag_name) (attribute (shorthand_attribute content: (js))))))"
    );
}

#[test]
fn test_spread_attribute_with_others() {
    assert_eq!(
        parse(r#"<Component id="main" {...props} />"#),
        "(document (element (self_closing_tag name: (tag_name) (attribute name: (attribute_name) value: (quoted_attribute_value (attribute_value))) (attribute (shorthand_attribute content: (js))))))"
    );
}

#[test]
fn test_spread_attribute_nested_braces() {
    assert_eq!(
        parse("<input {...({})} onfocus={() => console.log('x')} />"),
        "(document (element (self_closing_tag name: (tag_name) (attribute (shorthand_attribute content: (js))) (attribute name: (attribute_name) value: (expression content: (js))))))"
    );
}

#[test]
fn test_spread_attribute_complex_expression() {
    // Spread with computed expression
    assert_eq!(
        parse("<div {...{a: 1, b: 2}} />"),
        "(document (element (self_closing_tag name: (tag_name) (attribute (shorthand_attribute content: (js))))))"
    );
}

#[test]
fn test_spread_and_shorthand_together() {
    // Mix of spread and shorthand attributes
    assert_eq!(
        parse("<input {...rest} {value} />"),
        "(document (element (self_closing_tag name: (tag_name) (attribute (shorthand_attribute content: (js))) (attribute (shorthand_attribute content: (js))))))"
    );
}

// =============================================================================
// Directive attributes (nested inside attribute_name)
// =============================================================================

#[test]
fn test_directive_bind() {
    assert_eq!(
        parse("<input bind:value={name} />"),
        "(document (element (self_closing_tag name: (tag_name) (attribute name: (attribute_name (attribute_directive) (attribute_identifier)) value: (expression content: (js))))))"
    );
}

#[test]
fn test_directive_on() {
    assert_eq!(
        parse("<button on:click={handleClick}></button>"),
        "(document (element (start_tag name: (tag_name) (attribute name: (attribute_name (attribute_directive) (attribute_identifier)) value: (expression content: (js)))) (end_tag name: (tag_name))))"
    );
}

#[test]
fn test_directive_with_modifiers() {
    assert_eq!(
        parse("<button on:click|preventDefault|stopPropagation={handler}></button>"),
        "(document (element (start_tag name: (tag_name) (attribute name: (attribute_name (attribute_directive) (attribute_identifier) (attribute_modifiers (attribute_modifier) (attribute_modifier))) value: (expression content: (js)))) (end_tag name: (tag_name))))"
    );
}

#[test]
fn test_directive_class() {
    assert_eq!(
        parse("<div class:active={isActive}></div>"),
        "(document (element (start_tag name: (tag_name) (attribute name: (attribute_name (attribute_directive) (attribute_identifier)) value: (expression content: (js)))) (end_tag name: (tag_name))))"
    );
}

#[test]
fn test_directive_class_with_colon_in_name() {
    assert_eq!(
        parse("<div class:foo:bar={enabled}></div>"),
        "(document (element (start_tag name: (tag_name) (attribute name: (attribute_name (attribute_directive) (attribute_identifier)) value: (expression content: (js)))) (end_tag name: (tag_name))))"
    );
}

#[test]
fn test_directive_style() {
    assert_eq!(
        parse("<div style:color={textColor}></div>"),
        "(document (element (start_tag name: (tag_name) (attribute name: (attribute_name (attribute_directive) (attribute_identifier)) value: (expression content: (js)))) (end_tag name: (tag_name))))"
    );
}

#[test]
fn test_directive_style_custom_property() {
    assert_eq!(
        parse("<div style:--color={textColor}></div>"),
        "(document (element (start_tag name: (tag_name) (attribute name: (attribute_name (attribute_directive) (attribute_identifier)) value: (expression content: (js)))) (end_tag name: (tag_name))))"
    );
}

#[test]
fn test_directive_style_unquoted_mixed_value() {
    // Regression test: style:attr=string{mixed} should parse as a single attribute
    // with an unquoted_attribute_value containing text and expression,
    // NOT as two separate attributes (style:attr=string and {mixed} shorthand)
    assert_eq!(
        parse("<div style:attr=string{mixed}></div>"),
        "(document (element (start_tag name: (tag_name) (attribute name: (attribute_name (attribute_directive) (attribute_identifier)) value: (unquoted_attribute_value (attribute_value) (expression content: (js))))) (end_tag name: (tag_name))))"
    );
}

#[test]
fn test_directive_use() {
    assert_eq!(
        parse("<div use:tooltip></div>"),
        "(document (element (start_tag name: (tag_name) (attribute name: (attribute_name (attribute_directive) (attribute_identifier)))) (end_tag name: (tag_name))))"
    );
}

#[test]
fn test_directive_use_store_member_action() {
    assert_eq!(
        parse("<div use:$store.action={text}></div>"),
        "(document (element (start_tag name: (tag_name) (attribute name: (attribute_name (attribute_directive) (attribute_identifier)) value: (expression content: (js)))) (end_tag name: (tag_name))))"
    );
}

#[test]
fn test_directive_transition() {
    assert_eq!(
        parse("<div transition:fade></div>"),
        "(document (element (start_tag name: (tag_name) (attribute name: (attribute_name (attribute_directive) (attribute_identifier)))) (end_tag name: (tag_name))))"
    );
}