Skip to content

Parser: Analyze and transform Action View helpers#1347

Merged
marcoroth merged 1 commit intomainfrom
actionview-helpers-transform
Mar 10, 2026
Merged

Parser: Analyze and transform Action View helpers#1347
marcoroth merged 1 commit intomainfrom
actionview-helpers-transform

Conversation

@marcoroth
Copy link
Owner

@marcoroth marcoroth commented Mar 10, 2026

This pull request adds the foundation for parsing and analyzing Action View tag helpers. To start with, only a few helpers are supported, tag.*, content_tag, and link_to. But more can be added by extending the handler registry.

A new action_view_helpers parser option is available to enable this analysis (defaults to false). When enabled, the parser detects supported helper calls and transforms them into synthetic HTMLElementNode AST representations. HTML attributes are extracted from Ruby keyword arguments, including data/aria nested hashes, attribute splats, interpolated strings, and method/remote to data-* conversions.

This also introduces several new AST node types: ERBOpenTagNode, HTMLVirtualCloseTagNode, RubyHTMLAttributesSplatNode, and RubyLiteralNode.

For example, the following template:

<%= tag.div class: "wrapper", data: { controller: "hello" } do %>
  Hello
<% end %>

Gets parsed and transformed to:

@ DocumentNode (location: (1:0)-(3:9))
└── children: (1 item)
    └── @ HTMLElementNode (location: (1:0)-(3:9))
        ├── open_tag: 
           └── @ ERBOpenTagNode (location: (1:0)-(1:65))
               ├── tag_opening: "<%=" (location: (1:0)-(1:3))├── content: " tag.div class: "wrapper", data: { controller: "hello" } do " (location: (1:3)-(1:63))
               ├── tag_closing: "%>" (location: (1:63)-(1:65))
               ├── tag_name: "div" (location: (1:8)-(1:11))
               └── children: (2 items)
                   ├── @ HTMLAttributeNode (location: (1:12)-(1:19))
                      ├── name: 
                         └── @ HTMLAttributeNameNode (location: (1:12)-(1:19))
                             └── children: (1 item)
                                 └── @ LiteralNode (location: (1:12)-(1:19))
                                     └── content: "class"
                             
                      ├── equals: "=" (location: (1:12)-(1:19))
                      └── value: 
                          └── @ HTMLAttributeValueNode (location: (1:12)-(1:19))
                              ├── open_quote: """ (location: (1:12)-(1:19))
                              ├── children: (1 item)
                                 └── @ LiteralNode (location: (1:12)-(1:19))
                                     └── content: "wrapper"
                                     
                              ├── close_quote: """ (location: (1:19)-(1:19))
                              └── quoted: true
                      
                   └── @ HTMLAttributeNode (location: (1:38)-(1:50))
                       ├── name: 
                          └── @ HTMLAttributeNameNode (location: (1:38)-(1:50))
                              ├── errors: []
                              └── children: (1 item)
                                  └── @ LiteralNode (location: (1:38)-(1:50))
                                      └── content: "data-controller"
                                      
                       ├── equals: "=" (location: (1:38)-(1:50))
                       └── value: 
                           └── @ HTMLAttributeValueNode (location: (1:38)-(1:50))
                               ├── open_quote: """ (location: (1:38)-(1:50))
                               ├── children: (1 item)
                                  └── @ LiteralNode (location: (1:38)-(1:50))
                                      └── content: "hello"
                                  
                               ├── close_quote: """ (location: (1:50)-(1:50))
                               └── quoted: true
                       
        ├── tag_name: "div" (location: (1:8)-(1:11))
        ├── body: (1 item)
           └── @ HTMLTextNode (location: (1:65)-(3:0))
               └── content: "\n  Hello\n"
               
        ├── close_tag: 
           └── @ ERBEndNode (location: (3:0)-(3:9))
               ├── tag_opening: "<%" (location: (3:0)-(3:2))
               ├── content: " end " (location: (3:2)-(3:7))
               └── tag_closing: "%>" (location: (3:7)-(3:9))
               
        ├── is_void: false
        └── element_source: "ActionView::Helpers::TagHelper#tag"

The location reporting for the HTML attribute nodes can still be improved and is going be necessary if we want to improve stimulus-lint for example, so we can accurately show which part of Ruby is relevant.

Resolves #1122

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 10, 2026

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

commit: 4e94e0d

@github-actions
Copy link

github-actions bot commented Mar 10, 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 4e94e0d


✅ Preview deployment has been cleaned up.

@marcoroth marcoroth merged commit ae03aad into main Mar 10, 2026
32 checks passed
@marcoroth marcoroth deleted the actionview-helpers-transform branch March 10, 2026 21:12
marcoroth added a commit that referenced this pull request Mar 10, 2026
This pull request implements two new built-in rewriters
`action-view-tag-helper-to-html` and `html-to-action-view-tag-helper`
using the infrastructure implemented in #1347.

This allows us to rewrite an Action View Tag Helper like:
```html+erb
<%= tag.div class: classes, data: { controller: "hello" } do %>
  Content
<% end %>
```
to HTML:
```html+erb
<div class="<%= classes %>" data-controller="hello">
  Content
</div>
```

and back!
@marcoroth marcoroth added this to the v1.0.0 milestone Mar 11, 2026
marcoroth added a commit that referenced this pull request Mar 11, 2026
)

This pull request implements a hover provider for Action View Tag
Helpers to show what they evaluate to. It uses the foundation introduced
in #1347 and uses the rewriters introduced in #1348 to show the "HTML
equivalent" in the hover box.


https://github.com/user-attachments/assets/1878a044-716c-4aab-b019-f13ad056cd32

Related #1282
marcoroth added a commit that referenced this pull request Mar 11, 2026
Based on the foundation of #1347 and #1348, this pull request implements
Code Actions to transform Action View Tag Helpers to pure HTML, and from
pure HTML back to Action View tag helpers. This should help to make it
easier to refactor between the two syntaxes.



https://github.com/user-attachments/assets/10229a78-9316-4f2c-a864-f5cc1536ad9e
marcoroth added a commit that referenced this pull request Mar 12, 2026
Based on the infrastructure introduced in #1347 we can now start making
the linter rules aware of the Action View Tag Helpers.

The `html-anchor-require-href` linter rule is now also able to flag the
following:
```erb
<%= link_to "Home", "#" %>
```
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Parser: Analyze Action View Tag Helpers

1 participant