From ec72b3e7945fd0f015f7999c5b713ce22b43c906 Mon Sep 17 00:00:00 2001 From: joaoGabriel55 Date: Tue, 11 Aug 2026 22:01:16 -0300 Subject: [PATCH 1/5] feat: Add `OnTypeFormattingProvider` for ERB block closers --- .../packages/language-service/src/index.ts | 1 + .../src/on_type_formatting_provider.ts | 55 ++++++++ .../test/on_type_formatting_provider.test.ts | 132 ++++++++++++++++++ 3 files changed, 188 insertions(+) create mode 100644 javascript/packages/language-service/src/on_type_formatting_provider.ts create mode 100644 javascript/packages/language-service/test/on_type_formatting_provider.test.ts diff --git a/javascript/packages/language-service/src/index.ts b/javascript/packages/language-service/src/index.ts index 331b0f43c..76ab8ae32 100644 --- a/javascript/packages/language-service/src/index.ts +++ b/javascript/packages/language-service/src/index.ts @@ -96,3 +96,4 @@ export * from "./render_collector" export * from "./definition_provider" export * from "./completion_provider" export * from "./references_provider" +export * from "./on_type_formatting_provider" diff --git a/javascript/packages/language-service/src/on_type_formatting_provider.ts b/javascript/packages/language-service/src/on_type_formatting_provider.ts new file mode 100644 index 000000000..55d9b5739 --- /dev/null +++ b/javascript/packages/language-service/src/on_type_formatting_provider.ts @@ -0,0 +1,55 @@ +import { TextEdit } from "vscode-languageserver-types" + +import type { Position } from "vscode-languageserver-types" +import type { TextDocument } from "vscode-languageserver-textdocument" + +export class OnTypeFormattingProvider { + getTextEdits(document: TextDocument, position: Position, character: string): TextEdit[] { + if (character !== ">") return [] + + const offset = document.offsetAt(position) + const source = document.getText() + const lineStart = source.lastIndexOf("\n", offset - 1) + 1 + const line = source.slice(lineStart, offset) + + const tagStart = line.lastIndexOf("<%") + if (tagStart === -1) return [] + + const tag = line.slice(tagStart) + if (!isBlockOpener(tag)) return [] + if (hasMatchingEnd(source.slice(offset))) return [] + + const indentation = line.match(/^\s*/)?.[0] ?? "" + + return [TextEdit.insert(position, `\n${indentation}<% end %>`)] + } +} + +function hasMatchingEnd(source: string): boolean { + const tags = source.matchAll(/<%(?![=#])\s*([\s\S]*?)\s*%>/g) + let nestedBlocks = 0 + + for (const match of tags) { + const tag = match[0] + const code = match[1] + + if (isBlockOpener(tag)) { + nestedBlocks += 1 + } else if (/^end\b/.test(code)) { + if (nestedBlocks === 0) return true + + nestedBlocks -= 1 + } + } + + return false +} + +function isBlockOpener(tag: string): boolean { + const match = tag.match(/^<%(?![=#])\s*([\s\S]*?)\s*%>$/) + if (!match) return false + + const code = match[1] + + return /^(?:if|unless|while|for)\b/.test(code) || /\bdo(?:\s*\|[^|]*\|)?\s*$/.test(code) +} diff --git a/javascript/packages/language-service/test/on_type_formatting_provider.test.ts b/javascript/packages/language-service/test/on_type_formatting_provider.test.ts new file mode 100644 index 000000000..3dfa7a243 --- /dev/null +++ b/javascript/packages/language-service/test/on_type_formatting_provider.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest" +import { Position } from "vscode-languageserver-types" +import { TextDocument } from "vscode-languageserver-textdocument" + +import { OnTypeFormattingProvider } from "../src/on_type_formatting_provider.js" + +function createDocument(content: string) { + return TextDocument.create("file:///test.html.erb", "erb", 1, content) +} + +describe("OnTypeFormattingProvider", () => { + const provider = new OnTypeFormattingProvider() + + it("inserts an ERB end tag after a do block opener", () => { + const source = "<% @items.each do |item| %>" + const document = createDocument(source) + + expect( + provider.getTextEdits(document, Position.create(0, source.length), ">"), + ).toEqual([ + { + range: { + start: { line: 0, character: source.length }, + end: { line: 0, character: source.length }, + }, + newText: "\n<% end %>", + }, + ]) + }) + + it.each([ + "<% if user.admin? %>", + "<% unless items.empty? %>", + "<% while pending? %>", + "<% for item in items %>", + ])("inserts an ERB end tag for %s", (source) => { + const document = createDocument(source) + + expect( + provider.getTextEdits(document, Position.create(0, source.length), ">"), + ).toHaveLength(1) + }) + + it("preserves the opening tag indentation", () => { + const source = " <% if user.admin? %>" + const document = createDocument(source) + + expect( + provider.getTextEdits(document, Position.create(0, source.length), ">"), + ).toEqual([ + { + range: { + start: { line: 0, character: source.length }, + end: { line: 0, character: source.length }, + }, + newText: "\n <% end %>", + }, + ]) + }) + + it("preserves indentation inside nested markup", () => { + const openingTag = " <% @items.each do |item| %>" + const document = createDocument(`
\n${openingTag}`) + + expect( + provider.getTextEdits( + document, + Position.create(1, openingTag.length), + ">", + )?.[0].newText, + ).toBe("\n <% end %>") + }) + + it.each([ + "<% user = current_user %>", + "<%= user.name %>", + "<%# explain this template %>", + "<% puts user.name if user %>", + "<% items.each %>", + ])("does not insert an end tag for %s", (source) => { + const document = createDocument(source) + + expect( + provider.getTextEdits(document, Position.create(0, source.length), ">"), + ).toEqual([]) + }) + + it("ignores trigger characters other than >", () => { + const source = "<% if user.admin? %>" + const document = createDocument(source) + + expect( + provider.getTextEdits(document, Position.create(0, source.length), "%"), + ).toEqual([]) + }) + + it("does not duplicate an existing matching end tag", () => { + const firstLine = "<% if user.admin? %>" + const document = createDocument( + `${firstLine}\n Admin\n<% end %>`, + ) + + expect( + provider.getTextEdits( + document, + Position.create(0, firstLine.length), + ">", + ), + ).toEqual([]) + }) + + it("finds the matching end tag after a nested block", () => { + const firstLine = "<% if user.admin? %>" + const document = createDocument( + [ + firstLine, + " <% items.each do |item| %>", + " <%= item %>", + " <% end %>", + "<% end %>", + ].join("\n"), + ) + + expect( + provider.getTextEdits( + document, + Position.create(0, firstLine.length), + ">", + ), + ).toEqual([]) + }) +}) From f1a77651dbf8d49dfa4bc9e448c98972894825e2 Mon Sep 17 00:00:00 2001 From: joaoGabriel55 Date: Tue, 11 Aug 2026 22:01:33 -0300 Subject: [PATCH 2/5] feat: Implement on-type formatting for ERB block closers --- .../language-server/src/on_type_formatting.ts | 24 +++++++ .../packages/language-server/src/server.ts | 7 +++ .../test/on_type_formatting.test.ts | 63 +++++++++++++++++++ 3 files changed, 94 insertions(+) create mode 100644 javascript/packages/language-server/src/on_type_formatting.ts create mode 100644 javascript/packages/language-server/test/on_type_formatting.test.ts diff --git a/javascript/packages/language-server/src/on_type_formatting.ts b/javascript/packages/language-server/src/on_type_formatting.ts new file mode 100644 index 000000000..3732852e4 --- /dev/null +++ b/javascript/packages/language-server/src/on_type_formatting.ts @@ -0,0 +1,24 @@ +import { OnTypeFormattingProvider } from "@herb-tools/language-service" + +import type { + DocumentOnTypeFormattingOptions, + DocumentOnTypeFormattingParams, + TextEdit, +} from "vscode-languageserver/node" +import type { Documents } from "./documents" + +export const ON_TYPE_FORMATTING_OPTIONS: DocumentOnTypeFormattingOptions = { + firstTriggerCharacter: ">", +} + +const provider = new OnTypeFormattingProvider() + +export function handleOnTypeFormatting( + documents: Documents, + params: DocumentOnTypeFormattingParams, +): TextEdit[] { + const document = documents.get(params.textDocument.uri) + if (!document) return [] + + return provider.getTextEdits(document, params.position, params.ch) +} diff --git a/javascript/packages/language-server/src/server.ts b/javascript/packages/language-server/src/server.ts index 88a065153..2f86fe35d 100644 --- a/javascript/packages/language-server/src/server.ts +++ b/javascript/packages/language-server/src/server.ts @@ -10,6 +10,7 @@ import { Connection, DocumentFormattingParams, DocumentRangeFormattingParams, + DocumentOnTypeFormattingParams, CodeActionParams, CodeActionKind, FoldingRangeParams, @@ -32,6 +33,7 @@ import { Config } from "@herb-tools/config" import { isPartialPath } from "@herb-tools/analysis" import { isConfigDocument, isPathInside } from "./utils" import { serverVersion } from "./build_info" +import { handleOnTypeFormatting, ON_TYPE_FORMATTING_OPTIONS } from "./on_type_formatting" import type { FileEvent } from "vscode-languageserver/node" import type { ExtractToPartialResult } from "@herb-tools/language-service" @@ -73,6 +75,7 @@ export class Server { }, documentFormattingProvider: true, documentRangeFormattingProvider: true, + documentOnTypeFormattingProvider: ON_TYPE_FORMATTING_OPTIONS, codeActionProvider: { codeActionKinds: [CodeActionKind.QuickFix, CodeActionKind.SourceFixAll, CodeActionKind.RefactorRewrite, CodeActionKind.RefactorExtract] }, @@ -199,6 +202,10 @@ export class Server { return this.session.projects.get(params.textDocument.uri)?.formattingProvider.formatRange(params) ?? [] }) + this.connection.onDocumentOnTypeFormatting((params: DocumentOnTypeFormattingParams) => { + return handleOnTypeFormatting(this.session.documents, params) + }) + this.connection.onDocumentHighlight((params: DocumentHighlightParams) => { const document = this.session.documents.get(params.textDocument.uri) diff --git a/javascript/packages/language-server/test/on_type_formatting.test.ts b/javascript/packages/language-server/test/on_type_formatting.test.ts new file mode 100644 index 000000000..9529c6b7e --- /dev/null +++ b/javascript/packages/language-server/test/on_type_formatting.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from "vitest" +import { TextDocument } from "vscode-languageserver-textdocument" + +import { + ON_TYPE_FORMATTING_OPTIONS, + handleOnTypeFormatting, +} from "../src/on_type_formatting" + +import type { Documents } from "../src/documents" + +describe("on-type formatting", () => { + it("advertises > as the trigger character", () => { + expect(ON_TYPE_FORMATTING_OPTIONS).toEqual({ + firstTriggerCharacter: ">", + }) + }) + + it("routes an on-type formatting request to the language service", () => { + const source = "<% if user.admin? %>" + const document = TextDocument.create( + "file:///test.html.erb", + "erb", + 1, + source, + ) + const documents = { + get: vi.fn().mockReturnValue(document), + } as unknown as Documents + + const edits = handleOnTypeFormatting(documents, { + textDocument: { uri: document.uri }, + position: { line: 0, character: source.length }, + ch: ">", + options: { tabSize: 2, insertSpaces: true }, + }) + + expect(documents.get).toHaveBeenCalledWith(document.uri) + expect(edits).toEqual([ + { + range: { + start: { line: 0, character: source.length }, + end: { line: 0, character: source.length }, + }, + newText: "\n<% end %>", + }, + ]) + }) + + it("returns no edits when the document is not open", () => { + const documents = { + get: vi.fn().mockReturnValue(undefined), + } as unknown as Documents + + expect( + handleOnTypeFormatting(documents, { + textDocument: { uri: "file:///missing.html.erb" }, + position: { line: 0, character: 0 }, + ch: ">", + options: { tabSize: 2, insertSpaces: true }, + }), + ).toEqual([]) + }) +}) From 7878da1253841939f83487f2bb7db8d46c3147df Mon Sep 17 00:00:00 2001 From: joaoGabriel55 Date: Tue, 11 Aug 2026 22:18:08 -0300 Subject: [PATCH 3/5] refactor: make the OnTypeFormattingProvider functions to private methods --- .../src/on_type_formatting_provider.ts | 53 +++++++++++-------- 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/javascript/packages/language-service/src/on_type_formatting_provider.ts b/javascript/packages/language-service/src/on_type_formatting_provider.ts index 55d9b5739..b7cc89767 100644 --- a/javascript/packages/language-service/src/on_type_formatting_provider.ts +++ b/javascript/packages/language-service/src/on_type_formatting_provider.ts @@ -4,7 +4,11 @@ import type { Position } from "vscode-languageserver-types" import type { TextDocument } from "vscode-languageserver-textdocument" export class OnTypeFormattingProvider { - getTextEdits(document: TextDocument, position: Position, character: string): TextEdit[] { + getTextEdits( + document: TextDocument, + position: Position, + character: string, + ): TextEdit[] { if (character !== ">") return [] const offset = document.offsetAt(position) @@ -16,40 +20,43 @@ export class OnTypeFormattingProvider { if (tagStart === -1) return [] const tag = line.slice(tagStart) - if (!isBlockOpener(tag)) return [] - if (hasMatchingEnd(source.slice(offset))) return [] + if (!this.isBlockOpener(tag)) return [] + if (this.hasMatchingEnd(source.slice(offset))) return [] const indentation = line.match(/^\s*/)?.[0] ?? "" return [TextEdit.insert(position, `\n${indentation}<% end %>`)] } -} -function hasMatchingEnd(source: string): boolean { - const tags = source.matchAll(/<%(?![=#])\s*([\s\S]*?)\s*%>/g) - let nestedBlocks = 0 + private hasMatchingEnd(source: string): boolean { + const tags = source.matchAll(/<%(?![=#])\s*([\s\S]*?)\s*%>/g) + let nestedBlocks = 0 - for (const match of tags) { - const tag = match[0] - const code = match[1] + for (const match of tags) { + const tag = match[0] + const code = match[1] - if (isBlockOpener(tag)) { - nestedBlocks += 1 - } else if (/^end\b/.test(code)) { - if (nestedBlocks === 0) return true + if (this.isBlockOpener(tag)) { + nestedBlocks += 1 + } else if (/^end\b/.test(code)) { + if (nestedBlocks === 0) return true - nestedBlocks -= 1 + nestedBlocks -= 1 + } } - } - return false -} + return false + } -function isBlockOpener(tag: string): boolean { - const match = tag.match(/^<%(?![=#])\s*([\s\S]*?)\s*%>$/) - if (!match) return false + private isBlockOpener(tag: string): boolean { + const match = tag.match(/^<%(?![=#])\s*([\s\S]*?)\s*%>$/) + if (!match) return false - const code = match[1] + const code = match[1] - return /^(?:if|unless|while|for)\b/.test(code) || /\bdo(?:\s*\|[^|]*\|)?\s*$/.test(code) + return ( + /^(?:if|unless|while|for)\b/.test(code) || + /\bdo(?:\s*\|[^|]*\|)?\s*$/.test(code) + ) + } } From 9d56da938585e7a22db21ed2e24ca0eb9d5b6dee Mon Sep 17 00:00:00 2001 From: joaoGabriel55 Date: Wed, 12 Aug 2026 09:11:48 -0300 Subject: [PATCH 4/5] fix: use parser diagnostics for ERB block closers --- .../language-server/src/on_type_formatting.ts | 21 +---- .../packages/language-server/src/server.ts | 8 +- .../packages/language-server/src/session.ts | 17 +++- .../test/on_type_formatting.test.ts | 90 +++++++++++-------- .../src/on_type_formatting_provider.ts | 50 ++++------- .../test/on_type_formatting_provider.test.ts | 84 ++++++++++++++++- 6 files changed, 177 insertions(+), 93 deletions(-) diff --git a/javascript/packages/language-server/src/on_type_formatting.ts b/javascript/packages/language-server/src/on_type_formatting.ts index 3732852e4..da09c7848 100644 --- a/javascript/packages/language-server/src/on_type_formatting.ts +++ b/javascript/packages/language-server/src/on_type_formatting.ts @@ -1,24 +1,5 @@ -import { OnTypeFormattingProvider } from "@herb-tools/language-service" - -import type { - DocumentOnTypeFormattingOptions, - DocumentOnTypeFormattingParams, - TextEdit, -} from "vscode-languageserver/node" -import type { Documents } from "./documents" +import type { DocumentOnTypeFormattingOptions } from "vscode-languageserver/node" export const ON_TYPE_FORMATTING_OPTIONS: DocumentOnTypeFormattingOptions = { firstTriggerCharacter: ">", } - -const provider = new OnTypeFormattingProvider() - -export function handleOnTypeFormatting( - documents: Documents, - params: DocumentOnTypeFormattingParams, -): TextEdit[] { - const document = documents.get(params.textDocument.uri) - if (!document) return [] - - return provider.getTextEdits(document, params.position, params.ch) -} diff --git a/javascript/packages/language-server/src/server.ts b/javascript/packages/language-server/src/server.ts index 2f86fe35d..64725ff68 100644 --- a/javascript/packages/language-server/src/server.ts +++ b/javascript/packages/language-server/src/server.ts @@ -33,7 +33,7 @@ import { Config } from "@herb-tools/config" import { isPartialPath } from "@herb-tools/analysis" import { isConfigDocument, isPathInside } from "./utils" import { serverVersion } from "./build_info" -import { handleOnTypeFormatting, ON_TYPE_FORMATTING_OPTIONS } from "./on_type_formatting" +import { ON_TYPE_FORMATTING_OPTIONS } from "./on_type_formatting" import type { FileEvent } from "vscode-languageserver/node" import type { ExtractToPartialResult } from "@herb-tools/language-service" @@ -203,7 +203,11 @@ export class Server { }) this.connection.onDocumentOnTypeFormatting((params: DocumentOnTypeFormattingParams) => { - return handleOnTypeFormatting(this.session.documents, params) + const document = this.session.documents.get(params.textDocument.uri) + + if (!document) return [] + + return this.session.onTypeFormattingProvider.getTextEdits(document, params.position, params.ch) }) this.connection.onDocumentHighlight((params: DocumentHighlightParams) => { diff --git a/javascript/packages/language-server/src/session.ts b/javascript/packages/language-server/src/session.ts index dd238bd9c..73c67f593 100644 --- a/javascript/packages/language-server/src/session.ts +++ b/javascript/packages/language-server/src/session.ts @@ -9,7 +9,20 @@ import { Capabilities } from "./capabilities" import { WorkspaceFolders } from "./workspace_folders" import { Documents } from "./documents" import { DiagnosticsPublisher } from "./diagnostics_publisher" -import { ParserService, FoldingRangeProvider, SelectionRangeProvider, DocumentHighlightProvider, InlayHintProvider, HoverProvider, RewriteCodeActionProvider, CommentProvider, DocumentSymbolProvider, ExtractCodeActionProvider, DefinitionProvider } from "@herb-tools/language-service" +import { + CommentProvider, + DefinitionProvider, + DocumentHighlightProvider, + DocumentSymbolProvider, + ExtractCodeActionProvider, + FoldingRangeProvider, + HoverProvider, + InlayHintProvider, + OnTypeFormattingProvider, + ParserService, + RewriteCodeActionProvider, + SelectionRangeProvider, +} from "@herb-tools/language-service" import { ConfigService } from "./config_service" import { SaveOrchestrator } from "./save_orchestrator" @@ -42,6 +55,7 @@ export class Session { definitionProvider: DefinitionProvider commentProvider: CommentProvider documentSymbolProvider: DocumentSymbolProvider + onTypeFormattingProvider: OnTypeFormattingProvider constructor(connection: Connection, params: InitializeParams) { this.connection = connection @@ -77,6 +91,7 @@ export class Session { this.rewriteCodeActionProvider = new RewriteCodeActionProvider(this.parserService, process.cwd()) this.commentProvider = new CommentProvider(this.parserService) this.documentSymbolProvider = new DocumentSymbolProvider(this.parserService) + this.onTypeFormattingProvider = new OnTypeFormattingProvider(this.parserService) this.extractCodeActionProvider = new ExtractCodeActionProvider(this.parserService, this.capabilities, existsSync) diff --git a/javascript/packages/language-server/test/on_type_formatting.test.ts b/javascript/packages/language-server/test/on_type_formatting.test.ts index 9529c6b7e..aafd6be0a 100644 --- a/javascript/packages/language-server/test/on_type_formatting.test.ts +++ b/javascript/packages/language-server/test/on_type_formatting.test.ts @@ -1,12 +1,42 @@ import { describe, expect, it, vi } from "vitest" import { TextDocument } from "vscode-languageserver-textdocument" -import { - ON_TYPE_FORMATTING_OPTIONS, - handleOnTypeFormatting, -} from "../src/on_type_formatting" +const connectionState = vi.hoisted(() => ({ + onTypeFormatting: undefined as + | ((params: { + textDocument: { uri: string } + position: { line: number; character: number } + ch: string + }) => unknown) + | undefined, +})) -import type { Documents } from "../src/documents" +vi.mock("vscode-languageserver/node", async (importOriginal) => { + const original = + await importOriginal() + const connection = new Proxy( + {}, + { + get: (_target, property) => { + if (property === "onDocumentOnTypeFormatting") { + return (handler: typeof connectionState.onTypeFormatting) => { + connectionState.onTypeFormatting = handler + } + } + + return vi.fn() + }, + }, + ) + + return { + ...original, + createConnection: vi.fn(() => connection), + } +}) + +import { ON_TYPE_FORMATTING_OPTIONS } from "../src/on_type_formatting" +import { Server } from "../src/server" describe("on-type formatting", () => { it("advertises > as the trigger character", () => { @@ -15,7 +45,7 @@ describe("on-type formatting", () => { }) }) - it("routes an on-type formatting request to the language service", () => { + it("routes requests through the provider stored in Session", () => { const source = "<% if user.admin? %>" const document = TextDocument.create( "file:///test.html.erb", @@ -23,41 +53,29 @@ describe("on-type formatting", () => { 1, source, ) - const documents = { - get: vi.fn().mockReturnValue(document), - } as unknown as Documents + const getTextEdits = vi.fn().mockReturnValue([{ newText: "expected" }]) + const server = new Server() - const edits = handleOnTypeFormatting(documents, { + Object.assign(server, { + session: { + documents: { get: vi.fn().mockReturnValue(document) }, + onTypeFormattingProvider: { getTextEdits }, + }, + }) + + const params = { textDocument: { uri: document.uri }, position: { line: 0, character: source.length }, ch: ">", - options: { tabSize: 2, insertSpaces: true }, - }) + } - expect(documents.get).toHaveBeenCalledWith(document.uri) - expect(edits).toEqual([ - { - range: { - start: { line: 0, character: source.length }, - end: { line: 0, character: source.length }, - }, - newText: "\n<% end %>", - }, + expect(connectionState.onTypeFormatting?.(params)).toEqual([ + { newText: "expected" }, ]) - }) - - it("returns no edits when the document is not open", () => { - const documents = { - get: vi.fn().mockReturnValue(undefined), - } as unknown as Documents - - expect( - handleOnTypeFormatting(documents, { - textDocument: { uri: "file:///missing.html.erb" }, - position: { line: 0, character: 0 }, - ch: ">", - options: { tabSize: 2, insertSpaces: true }, - }), - ).toEqual([]) + expect(getTextEdits).toHaveBeenCalledWith( + document, + params.position, + params.ch, + ) }) }) diff --git a/javascript/packages/language-service/src/on_type_formatting_provider.ts b/javascript/packages/language-service/src/on_type_formatting_provider.ts index b7cc89767..d69b5a595 100644 --- a/javascript/packages/language-service/src/on_type_formatting_provider.ts +++ b/javascript/packages/language-service/src/on_type_formatting_provider.ts @@ -2,8 +2,15 @@ import { TextEdit } from "vscode-languageserver-types" import type { Position } from "vscode-languageserver-types" import type { TextDocument } from "vscode-languageserver-textdocument" +import type { ParserService } from "./parser_service" export class OnTypeFormattingProvider { + private readonly parserService: ParserService + + constructor(parserService: ParserService) { + this.parserService = parserService + } + getTextEdits( document: TextDocument, position: Position, @@ -20,43 +27,22 @@ export class OnTypeFormattingProvider { if (tagStart === -1) return [] const tag = line.slice(tagStart) - if (!this.isBlockOpener(tag)) return [] - if (this.hasMatchingEnd(source.slice(offset))) return [] + if (!tag.endsWith("%>")) return [] + + const sourceWithoutTag = + source.slice(0, lineStart + tagStart) + source.slice(offset) + if (this.missingEnds(source) <= this.missingEnds(sourceWithoutTag)) + return [] const indentation = line.match(/^\s*/)?.[0] ?? "" return [TextEdit.insert(position, `\n${indentation}<% end %>`)] } - private hasMatchingEnd(source: string): boolean { - const tags = source.matchAll(/<%(?![=#])\s*([\s\S]*?)\s*%>/g) - let nestedBlocks = 0 - - for (const match of tags) { - const tag = match[0] - const code = match[1] - - if (this.isBlockOpener(tag)) { - nestedBlocks += 1 - } else if (/^end\b/.test(code)) { - if (nestedBlocks === 0) return true - - nestedBlocks -= 1 - } - } - - return false - } - - private isBlockOpener(tag: string): boolean { - const match = tag.match(/^<%(?![=#])\s*([\s\S]*?)\s*%>$/) - if (!match) return false - - const code = match[1] - - return ( - /^(?:if|unless|while|for)\b/.test(code) || - /\bdo(?:\s*\|[^|]*\|)?\s*$/.test(code) - ) + private missingEnds(source: string): number { + return this.parserService + .parseContent(source) + .recursiveErrors() + .filter((error) => error.type === "MISSING_ERB_END_TAG_ERROR").length } } diff --git a/javascript/packages/language-service/test/on_type_formatting_provider.test.ts b/javascript/packages/language-service/test/on_type_formatting_provider.test.ts index 3dfa7a243..aaef040ea 100644 --- a/javascript/packages/language-service/test/on_type_formatting_provider.test.ts +++ b/javascript/packages/language-service/test/on_type_formatting_provider.test.ts @@ -1,15 +1,22 @@ -import { describe, expect, it } from "vitest" +import { beforeAll, describe, expect, it } from "vitest" import { Position } from "vscode-languageserver-types" import { TextDocument } from "vscode-languageserver-textdocument" +import { Herb } from "@herb-tools/node-wasm" import { OnTypeFormattingProvider } from "../src/on_type_formatting_provider.js" +import { ParserService } from "../src/parser_service.js" function createDocument(content: string) { return TextDocument.create("file:///test.html.erb", "erb", 1, content) } describe("OnTypeFormattingProvider", () => { - const provider = new OnTypeFormattingProvider() + let provider: OnTypeFormattingProvider + + beforeAll(async () => { + await Herb.load() + provider = new OnTypeFormattingProvider(new ParserService(Herb)) + }) it("inserts an ERB end tag after a do block opener", () => { const source = "<% @items.each do |item| %>" @@ -33,6 +40,8 @@ describe("OnTypeFormattingProvider", () => { "<% unless items.empty? %>", "<% while pending? %>", "<% for item in items %>", + "<% case status %>", + "<% begin %>", ])("inserts an ERB end tag for %s", (source) => { const document = createDocument(source) @@ -129,4 +138,75 @@ describe("OnTypeFormattingProvider", () => { ), ).toEqual([]) }) + + it("inserts an end tag for a block opened inside an existing block", () => { + const openingTag = " <% if item.ok? %>" + const document = createDocument( + ["<% items.each do |item| %>", openingTag, "<% end %>"].join("\n"), + ) + + expect( + provider.getTextEdits( + document, + Position.create(1, openingTag.length), + ">", + ), + ).toEqual([ + { + range: { + start: { line: 1, character: openingTag.length }, + end: { line: 1, character: openingTag.length }, + }, + newText: "\n <% end %>", + }, + ]) + }) + + it("preserves indentation when inserting inside nested ERB and HTML", () => { + const openingTag = " <% if y %>" + const document = createDocument( + [ + "
", + " <% a.each do |x| %>", + "
", + " <% b.each do |y| %>", + openingTag, + " <% end %>", + "
", + " <% end %>", + "
", + ].join("\n"), + ) + + expect( + provider.getTextEdits( + document, + Position.create(4, openingTag.length), + ">", + ), + ).toEqual([ + { + range: { + start: { line: 4, character: openingTag.length }, + end: { line: 4, character: openingTag.length }, + }, + newText: "\n <% end %>", + }, + ]) + }) + + it("does not insert for a non-block tag when another block is missing an end", () => { + const assignment = " <% user = current_user %>" + const document = createDocument( + ["<% if signed_in? %>", assignment].join("\n"), + ) + + expect( + provider.getTextEdits( + document, + Position.create(1, assignment.length), + ">", + ), + ).toEqual([]) + }) }) From cf81f531c71f3088bfff152f3f0522b7ee7bc503 Mon Sep 17 00:00:00 2001 From: joaoGabriel55 Date: Fri, 14 Aug 2026 10:00:37 -0300 Subject: [PATCH 5/5] Place cursor inside completed ERB blocks --- .../language-server/src/capabilities.ts | 2 + .../packages/language-server/src/server.ts | 42 ++++- .../language-server/test/capabilities.test.ts | 8 +- .../test/on_type_formatting.test.ts | 169 +++++++++++++++++- .../src/on_type_formatting_provider.ts | 42 ++++- .../test/on_type_formatting_provider.test.ts | 42 ++++- 6 files changed, 285 insertions(+), 20 deletions(-) diff --git a/javascript/packages/language-server/src/capabilities.ts b/javascript/packages/language-server/src/capabilities.ts index 1cf60b460..d3098dac2 100644 --- a/javascript/packages/language-server/src/capabilities.ts +++ b/javascript/packages/language-server/src/capabilities.ts @@ -20,6 +20,7 @@ export class Capabilities { readonly hasConfiguration: boolean readonly hasWorkspaceFolders: boolean readonly hasDiagnosticRelatedInformation: boolean + readonly hasApplyEdit: boolean readonly hasShowDocument: boolean constructor(params: InitializeParams) { @@ -28,6 +29,7 @@ export class Capabilities { this.hasConfiguration = !!this.client.workspace?.configuration this.hasWorkspaceFolders = !!this.client.workspace?.workspaceFolders + this.hasApplyEdit = !!this.client.workspace?.applyEdit this.hasShowDocument = !!this.client.window?.showDocument this.hasDiagnosticRelatedInformation = !!this.client.textDocument?.publishDiagnostics?.relatedInformation } diff --git a/javascript/packages/language-server/src/server.ts b/javascript/packages/language-server/src/server.ts index 64725ff68..d8587364f 100644 --- a/javascript/packages/language-server/src/server.ts +++ b/javascript/packages/language-server/src/server.ts @@ -202,12 +202,50 @@ export class Server { return this.session.projects.get(params.textDocument.uri)?.formattingProvider.formatRange(params) ?? [] }) - this.connection.onDocumentOnTypeFormatting((params: DocumentOnTypeFormattingParams) => { + this.connection.onDocumentOnTypeFormatting(async (params: DocumentOnTypeFormattingParams) => { const document = this.session.documents.get(params.textDocument.uri) if (!document) return [] - return this.session.onTypeFormattingProvider.getTextEdits(document, params.position, params.ch) + const formatting = this.session.onTypeFormattingProvider.getFormatting( + document, + params.position, + params.ch, + params.options, + ) + + if ( + formatting.cursor === null || + !this.session.capabilities.hasApplyEdit || + !this.session.capabilities.hasShowDocument + ) { + return formatting.edits + } + + const applyResult = await this.connection.workspace.applyEdit({ + changes: { [document.uri]: formatting.edits }, + }) + + if (!applyResult.applied) return formatting.edits + + try { + const showResult = await this.connection.window.showDocument({ + uri: document.uri, + takeFocus: true, + selection: { + start: formatting.cursor, + end: formatting.cursor, + }, + }) + + if (!showResult.success) { + this.connection.console.warn("Failed to move the cursor after on-type formatting") + } + } catch (error) { + this.connection.console.error(`Failed to move the cursor after on-type formatting: ${error}`) + } + + return [] }) this.connection.onDocumentHighlight((params: DocumentHighlightParams) => { diff --git a/javascript/packages/language-server/test/capabilities.test.ts b/javascript/packages/language-server/test/capabilities.test.ts index 80b843d25..85724d896 100644 --- a/javascript/packages/language-server/test/capabilities.test.ts +++ b/javascript/packages/language-server/test/capabilities.test.ts @@ -18,6 +18,7 @@ describe("Capabilities", () => { expect(capabilities.hasConfiguration).toBe(false) expect(capabilities.hasWorkspaceFolders).toBe(false) + expect(capabilities.hasApplyEdit).toBe(false) expect(capabilities.hasShowDocument).toBe(false) expect(capabilities.hasDiagnosticRelatedInformation).toBe(false) }) @@ -26,7 +27,11 @@ describe("Capabilities", () => { const capabilities = new Capabilities({ ...mockParams, capabilities: { - workspace: { configuration: true, workspaceFolders: true }, + workspace: { + applyEdit: true, + configuration: true, + workspaceFolders: true, + }, window: { showDocument: { support: true } }, textDocument: { publishDiagnostics: { relatedInformation: true } } } @@ -34,6 +39,7 @@ describe("Capabilities", () => { expect(capabilities.hasConfiguration).toBe(true) expect(capabilities.hasWorkspaceFolders).toBe(true) + expect(capabilities.hasApplyEdit).toBe(true) expect(capabilities.hasShowDocument).toBe(true) expect(capabilities.hasDiagnosticRelatedInformation).toBe(true) }) diff --git a/javascript/packages/language-server/test/on_type_formatting.test.ts b/javascript/packages/language-server/test/on_type_formatting.test.ts index aafd6be0a..f05391161 100644 --- a/javascript/packages/language-server/test/on_type_formatting.test.ts +++ b/javascript/packages/language-server/test/on_type_formatting.test.ts @@ -1,14 +1,19 @@ -import { describe, expect, it, vi } from "vitest" +import { beforeEach, describe, expect, it, vi } from "vitest" import { TextDocument } from "vscode-languageserver-textdocument" const connectionState = vi.hoisted(() => ({ + applyEdit: vi.fn(), + error: vi.fn(), onTypeFormatting: undefined as | ((params: { textDocument: { uri: string } position: { line: number; character: number } ch: string + options: { tabSize: number; insertSpaces: boolean } }) => unknown) | undefined, + showDocument: vi.fn(), + warn: vi.fn(), })) vi.mock("vscode-languageserver/node", async (importOriginal) => { @@ -24,6 +29,22 @@ vi.mock("vscode-languageserver/node", async (importOriginal) => { } } + if (property === "languages") { + return { inlayHint: { on: vi.fn() } } + } + + if (property === "console") { + return { error: connectionState.error, warn: connectionState.warn } + } + + if (property === "workspace") { + return { applyEdit: connectionState.applyEdit } + } + + if (property === "window") { + return { showDocument: connectionState.showDocument } + } + return vi.fn() }, }, @@ -39,13 +60,17 @@ import { ON_TYPE_FORMATTING_OPTIONS } from "../src/on_type_formatting" import { Server } from "../src/server" describe("on-type formatting", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + it("advertises > as the trigger character", () => { expect(ON_TYPE_FORMATTING_OPTIONS).toEqual({ firstTriggerCharacter: ">", }) }) - it("routes requests through the provider stored in Session", () => { + it("routes requests through the provider stored in Session", async () => { const source = "<% if user.admin? %>" const document = TextDocument.create( "file:///test.html.erb", @@ -53,13 +78,17 @@ describe("on-type formatting", () => { 1, source, ) - const getTextEdits = vi.fn().mockReturnValue([{ newText: "expected" }]) + const getFormatting = vi.fn().mockReturnValue({ + edits: [{ newText: "expected" }], + cursor: null, + }) const server = new Server() Object.assign(server, { session: { + capabilities: { hasApplyEdit: false, hasShowDocument: false }, documents: { get: vi.fn().mockReturnValue(document) }, - onTypeFormattingProvider: { getTextEdits }, + onTypeFormattingProvider: { getFormatting }, }, }) @@ -67,15 +96,143 @@ describe("on-type formatting", () => { textDocument: { uri: document.uri }, position: { line: 0, character: source.length }, ch: ">", + options: { tabSize: 4, insertSpaces: true }, } - expect(connectionState.onTypeFormatting?.(params)).toEqual([ + await expect(connectionState.onTypeFormatting?.(params)).resolves.toEqual([ { newText: "expected" }, ]) - expect(getTextEdits).toHaveBeenCalledWith( + expect(getFormatting).toHaveBeenCalledWith( document, params.position, params.ch, + params.options, ) }) + + it("applies the edit before moving the cursor to the block body", async () => { + const source = "<% if user.admin? %>" + const document = TextDocument.create( + "file:///test.html.erb", + "erb", + 1, + source, + ) + const edit = { + range: { + start: { line: 0, character: source.length }, + end: { line: 0, character: source.length }, + }, + newText: "\n \n<% end %>", + } + const cursor = { line: 1, character: 2 } + const server = new Server() + + connectionState.applyEdit.mockResolvedValue({ applied: true }) + connectionState.showDocument.mockResolvedValue({ success: true }) + + Object.assign(server, { + session: { + capabilities: { hasApplyEdit: true, hasShowDocument: true }, + documents: { get: vi.fn().mockReturnValue(document) }, + onTypeFormattingProvider: { + getFormatting: vi.fn().mockReturnValue({ edits: [edit], cursor }), + }, + }, + }) + + const result = await connectionState.onTypeFormatting?.({ + textDocument: { uri: document.uri }, + position: { line: 0, character: source.length }, + ch: ">", + options: { tabSize: 2, insertSpaces: true }, + }) + + expect(connectionState.applyEdit).toHaveBeenCalledWith({ + changes: { [document.uri]: [edit] }, + }) + expect(connectionState.showDocument).toHaveBeenCalledWith({ + uri: document.uri, + takeFocus: true, + selection: { start: cursor, end: cursor }, + }) + expect(connectionState.applyEdit.mock.invocationCallOrder[0]).toBeLessThan( + connectionState.showDocument.mock.invocationCallOrder[0], + ) + expect(result).toEqual([]) + }) + + it("returns the edit when the client does not apply it", async () => { + const source = "<% if user.admin? %>" + const document = TextDocument.create( + "file:///test.html.erb", + "erb", + 1, + source, + ) + const edit = { newText: "\n \n<% end %>" } + const server = new Server() + + connectionState.applyEdit.mockResolvedValue({ applied: false }) + + Object.assign(server, { + session: { + capabilities: { hasApplyEdit: true, hasShowDocument: true }, + documents: { get: vi.fn().mockReturnValue(document) }, + onTypeFormattingProvider: { + getFormatting: vi.fn().mockReturnValue({ + edits: [edit], + cursor: { line: 1, character: 2 }, + }), + }, + }, + }) + + const result = await connectionState.onTypeFormatting?.({ + textDocument: { uri: document.uri }, + position: { line: 0, character: source.length }, + ch: ">", + options: { tabSize: 2, insertSpaces: true }, + }) + + expect(result).toEqual([edit]) + expect(connectionState.showDocument).not.toHaveBeenCalled() + }) + + it("keeps the applied edit when moving the cursor fails", async () => { + const source = "<% if user.admin? %>" + const document = TextDocument.create( + "file:///test.html.erb", + "erb", + 1, + source, + ) + const server = new Server() + + connectionState.applyEdit.mockResolvedValue({ applied: true }) + connectionState.showDocument.mockRejectedValue(new Error("client failure")) + + Object.assign(server, { + session: { + capabilities: { hasApplyEdit: true, hasShowDocument: true }, + documents: { get: vi.fn().mockReturnValue(document) }, + onTypeFormattingProvider: { + getFormatting: vi.fn().mockReturnValue({ + edits: [{ newText: "\n \n<% end %>" }], + cursor: { line: 1, character: 2 }, + }), + }, + }, + }) + + const request = connectionState.onTypeFormatting?.({ + textDocument: { uri: document.uri }, + position: { line: 0, character: source.length }, + ch: ">", + options: { tabSize: 2, insertSpaces: true }, + }) + + await expect(request).resolves.toEqual([]) + expect(connectionState.error).toHaveBeenCalled() + }) }) diff --git a/javascript/packages/language-service/src/on_type_formatting_provider.ts b/javascript/packages/language-service/src/on_type_formatting_provider.ts index d69b5a595..2f797c9e2 100644 --- a/javascript/packages/language-service/src/on_type_formatting_provider.ts +++ b/javascript/packages/language-service/src/on_type_formatting_provider.ts @@ -1,9 +1,14 @@ import { TextEdit } from "vscode-languageserver-types" -import type { Position } from "vscode-languageserver-types" +import type { FormattingOptions, Position } from "vscode-languageserver-types" import type { TextDocument } from "vscode-languageserver-textdocument" import type { ParserService } from "./parser_service" +interface OnTypeFormattingResult { + edits: TextEdit[] + cursor: Position | null +} + export class OnTypeFormattingProvider { private readonly parserService: ParserService @@ -15,8 +20,18 @@ export class OnTypeFormattingProvider { document: TextDocument, position: Position, character: string, + options: FormattingOptions = { tabSize: 2, insertSpaces: true }, ): TextEdit[] { - if (character !== ">") return [] + return this.getFormatting(document, position, character, options).edits + } + + getFormatting( + document: TextDocument, + position: Position, + character: string, + options: FormattingOptions = { tabSize: 2, insertSpaces: true }, + ): OnTypeFormattingResult { + if (character !== ">") return { edits: [], cursor: null } const offset = document.offsetAt(position) const source = document.getText() @@ -24,19 +39,34 @@ export class OnTypeFormattingProvider { const line = source.slice(lineStart, offset) const tagStart = line.lastIndexOf("<%") - if (tagStart === -1) return [] + if (tagStart === -1) return { edits: [], cursor: null } const tag = line.slice(tagStart) - if (!tag.endsWith("%>")) return [] + if (!tag.endsWith("%>")) return { edits: [], cursor: null } const sourceWithoutTag = source.slice(0, lineStart + tagStart) + source.slice(offset) if (this.missingEnds(source) <= this.missingEnds(sourceWithoutTag)) - return [] + return { edits: [], cursor: null } const indentation = line.match(/^\s*/)?.[0] ?? "" + const indentationUnit = options.insertSpaces + ? " ".repeat(options.tabSize) + : "\t" + const bodyIndentation = indentation + indentationUnit - return [TextEdit.insert(position, `\n${indentation}<% end %>`)] + return { + edits: [ + TextEdit.insert( + position, + `\n${bodyIndentation}\n${indentation}<% end %>`, + ), + ], + cursor: { + line: position.line + 1, + character: bodyIndentation.length, + }, + } } private missingEnds(source: string): number { diff --git a/javascript/packages/language-service/test/on_type_formatting_provider.test.ts b/javascript/packages/language-service/test/on_type_formatting_provider.test.ts index aaef040ea..fe7481739 100644 --- a/javascript/packages/language-service/test/on_type_formatting_provider.test.ts +++ b/javascript/packages/language-service/test/on_type_formatting_provider.test.ts @@ -30,11 +30,23 @@ describe("OnTypeFormattingProvider", () => { start: { line: 0, character: source.length }, end: { line: 0, character: source.length }, }, - newText: "\n<% end %>", + newText: "\n \n<% end %>", }, ]) }) + it("places the cursor on the indented block body line", () => { + const source = "<% if user.admin? %>" + const document = createDocument(source) + + expect( + provider.getFormatting(document, Position.create(0, source.length), ">", { + tabSize: 2, + insertSpaces: true, + }).cursor, + ).toEqual({ line: 1, character: 2 }) + }) + it.each([ "<% if user.admin? %>", "<% unless items.empty? %>", @@ -62,7 +74,27 @@ describe("OnTypeFormattingProvider", () => { start: { line: 0, character: source.length }, end: { line: 0, character: source.length }, }, - newText: "\n <% end %>", + newText: "\n \n <% end %>", + }, + ]) + }) + + it("uses the editor indentation options for the block body", () => { + const source = "\t<% if user.admin? %>" + const document = createDocument(source) + + expect( + provider.getTextEdits(document, Position.create(0, source.length), ">", { + tabSize: 4, + insertSpaces: false, + }), + ).toEqual([ + { + range: { + start: { line: 0, character: source.length }, + end: { line: 0, character: source.length }, + }, + newText: "\n\t\t\n\t<% end %>", }, ]) }) @@ -77,7 +109,7 @@ describe("OnTypeFormattingProvider", () => { Position.create(1, openingTag.length), ">", )?.[0].newText, - ).toBe("\n <% end %>") + ).toBe("\n \n <% end %>") }) it.each([ @@ -157,7 +189,7 @@ describe("OnTypeFormattingProvider", () => { start: { line: 1, character: openingTag.length }, end: { line: 1, character: openingTag.length }, }, - newText: "\n <% end %>", + newText: "\n \n <% end %>", }, ]) }) @@ -190,7 +222,7 @@ describe("OnTypeFormattingProvider", () => { start: { line: 4, character: openingTag.length }, end: { line: 4, character: openingTag.length }, }, - newText: "\n <% end %>", + newText: "\n \n <% end %>", }, ]) })