Skip to content

Parser: Introduce HTMLConditionalOpenTagNode#1153

Merged
marcoroth merged 4 commits intomainfrom
html-conditional-open-tag-node
Feb 13, 2026
Merged

Parser: Introduce HTMLConditionalOpenTagNode#1153
marcoroth merged 4 commits intomainfrom
html-conditional-open-tag-node

Conversation

@marcoroth
Copy link
Owner

@marcoroth marcoroth commented Feb 13, 2026

This pull request adds a new HTMLConditionalOpenTagNode AST node type for representing ERB conditionals that only vary the open tag (typically attributes) while sharing the same tag name, body content, and close tag.

<% if @condition %>
  <div class="a">
<% else %>
  <div class="b">
<% end %>
  Content
</div>

Previously, this pattern was incorrectly parsed as mismatched tags. Now, Herb detects this pattern and creates a proper HTMLElementNode with an HTMLConditionalOpenTagNode as its open_tag, allowing the formatter, linter, and other tools to understand the structure correctly:

@ DocumentNode (location: (1:0)-(8:0))
└── children: (2 items)
    ├── @ HTMLElementNode (location: (1:0)-(7:6))
       ├── open_tag:
          └── @ HTMLConditionalOpenTagNode (location: (1:0)-(5:9))
              ├── conditional:
                 └── @ ERBIfNode (location: (1:0)-(5:9))
                     ├── tag_opening: "<%" (location: (1:0)-(1:2))
                     ├── content: " if some_condition " (location: (1:2)-(1:21))
                     ├── tag_closing: "%>" (location: (1:21)-(1:23))
                     ├── then_keyword: 
                     ├── statements: (3 items)
                        ├── @ HTMLTextNode (location: (1:23)-(2:2))
                           └── content: "\n  "
                        
                        ├── @ HTMLOpenTagNode (location: (2:2)-(2:17))
                           ├── tag_opening: "<" (location: (2:2)-(2:3))
                           ├── tag_name: "div" (location: (2:3)-(2:6))
                           ├── tag_closing: ">" (location: (2:16)-(2:17))
                           ├── children: (1 item) [...]
                           └── is_void: false
                        
                        └── @ HTMLTextNode (location: (2:17)-(3:0))
                            └── content: "\n"
                     
                     ├── subsequent:
                        └── @ ERBElseNode (location: (3:0)-(5:0))
                            ├── tag_opening: "<%" (location: (3:0)-(3:2))
                            ├── content: " else " (location: (3:2)-(3:8))
                            ├── tag_closing: "%>" (location: (3:8)-(3:10))
                            └── statements: (3 items)
                                ├── @ HTMLTextNode (location: (3:10)-(4:2))
                                   └── content: "\n  "
                                
                                ├── @ HTMLOpenTagNode (location: (4:2)-(4:17))
                                   ├── tag_opening: "<" (location: (4:2)-(4:3))
                                   ├── tag_name: "div" (location: (4:3)-(4:6))
                                   ├── tag_closing: ">" (location: (4:16)-(4:17))
                                   ├── children: (1 item) [...]
                                   └── is_void: false
                                
                                └── @ HTMLTextNode (location: (4:17)-(5:0))
                                    └── content: "\n"
                     
                     └── end_node:
                         └── @ ERBEndNode (location: (5:0)-(5:9))
                             ├── tag_opening: "<%" (location: (5:0)-(5:2))
                             ├── content: " end " (location: (5:2)-(5:7))
                             └── tag_closing: "%>" (location: (5:7)-(5:9))
              
              ├── tag_name: "div" (location: (2:3)-(2:6))
              └── is_void: false
       
       ├── tag_name: "div" (location: (2:3)-(2:6))
       ├── body: (1 item)
          └── @ HTMLTextNode (location: (5:9)-(7:0))
              └── content: "\n  Content\n"
       
       ├── close_tag:
          └── @ HTMLCloseTagNode (location: (7:0)-(7:6))
              ├── tag_opening: "</" (location: (7:0)-(7:2))
              ├── tag_name: "div" (location: (7:2)-(7:5))
              ├── children: []
              └── tag_closing: ">" (location: (7:5)-(7:6))
       
       ├── is_void: false
       └── source: "HTML"
    
    └── @ HTMLTextNode (location: (7:6)-(8:0))
        └── content: "\n"

Follow up on #1146

Resolves #83
Resolves #398
Resolves #490
Resolves #779

@github-actions github-actions bot added the node label Feb 13, 2026
@pkg-pr-new
Copy link

pkg-pr-new bot commented Feb 13, 2026

npx https://pkg.pr.new/@herb-tools/formatter@1153
npx https://pkg.pr.new/@herb-tools/language-server@1153
npx https://pkg.pr.new/@herb-tools/linter@1153

commit: 46a0590

@github-actions
Copy link

github-actions bot commented Feb 13, 2026

🌿 Interactive Playground and Documentation Preview

A preview deployment has been built for this pull request. Try out the changes live in the interactive playground:


🌱 Grown from commit 46a0590


✅ Preview deployment has been cleaned up.

@marcoroth marcoroth added this to the v1.0.0 milestone Feb 13, 2026
@marcoroth marcoroth merged commit 19d7e79 into main Feb 13, 2026
32 checks passed
@marcoroth marcoroth deleted the html-conditional-open-tag-node branch February 13, 2026 11:55
marcoroth added a commit that referenced this pull request Feb 13, 2026
This pull request updates the type system to support discriminated
unions for AST node fields instead of using the generic `Node` type as
the common ancestor.

The `config.yml` schema definitions have been updated to specify exact
union types for fields like:
* `HTMLElementNode.open_tag` (now `HTMLOpenTagNode |
HTMLConditionalOpenTagNode`) (see #1153)
* `ERBIfNode.subsequent` (now `ERBIfNode | ERBElseNode`)
* `HTMLAttributeValueNode.children` (now `LiteralNode | ERBContentNode`)
* `HTMLCloseTagNode.children` (now `WhitespaceNode`)

The code generation templates have been updated across all language
bindings. TypeScript now generates proper union types and the `is*Node`
type guard functions have been updated to accept `null | undefined` for
easier chaining. Ruby generates union type signatures in RBS files. Rust
adds a new `union_types.rs` file with enum types and conversion
functions for each union.

With these type improvements the linter, formatter, and language server
code has been simplified to use `is*Node` type guards instead of manual
`.type === "AST_*"` string checks. This removes numerous `as any` and
`as *Node` casts that were previously needed, making the code more
type-safe and readable.
marcoroth added a commit that referenced this pull request Feb 24, 2026
…1240)

This pull request updates the conditional element analysis passes to no
longer produce false `ConditionalElementMultipleTagsError` errors on
valid templates containing complete HTML elements inside ERB control
flow branches.

The following template now parses without any errors:
```html+erb
<% if true %>
  <% if true %>
    <div>
      <div></div>
      <% if true %><% end %>
    </div>
  <% else %>
    <div></div>
  <% end %>
<% end %>
```

Related #1153 
Related #1146
Related #1208

Resolves #1239
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment