Skip to content

Language Server: Implement textDocument/onTypeFormatting for ERB block closers - #2195

Open
joaoGabriel55 wants to merge 6 commits into
marcoroth:mainfrom
joaoGabriel55:Language-Server-Implement-on-type-formatting-for-ERB-block-closers
Open

Language Server: Implement textDocument/onTypeFormatting for ERB block closers#2195
joaoGabriel55 wants to merge 6 commits into
marcoroth:mainfrom
joaoGabriel55:Language-Server-Implement-on-type-formatting-for-ERB-block-closers

Conversation

@joaoGabriel55

@joaoGabriel55 joaoGabriel55 commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

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:

  • Adds an OnTypeFormattingProvider to the language service.
  • Supports do, if, unless, while, and for blocks.
  • Preserves the indentation of the opening ERB tag.
  • Avoids inserting closers for output tags, comments, regular statements, and non-block constructs.
  • Detects an existing matching <% end %>, including after nested blocks, to prevent duplicates.
  • Advertises documentOnTypeFormattingProvider with > as its trigger character.
  • Wires textDocument/onTypeFormatting requests through the language server.
  • Adds language-service and language-server test coverage.

How to test

Run the focused test suites:

yarn nx test @herb-tools/language-service
yarn nx test @herb-tools/language-server

To test manually in an editor using the Herb language server, open an .html.erb file and type:

<% if user.admin? %

After typing the final >, it should become:

<% if user.admin? %>
<% end %>

Confirm that:

  • do, if, unless, while, and for blocks receive a closing tag.
  • The closing tag matches the opener’s indentation.
  • Existing matching <% end %> tags are not duplicated.
  • Output tags such as <%= user.name %>, comments, and non-block statements do not receive a closing tag.

Demo

Screen.Recording.2026-08-11.at.21.57.12.mov

@github-actions github-actions Bot added language-server @herb-tools/language-server and Language Server Protocol support typescript TypeScript source across the javascript/ packages language-service @herb-tools/language-service HTML+ERB language service labels Aug 12, 2026
@joaoGabriel55
joaoGabriel55 force-pushed the Language-Server-Implement-on-type-formatting-for-ERB-block-closers branch from cfe069b to 7dafc24 Compare August 12, 2026 01:20

@marcoroth marcoroth left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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").length

And 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! 🙏🏼

@joaoGabriel55

Copy link
Copy Markdown
Contributor Author

Hello @marcoroth, thanks a lot for your feedback

@joaoGabriel55
joaoGabriel55 force-pushed the Language-Server-Implement-on-type-formatting-for-ERB-block-closers branch from 7dafc24 to 1a58fe3 Compare August 12, 2026 12:47
@joaoGabriel55

Copy link
Copy Markdown
Contributor Author

Reporting MISSING_ERB_END_TAG_ERROR as usual:

image

@marcoroth marcoroth changed the title Language Server: Implement on-type formatting for ERB block closers Language Server: Implement textDocument/onTypeFormatting for ERB block closers Aug 12, 2026

@marcoroth marcoroth left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

@joaoGabriel55

Copy link
Copy Markdown
Contributor Author

Cool. I think that it is always good improving the DX

@joaoGabriel55
joaoGabriel55 force-pushed the Language-Server-Implement-on-type-formatting-for-ERB-block-closers branch from 1a58fe3 to 9d56da9 Compare August 14, 2026 11:44
@joaoGabriel55

joaoGabriel55 commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

QA - Place cursor inside completed ERB blocks

Screen.Recording.2026-08-14.at.10.29.14.mov
  1. Restore the required esbuild setting in javascript/packages/vscode/esbuild.js:
platform: 'node',
mainFields: ['module', 'main'],
outfile: 'dist/extension.js',
  1. Build the extension and updated language server:
yarn workspace herb-lsp build
  1. Confirm the broken import is absent:
rg 'require2\("\./parser/htmlScanner"\)' \
javascript/packages/vscode/dist/extension.js

Expected: no output.

  1. Open the extension package in VS Code:
code javascript/packages/vscode
  1. Ensure the Ruby LSP extension is installed and enabled.
  2. Open the Run and Debug panel, select Launch Client, and press F5.
  3. In the Extension Development Host, open:
/Users/quaresma/opensource/herb/examples/erb.html.erb

Ensure the language mode is Herb (HTML+ERB).

  1. Type this manually, entering the final > last:
<% if condition %

After typing >, expect:

<% 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.

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

Labels

language-server @herb-tools/language-server and Language Server Protocol support language-service @herb-tools/language-service HTML+ERB language service typescript TypeScript source across the javascript/ packages

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants