diff --git a/javascript/packages/language-server/src/folding_range_service.ts b/javascript/packages/language-server/src/folding_range_service.ts new file mode 100644 index 00000000..f7357d57 --- /dev/null +++ b/javascript/packages/language-server/src/folding_range_service.ts @@ -0,0 +1,138 @@ +import { FoldingRange, FoldingRangeKind } from "vscode-languageserver/node" +import { Visitor } from "@herb-tools/core" + +import type { + Node, + HTMLElementNode, + HTMLOpenTagNode, + HTMLAttributeValueNode, + HTMLCommentNode, + ERBIfNode, + ERBElseNode, + ERBUnlessNode, + ERBBlockNode, + ERBWhileNode, + ERBUntilNode, + ERBForNode, + ERBBeginNode, + DocumentNode, +} from "@herb-tools/core" + +export class FoldingRangeService { + getFoldingRanges(document: DocumentNode): FoldingRange[] { + const collector = new FoldingRangeCollector() + + collector.visit(document) + + return collector.ranges + } +} + +/** + * Visitor that collects foldable ranges from the AST + */ +class FoldingRangeCollector extends Visitor { + ranges: FoldingRange[] = [] + + visitHTMLElementNode(node: HTMLElementNode): void { + this.addRangeForNode(node, node.body) + this.visitChildNodes(node) + } + + visitHTMLOpenTagNode(node: HTMLOpenTagNode): void { + this.addRangeForNode(node, node.children) + this.visitChildNodes(node) + } + + visitHTMLCommentNode(node: HTMLCommentNode): void { + const startLine = this.toZeroBased(node.location.start.line) + const endLine = this.toZeroBased(node.location.end.line) + + this.addRange(startLine, endLine, FoldingRangeKind.Comment) + + this.visitChildNodes(node) + } + + visitHTMLAttributeValueNode(node: HTMLAttributeValueNode): void { + this.addRangeForNode(node, node.children) + this.visitChildNodes(node) + } + + visitERBIfNode(node: ERBIfNode): void { + const startLine = this.toZeroBased(node.location.start.line) + const endLine = this.toZeroBased(node.location.end.line) + + this.addRange(startLine, endLine) + + if (node.statements.length > 0) { + const firstStatement = node.statements[0] + const lastStatement = node.statements[node.statements.length - 1] + + const startLine = this.toZeroBased(firstStatement.location.start.line) + const endLine = this.toZeroBased(lastStatement.location.end.line) + + this.addRange(startLine, endLine) + } + + this.visitChildNodes(node) + } + + visitERBElseNode(node: ERBElseNode): void { + this.addRangeForNode(node, node.statements) + this.visitChildNodes(node) + } + + visitERBUnlessNode(node: ERBUnlessNode): void { + this.addRangeForNode(node, node.statements) + this.visitChildNodes(node) + } + + visitERBBlockNode(node: ERBBlockNode): void { + this.addRangeForNode(node, node.body) + this.visitChildNodes(node) + } + + visitERBWhileNode(node: ERBWhileNode): void { + this.addRangeForNode(node, node.statements) + this.visitChildNodes(node) + } + + visitERBUntilNode(node: ERBUntilNode): void { + this.addRangeForNode(node, node.statements) + this.visitChildNodes(node) + } + + visitERBForNode(node: ERBForNode): void { + this.addRangeForNode(node, node.statements) + this.visitChildNodes(node) + } + + visitERBBeginNode(node: ERBBeginNode): void { + this.addRangeForNode(node, node.statements) + this.visitChildNodes(node) + } + + // TODO: consider adding `startCharacter` and `endCharacter` + private addRange(startLine: number, endLine: number, kind?: FoldingRangeKind): void { + if (endLine > startLine) { + this.ranges.push({ + startLine, + endLine, + kind, + }) + } + } + + private addRangeForNode(node: Node, children: Node[]) { + if (children.length > 0) { + const startLine = this.toZeroBased(node.location.start.line) + const endLine = this.toZeroBased(node.location.end.line) + + this.addRange(startLine, endLine) + } + } + + private toZeroBased(line: number): number { + return line - 1 + } +} diff --git a/javascript/packages/language-server/src/index.ts b/javascript/packages/language-server/src/index.ts index fed2e73d..07224706 100644 --- a/javascript/packages/language-server/src/index.ts +++ b/javascript/packages/language-server/src/index.ts @@ -3,6 +3,7 @@ export * from "./service" export * from "./diagnostics" export * from "./document_service" export * from "./formatting_service" +export * from "./folding_range_service" export * from "./project" export * from "./settings" export * from "./utils" diff --git a/javascript/packages/language-server/src/server.ts b/javascript/packages/language-server/src/server.ts index 93446fbc..c5de3080 100644 --- a/javascript/packages/language-server/src/server.ts +++ b/javascript/packages/language-server/src/server.ts @@ -12,6 +12,8 @@ import { DocumentRangeFormattingParams, CodeActionParams, CodeActionKind, + TextEdit, + FoldingRangeParams, } from "vscode-languageserver/node" import { Service } from "./service" @@ -53,6 +55,7 @@ export class Server { codeActionProvider: { codeActionKinds: [CodeActionKind.QuickFix, CodeActionKind.SourceFixAll] }, + foldingRangeProvider: true, }, } @@ -175,6 +178,16 @@ export class Server { return autofixCodeActions.concat(linterDisableCodeActions) }) + + this.connection.onFoldingRanges((params: FoldingRangeParams) => { + const document = this.service.documentService.get(params.textDocument.uri) + + if (!document) return [] + + const parseResult = this.service.parserService.parseDocument(document) + + return this.service.foldingRangeService.getFoldingRanges(parseResult.document) + }) } listen() { diff --git a/javascript/packages/language-server/src/service.ts b/javascript/packages/language-server/src/service.ts index 27679058..ee33a04d 100644 --- a/javascript/packages/language-server/src/service.ts +++ b/javascript/packages/language-server/src/service.ts @@ -12,6 +12,7 @@ import { ConfigService } from "./config_service" import { AutofixService } from "./autofix_service" import { CodeActionService } from "./code_action_service" import { DocumentSaveService } from "./document_save_service" +import { FoldingRangeService } from "./folding_range_service" import { version } from "../package.json" @@ -30,6 +31,7 @@ export class Service { configService: ConfigService codeActionService: CodeActionService documentSaveService: DocumentSaveService + foldingRangeService: FoldingRangeService constructor(connection: Connection, params: InitializeParams) { this.connection = connection @@ -44,6 +46,7 @@ export class Service { this.codeActionService = new CodeActionService(this.project, this.config) this.diagnostics = new Diagnostics(this.connection, this.documentService, this.parserService, this.linterService, this.configService) this.documentSaveService = new DocumentSaveService(this.connection, this.settings, this.autofixService, this.formattingService) + this.foldingRangeService = new FoldingRangeService() if (params.initializationOptions) { this.settings.globalSettings = params.initializationOptions as PersonalHerbSettings diff --git a/javascript/packages/language-server/test/folding_range_service.test.ts b/javascript/packages/language-server/test/folding_range_service.test.ts new file mode 100644 index 00000000..49c5b0cb --- /dev/null +++ b/javascript/packages/language-server/test/folding_range_service.test.ts @@ -0,0 +1,412 @@ +import dedent from "dedent" + +import { describe, it, expect, beforeAll } from "vitest" +import { FoldingRangeKind } from "vscode-languageserver/node" + +import { FoldingRangeService } from "../src/folding_range_service" +import { Herb } from "@herb-tools/node-wasm" + +describe("FoldingRangeService", () => { + let service: FoldingRangeService + + beforeAll(async () => { + await Herb.load() + service = new FoldingRangeService() + }) + + describe("HTML elements", () => { + it("creates folding ranges for multi-line HTML elements", () => { + const content = dedent` +
+

+ Hello +

+
+ ` + + const parseResult = Herb.parse(content) + const ranges = service.getFoldingRanges(parseResult.value) + + expect(ranges.map(range => [range.startLine, range.endLine])).toEqual([ + [0, 4], + [1, 3] + ]) + }) + + it("does not create folding ranges for void elements", () => { + const content = dedent` +
+
+ +
+ ` + + const parseResult = Herb.parse(content) + const ranges = service.getFoldingRanges(parseResult.value) + + expect(ranges.map(range => [range.startLine, range.endLine])).toEqual([ + [0, 3] + ]) + }) + + it("handles nested HTML elements", () => { + const content = dedent` +
+ +
+ ` + + const parseResult = Herb.parse(content) + const ranges = service.getFoldingRanges(parseResult.value) + + expect(ranges.map(range => [range.startLine, range.endLine])).toEqual([ + [0, 9], + [1, 8], + [2, 4], + [5, 7] + ]) + }) + }) + + describe("HTML comments", () => { + it("creates folding ranges for multi-line comments", () => { + const content = dedent` + +
Content
+ ` + + const parseResult = Herb.parse(content) + const ranges = service.getFoldingRanges(parseResult.value) + + expect(ranges.map(range => [range.startLine, range.endLine, range.kind])).toEqual([ + [0, 2, FoldingRangeKind.Comment] + ]) + }) + + it("does not create folding ranges for single-line comments", () => { + const content = dedent` + +
Content
+ ` + + const parseResult = Herb.parse(content) + const ranges = service.getFoldingRanges(parseResult.value) + + expect(ranges).toEqual([]) + }) + }) + + describe("ERB control flow", () => { + it("creates folding ranges for if blocks", () => { + const content = dedent` + <% if condition %> +

True

+ <% end %> + ` + + const parseResult = Herb.parse(content) + const ranges = service.getFoldingRanges(parseResult.value) + + expect(ranges.map(range => [range.startLine, range.endLine])).toEqual([ + [0, 2], + [0, 2] + ]) + }) + + it("creates folding ranges for if/else blocks", () => { + const content = dedent` + <% if condition %> +

True

+ <% else %> +

False

+ <% end %> + ` + + const parseResult = Herb.parse(content) + const ranges = service.getFoldingRanges(parseResult.value) + + expect(ranges.map(range => [range.startLine, range.endLine])).toEqual([ + [0, 4], + [0, 2], + [2, 4], + ]) + }) + + it("creates folding ranges for if/elsif/else blocks", () => { + const content = dedent` + <% if condition1 %> +

First

+ <% elsif condition2 %> +

Second

+ <% else %> +

Third

+ <% end %> + ` + + const parseResult = Herb.parse(content) + const ranges = service.getFoldingRanges(parseResult.value) + + expect(ranges.length).toBeGreaterThanOrEqual(3) + }) + + it("creates folding ranges for unless blocks", () => { + const content = dedent` + <% unless condition %> +

False

+ <% end %> + ` + + const parseResult = Herb.parse(content) + const ranges = service.getFoldingRanges(parseResult.value) + + expect(ranges.map(range => [range.startLine, range.endLine])).toEqual([ + [0, 2] + ]) + }) + + it("creates folding ranges for each blocks", () => { + const content = dedent` + <% items.each do |item| %> +
  • <%= item %>
  • + <% end %> + ` + + const parseResult = Herb.parse(content) + const ranges = service.getFoldingRanges(parseResult.value) + + expect(ranges.map(range => [range.startLine, range.endLine])).toEqual([ + [0, 2] + ]) + }) + + it("creates folding ranges for while blocks", () => { + const content = dedent` + <% while condition %> +

    Loop

    + <% end %> + ` + + const parseResult = Herb.parse(content) + const ranges = service.getFoldingRanges(parseResult.value) + + expect(ranges.map(range => [range.startLine, range.endLine])).toEqual([ + [0, 2] + ]) + }) + + it("creates folding ranges for for blocks", () => { + const content = dedent` + <% for i in 1..10 %> +

    <%= i %>

    + <% end %> + ` + + const parseResult = Herb.parse(content) + const ranges = service.getFoldingRanges(parseResult.value) + + expect(ranges.map(range => [range.startLine, range.endLine])).toEqual([ + [0, 2] + ]) + }) + + it("creates folding ranges for begin/rescue blocks", () => { + const content = dedent` + <% begin %> + <%= risky_operation %> + <% rescue %> +

    Error

    + <% end %> + ` + + const parseResult = Herb.parse(content) + const ranges = service.getFoldingRanges(parseResult.value) + + expect(ranges.map(range => [range.startLine, range.endLine])).toEqual([ + [0, 4] + ]) + }) + }) + + describe("complex nesting", () => { + it("handles nested ERB and HTML correctly", () => { + const content = dedent` +
    + <% if user.logged_in? %> + + <% else %> +

    Please log in

    + <% end %> +
    + ` + + const parseResult = Herb.parse(content) + const ranges = service.getFoldingRanges(parseResult.value) + + expect(ranges.map(range => [range.startLine, range.endLine])).toEqual([ + [0, 12], + [1, 11], + [1, 9], + [2, 8], + [3, 7], + [4, 6], + [9, 11] + ]) + }) + }) + + describe("multi-line attributes", () => { + it("creates folding ranges for elements with multi-line attributes", () => { + const content = dedent` +
    + Content +
    + ` + + const parseResult = Herb.parse(content) + const ranges = service.getFoldingRanges(parseResult.value) + + expect(ranges.map(range => [range.startLine, range.endLine])).toEqual([ + [0, 5], + [0, 3] + ]) + }) + }) + + describe("edge cases", () => { + it("handles empty document", () => { + const content = "" + + const parseResult = Herb.parse(content) + const ranges = service.getFoldingRanges(parseResult.value) + + expect(ranges).toEqual([]) + }) + + it("handles document with only text", () => { + const content = "Just plain text" + + const parseResult = Herb.parse(content) + const ranges = service.getFoldingRanges(parseResult.value) + + expect(ranges).toEqual([]) + }) + + it("does not create ranges for single-line elements", () => { + const content = "
    Single line
    " + + const parseResult = Herb.parse(content) + const ranges = service.getFoldingRanges(parseResult.value) + + expect(ranges).toEqual([]) + }) + }) + + describe("whole document", () => { + it("handles whole document", () => { + const content = dedent` + + + + Folding Ranges Test + + + +
    + <% if user.logged_in? %> +

    Welcome, <%= user.name %>!

    + + <% else %> +

    Please log in

    + <% end %> + + <% unless user.admin? %> +

    You are not an admin

    + <% end %> + + <% case user.role %> + <% when "admin" %> +
    +

    Admin Panel

    +
    + <% when "moderator" %> +
    +

    Moderator Panel

    +
    + <% else %> +
    +

    User Panel

    +
    + <% end %> + + <% begin %> + <%= risky_operation %> + <% rescue StandardError => e %> +

    Error: <%= e.message %>

    + <% ensure %> +

    Cleanup

    + <% end %> + + <% for i in 1..10 %> +
    <%= i %>
    + <% end %> + + <% while counter < 10 %> +

    <%= counter %>

    + <% end %> +
    + + + ` + + const parseResult = Herb.parse(content) + const ranges = service.getFoldingRanges(parseResult.value) + + expect(ranges.map(range => [range.startLine, range.endLine])).toEqual([ + [1, 59], + [2, 4], + [5, 58], + [6, 7], + [8, 57], + [9, 21], + [9, 19], + [11, 18], + [12, 17], + [13, 16], + [19, 21], + [23, 25], + [29, 31], + [33, 35], + [37, 39], + [42, 48], + [50, 52], + [54, 56], + ]) + }) + }) +})