Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 165 additions & 0 deletions javascript/packages/language-server/src/rewrite_code_action_service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { CodeAction, CodeActionKind, TextEdit, WorkspaceEdit, Range } from "vscode-languageserver/node"
import { TextDocument } from "vscode-languageserver-textdocument"

import { Visitor, Herb } from "@herb-tools/node-wasm"
import { IdentityPrinter } from "@herb-tools/printer"
import { ActionViewTagHelperToHTMLRewriter, HTMLToActionViewTagHelperRewriter } from "@herb-tools/rewriter"
import { isERBOpenTagNode, isHTMLOpenTagNode } from "@herb-tools/core"
import { ParserService } from "./parser_service"
import { nodeToRange } from "./range_utils"

import type { Node, HTMLElementNode } from "@herb-tools/core"

interface CollectedElement {
node: HTMLElementNode
range: Range
}

class ElementCollector extends Visitor {
public actionViewElements: CollectedElement[] = []
public htmlElements: CollectedElement[] = []

visitHTMLElementNode(node: HTMLElementNode): void {
if (node.element_source && node.element_source !== "HTML" && isERBOpenTagNode(node.open_tag)) {
this.actionViewElements.push({
node,
range: nodeToRange(node),
})
} else if (isHTMLOpenTagNode(node.open_tag) && node.open_tag.tag_name) {
this.htmlElements.push({
node,
range: nodeToRange(node),
})
}

this.visitChildNodes(node)
}
}

export class RewriteCodeActionService {
private parserService: ParserService

constructor(parserService: ParserService) {
this.parserService = parserService
}

getCodeActions(document: TextDocument, requestedRange: Range): CodeAction[] {
const parseResult = this.parserService.parseContent(document.getText(), {
action_view_helpers: true,
track_whitespace: true,
})

const collector = new ElementCollector()
collector.visit(parseResult.value)

const actions: CodeAction[] = []

for (const element of collector.actionViewElements) {
if (!this.rangesOverlap(element.range, requestedRange)) continue

const action = this.createActionViewToHTMLAction(document, element)

if (action) {
actions.push(action)
}
}

for (const element of collector.htmlElements) {
if (!this.rangesOverlap(element.range, requestedRange)) continue

const action = this.createHTMLToActionViewAction(document, element)

if (action) {
actions.push(action)
}
}

return actions
}

private createActionViewToHTMLAction(document: TextDocument, element: CollectedElement): CodeAction | null {
const originalText = document.getText(element.range)

const parseResult = this.parserService.parseContent(originalText, {
action_view_helpers: true,
track_whitespace: true,
})

if (parseResult.failed) return null

const rewriter = new ActionViewTagHelperToHTMLRewriter()
rewriter.rewrite(parseResult.value as Node, { baseDir: process.cwd() })

const rewrittenText = IdentityPrinter.print(parseResult.value)

if (rewrittenText === originalText) return null

const edit: WorkspaceEdit = {
changes: {
[document.uri]: [TextEdit.replace(element.range, rewrittenText)]
}
}

const tagName = element.node.tag_name?.value
const title = tagName
? `Herb: Convert to \`<${tagName}>\``
: "Herb: Convert to HTML"

return {
title,
kind: CodeActionKind.RefactorRewrite,
edit,
}
}

private createHTMLToActionViewAction(document: TextDocument, element: CollectedElement): CodeAction | null {
const originalText = document.getText(element.range)

const parseResult = this.parserService.parseContent(originalText, {
track_whitespace: true,
})

if (parseResult.failed) return null

const rewriter = new HTMLToActionViewTagHelperRewriter()
rewriter.rewrite(parseResult.value as Node, { baseDir: process.cwd() })

const rewrittenText = IdentityPrinter.print(parseResult.value)

if (rewrittenText === originalText) return null

const edit: WorkspaceEdit = {
changes: {
[document.uri]: [TextEdit.replace(element.range, rewrittenText)]
}
}

const tagName = element.node.tag_name?.value
const isAnchor = tagName === "a"
const isTurboFrame = tagName === "turbo-frame"
const methodName = tagName?.replace(/-/g, "_")
const title = isAnchor
? "Herb: Convert to `link_to`"
: isTurboFrame
? "Herb: Convert to `turbo_frame_tag`"
: methodName
? `Herb: Convert to \`tag.${methodName}\``
: "Herb: Convert to tag helper"

return {
title,
kind: CodeActionKind.RefactorRewrite,
edit,
}
}

private rangesOverlap(r1: Range, r2: Range): boolean {
if (r1.end.line < r2.start.line) return false
if (r1.start.line > r2.end.line) return false

if (r1.end.line === r2.start.line && r1.end.character < r2.start.character) return false
if (r1.start.line === r2.end.line && r1.start.character > r2.end.character) return false

return true
}
}
5 changes: 3 additions & 2 deletions javascript/packages/language-server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export class Server {
documentFormattingProvider: true,
documentRangeFormattingProvider: true,
codeActionProvider: {
codeActionKinds: [CodeActionKind.QuickFix, CodeActionKind.SourceFixAll]
codeActionKinds: [CodeActionKind.QuickFix, CodeActionKind.SourceFixAll, CodeActionKind.RefactorRewrite]
},
foldingRangeProvider: true,
documentHighlightProvider: true,
Expand Down Expand Up @@ -196,8 +196,9 @@ export class Server {
)

const autofixCodeActions = this.service.codeActionService.autofixCodeActions(params, document)
const rewriteCodeActions = this.service.rewriteCodeActionService.getCodeActions(document, params.range)

return autofixCodeActions.concat(linterDisableCodeActions)
return autofixCodeActions.concat(linterDisableCodeActions).concat(rewriteCodeActions)
})

this.connection.onFoldingRanges((params: FoldingRangeParams) => {
Expand Down
3 changes: 3 additions & 0 deletions javascript/packages/language-server/src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { DocumentSaveService } from "./document_save_service"
import { FoldingRangeService } from "./folding_range_service"
import { DocumentHighlightService } from "./document_highlight_service"
import { HoverService } from "./hover_service"
import { RewriteCodeActionService } from "./rewrite_code_action_service"
import { CommentService } from "./comment_service"

import { version } from "../package.json"
Expand All @@ -37,6 +38,7 @@ export class Service {
foldingRangeService: FoldingRangeService
documentHighlightService: DocumentHighlightService
hoverService: HoverService
rewriteCodeActionService: RewriteCodeActionService
commentService: CommentService

constructor(connection: Connection, params: InitializeParams) {
Expand All @@ -55,6 +57,7 @@ export class Service {
this.foldingRangeService = new FoldingRangeService(this.parserService)
this.documentHighlightService = new DocumentHighlightService(this.parserService)
this.hoverService = new HoverService(this.parserService)
this.rewriteCodeActionService = new RewriteCodeActionService(this.parserService)
this.commentService = new CommentService(this.parserService)

if (params.initializationOptions) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import dedent from "dedent"

import { describe, it, expect, beforeAll } from "vitest"
import { Range, CodeActionKind } from "vscode-languageserver/node"
import { TextDocument } from "vscode-languageserver-textdocument"

import { RewriteCodeActionService } from "../src/rewrite_code_action_service"
import { ParserService } from "../src/parser_service"
import { Herb } from "@herb-tools/node-wasm"

describe("RewriteCodeActionService", () => {
let parserService: ParserService
let service: RewriteCodeActionService

beforeAll(async () => {
await Herb.load()
parserService = new ParserService()
service = new RewriteCodeActionService(parserService)
})

function createDocument(content: string): TextDocument {
return TextDocument.create("file:///test.html.erb", "erb", 1, content)
}

function getCodeActions(content: string, startLine: number, startChar: number, endLine: number, endChar: number) {
const document = createDocument(content)
const range = Range.create(startLine, startChar, endLine, endChar)
return service.getCodeActions(document, range)
}

describe("ActionView to HTML", () => {
it("offers convert to HTML for tag.div", () => {
const content = dedent`
<%= tag.div do %>
Content
<% end %>
`

const actions = getCodeActions(content, 0, 0, 2, 8)

const convertAction = actions.find(a => a.title.includes("Convert to"))
expect(convertAction).toBeDefined()
expect(convertAction!.title).toBe("Herb: Convert to `<div>`")
expect(convertAction!.kind).toBe(CodeActionKind.RefactorRewrite)
})

it("includes a text edit that replaces with HTML", () => {
const content = dedent`
<%= tag.div do %>
Content
<% end %>
`

const actions = getCodeActions(content, 0, 0, 2, 8)

const convertAction = actions.find(a => a.title.includes("<div>"))
expect(convertAction).toBeDefined()
expect(convertAction!.edit).toBeDefined()

const changes = convertAction!.edit!.changes!["file:///test.html.erb"]
expect(changes).toHaveLength(1)
expect(changes[0].newText).toContain("<div>")
expect(changes[0].newText).toContain("</div>")
})

it("offers convert for tag.span", () => {
const content = '<%= tag.span "text" %>'

const actions = getCodeActions(content, 0, 0, 0, content.length)

const convertAction = actions.find(a => a.title.includes("<span>"))
expect(convertAction).toBeDefined()
expect(convertAction!.title).toBe("Herb: Convert to `<span>`")
})

it("offers convert for tag with attributes", () => {
const content = '<%= tag.div class: "container" do %><% end %>'

const actions = getCodeActions(content, 0, 0, 0, content.length)

const convertAction = actions.find(a => a.title.includes("<div>"))
expect(convertAction).toBeDefined()

const changes = convertAction!.edit!.changes!["file:///test.html.erb"]
expect(changes[0].newText).toContain("container")
})
})

describe("HTML to ActionView", () => {
it("offers convert to tag helper for div", () => {
const content = "<div>hello</div>"

const actions = getCodeActions(content, 0, 0, 0, content.length)

const convertAction = actions.find(a => a.title.includes("tag.div"))
expect(convertAction).toBeDefined()
expect(convertAction!.title).toBe("Herb: Convert to `tag.div`")
expect(convertAction!.kind).toBe(CodeActionKind.RefactorRewrite)
})

it("offers convert to link_to for anchor tags", () => {
const content = '<a href="/home">Home</a>'

const actions = getCodeActions(content, 0, 0, 0, content.length)

const convertAction = actions.find(a => a.title.includes("link_to"))
expect(convertAction).toBeDefined()
expect(convertAction!.title).toBe("Herb: Convert to `link_to`")
})

it("includes a text edit that replaces with tag helper", () => {
const content = "<div>hello</div>"

const actions = getCodeActions(content, 0, 0, 0, content.length)

const convertAction = actions.find(a => a.title.includes("tag.div"))
expect(convertAction).toBeDefined()

const changes = convertAction!.edit!.changes!["file:///test.html.erb"]
expect(changes).toHaveLength(1)
expect(changes[0].newText).toContain("tag.div")
})

it("offers convert for span with attributes", () => {
const content = '<span class="highlight">text</span>'

const actions = getCodeActions(content, 0, 0, 0, content.length)

const convertAction = actions.find(a => a.title.includes("tag.span"))
expect(convertAction).toBeDefined()
})
})

describe("no actions", () => {
it("returns no rewrite actions for plain text", () => {
const content = "just some text"

const actions = getCodeActions(content, 0, 0, 0, content.length)

const rewriteActions = actions.filter(a => a.kind === CodeActionKind.RefactorRewrite)
expect(rewriteActions).toHaveLength(0)
})

it("returns no rewrite actions for regular ERB", () => {
const content = "<%= some_method %>"

const actions = getCodeActions(content, 0, 0, 0, content.length)

const rewriteActions = actions.filter(a => a.kind === CodeActionKind.RefactorRewrite)
expect(rewriteActions).toHaveLength(0)
})

it("does not offer actions when cursor is outside element range", () => {
const content = dedent`
<p>before</p>
<%= tag.div do %>
Content
<% end %>
<p>after</p>
`

const actions = getCodeActions(content, 0, 0, 0, 14)

const divAction = actions.find(a => a.title.includes("<div>"))
expect(divAction).toBeUndefined()
})
})
})
Loading
Loading