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
65 changes: 64 additions & 1 deletion javascript/packages/language-server/src/diagnostics.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import { TextDocument } from "vscode-languageserver-textdocument"
import { Connection, Diagnostic } from "vscode-languageserver/node"
import { Connection, Diagnostic, DiagnosticSeverity, DiagnosticTag } from "vscode-languageserver/node"
import { Visitor } from "@herb-tools/core"

import type {
Node,
ERBCaseNode,
ERBCaseMatchNode,
HTMLTextNode,
} from "@herb-tools/core"

import { isHTMLTextNode } from "@herb-tools/core"

import { ParserService } from "./parser_service"
import { LinterService } from "./linter_service"
Expand Down Expand Up @@ -36,17 +46,25 @@ export class Diagnostics {
} else {
const parseResult = this.parserService.parseDocument(textDocument)
const lintResult = await this.linterService.lintDocument(textDocument)
const unreachableCodeDiagnostics = this.getUnreachableCodeDiagnostics(parseResult.document)

allDiagnostics = [
...parseResult.diagnostics,
...lintResult.diagnostics,
...unreachableCodeDiagnostics,
]
}

this.diagnostics.set(textDocument, allDiagnostics)
this.sendDiagnosticsFor(textDocument)
}

private getUnreachableCodeDiagnostics(document: Node): Diagnostic[] {
const collector = new UnreachableCodeCollector()
collector.visit(document)
return collector.diagnostics
}

async refreshDocument(document: TextDocument) {
await this.validate(document)
}
Expand All @@ -67,3 +85,48 @@ export class Diagnostics {
this.diagnostics.delete(textDocument)
}
}

export class UnreachableCodeCollector extends Visitor {
diagnostics: Diagnostic[] = []

visitERBCaseNode(node: ERBCaseNode): void {
this.checkUnreachableChildren(node.children)
this.visitChildNodes(node)
}

visitERBCaseMatchNode(node: ERBCaseMatchNode): void {
this.checkUnreachableChildren(node.children)
this.visitChildNodes(node)
}

private checkUnreachableChildren(children: Node[]): void {
for (const child of children) {
if (isHTMLTextNode(child) && child.content.trim() === "") {
continue
}

const diagnostic: Diagnostic = {
range: {
start: {
line: this.toZeroBased(child.location.start.line),
character: child.location.start.column
},
end: {
line: this.toZeroBased(child.location.end.line),
character: child.location.end.column
}
},
message: "Unreachable code: content between case and when/in is never executed",
severity: DiagnosticSeverity.Hint,
tags: [DiagnosticTag.Unnecessary],
source: "Herb Language Server"
}

this.diagnostics.push(diagnostic)
}
}

private toZeroBased(line: number): number {
return line - 1
}
}
130 changes: 130 additions & 0 deletions javascript/packages/language-server/test/unreachable_code.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import dedent from "dedent"

import { describe, it, expect, beforeAll } from "vitest"
import { DiagnosticSeverity, DiagnosticTag } from "vscode-languageserver/node"

import { UnreachableCodeCollector } from "../src/diagnostics"
import { Herb } from "@herb-tools/node-wasm"

describe("Unreachable Code Diagnostics", () => {
beforeAll(async () => {
await Herb.load()
})

describe("ERB case statements", () => {
it("detects unreachable code between case and when", () => {
const content = dedent`
<% case abc %>
something here that is not renderable
<% when String %>
actual string
<% end %>
`

const parseResult = Herb.parse(content)
const collector = new UnreachableCodeCollector()
collector.visit(parseResult.value)

expect(collector.diagnostics.length).toBeGreaterThan(0)

const diagnostic = collector.diagnostics[0]
expect(diagnostic.message).toContain("Unreachable code")
expect(diagnostic.severity).toBe(DiagnosticSeverity.Hint)
expect(diagnostic.tags).toContain(DiagnosticTag.Unnecessary)
expect(diagnostic.source).toBe("Herb Language Server")
})

it("detects unreachable code in case/in statements", () => {
const content = dedent`
<% case abc %>
<% in String %>
actual string
<% end %>
`

const parseResult = Herb.parse(content)
const collector = new UnreachableCodeCollector()
collector.visit(parseResult.value)

expect(collector.diagnostics.length).toBe(0)
})

it("detects unreachable HTML content between case and when", () => {
const content = dedent`
<% case status %>
<div>This will never render</div>
<p>Neither will this</p>
<% when "active" %>
<p>Active</p>
<% when "inactive" %>
<p>Inactive</p>
<% end %>
`

const parseResult = Herb.parse(content)
const collector = new UnreachableCodeCollector()
collector.visit(parseResult.value)

expect(collector.diagnostics.length).toBeGreaterThan(0)
})

it("does not report diagnostics for case without unreachable children", () => {
const content = dedent`
<% case status %>
<% when "active" %>
<p>Active</p>
<% when "inactive" %>
<p>Inactive</p>
<% else %>
<p>Unknown</p>
<% end %>
`

const parseResult = Herb.parse(content)
const collector = new UnreachableCodeCollector()
collector.visit(parseResult.value)

expect(collector.diagnostics.length).toBe(0)
})

it("detects unreachable code with mixed content", () => {
const content = dedent`
<% case type %>
Some text
<%= variable %>
<span>HTML</span>
<% when :foo %>
<p>Foo</p>
<% end %>
`

const parseResult = Herb.parse(content)
const collector = new UnreachableCodeCollector()
collector.visit(parseResult.value)

expect(collector.diagnostics.length).toBeGreaterThan(0)
})
})

describe("nested case statements", () => {
it("detects unreachable code in nested case statements", () => {
const content = dedent`
<% case outer %>
unreachable outer
<% when "a" %>
<% case inner %>
unreachable inner
<% when "b" %>
reachable
<% end %>
<% end %>
`

const parseResult = Herb.parse(content)
const collector = new UnreachableCodeCollector()
collector.visit(parseResult.value)

expect(collector.diagnostics.length).toBeGreaterThanOrEqual(2)
})
})
})
Loading