Language Server: Implement textDocument/onTypeFormatting for ERB block closers - #2195
Conversation
cfe069b to
7dafc24
Compare
marcoroth
left a comment
There was a problem hiding this comment.
Thanks for taking this on @joaoGabriel55, and for the demo video!
There is one bug I would want fixed before this goes in, and I think fixing it makes the implementation even smaller and more logical to follow.
The closer is skipped when you type inside an existing block. hasMatchingEnd scans forward from the cursor and counts nesting, but it cannot tell whether an end it finds belongs to the block you just opened or to one that already encloses you. Running the logic from this PR against a few inputs:
Concretely:
<% items.each do |item| %>
<% if item.ok? %>|
<% end %>Typing that final > inserts nothing, because the <% end %> that closes items.each is read as already closing the new if. In real templates you are nearly always inside something.
The parser already knows and reports an unclosed block as MISSING_ERB_END_TAG_ERROR, and the node itself comes back with end_node unset, so a half typed template still gives a usable answer.
That turns the whole decision into two conditions: the line you just typed closes an ERB tag, and the document is now short an end.
const missingEnds = (source: string) => parse(source).recursiveErrors().filter(error => error.type === "MISSING_ERB_END_TAG_ERROR").lengthAnd also, case and begin would come for free, because the parser already knows they need an end. isBlockOpener and hasMatchingEnd both go, which are the two pieces carrying all the complexity.
Keep the indentation logic exactly as it is, since this part is already right.
Since the closer is inserted directly after the opener's line, the opener's own leading whitespace is the correct indentation by construction. line.match(/^\s*/) does that, and it holds up nested and inside HTML:
<% a.each do |x| %>
<% b.each do |y| %>
<% if y %>
<% end %> <- new, at the if's depth
<% end %>
<% end %>So the split is: the AST decides whether to insert, the typed line decides where. I hope this makes sense.
Smaller things
on_type_formatting.ts builds a module level const provider = new OnTypeFormattingProvider() and exposes a free function, while every other provider is constructed in Session and reached through this.session. I think this is worth matching. The provider will still need a HerbBackend injected anyway once it parses, the same way ParserService is handed to the others.
Please let me know if you need any more guidance (and sorry for not providing more context in the first place, I pretty much just dumped that idea in an issue so I don't forget 🙈).
Either way, thank you for giving it a shot! 🙏🏼
|
Hello @marcoroth, thanks a lot for your feedback |
7dafc24 to
1a58fe3
Compare
textDocument/onTypeFormatting for ERB block closers
marcoroth
left a comment
There was a problem hiding this comment.
Awesome, thank you @joaoGabriel55, this looks great!
I got one more small nit thats more behavior than code related. Right now if you complete the > in <% if condition % it inserts the <% end %> and puts the curser here:
<% if condition %>
<% end %>▮I wonder if its more natural to put it like this instead:
<% if condition %>
▮
<% end %>One extra newline and then indent one more level. What do you think?
|
Cool. I think that it is always good improving the DX |
1a58fe3 to
9d56da9
Compare
QA - Place cursor inside completed ERB blocksScreen.Recording.2026-08-14.at.10.29.14.mov
platform: 'node',
mainFields: ['module', 'main'],
outfile: 'dist/extension.js',
Expected: no output.
Ensure the language mode is Herb (HTML+ERB).
<% if condition %
After typing <% if condition %>
▮
<% end %>The cursor should be on the indented blank line. Also try changing VS Code indentation to four spaces or tabs and verify the inserted body follows that setting. |
…-for-ERB-block-closers

Summary
Issue: #2167
Adds LSP on-type formatting for ERB block openers. When the final
>is typed, the language server automatically inserts a matching<% end %>on the next line.This change:
OnTypeFormattingProviderto the language service.do,if,unless,while, andforblocks.<% end %>, including after nested blocks, to prevent duplicates.documentOnTypeFormattingProviderwith>as its trigger character.textDocument/onTypeFormattingrequests through the language server.How to test
Run the focused test suites:
To test manually in an editor using the Herb language server, open an
.html.erbfile and type:After typing the final
>, it should become:Confirm that:
do,if,unless,while, andforblocks receive a closing tag.<% end %>tags are not duplicated.<%= user.name %>, comments, and non-block statements do not receive a closing tag.Demo
Screen.Recording.2026-08-11.at.21.57.12.mov