Skip to content

Commit b001fed

Browse files
authored
Language Server: Add Unnecessary diagnostic for <% case %> children (marcoroth#802)
This pull request adds an `Unnecessary` diagnostic for any `children` in the `ERBCaseNode` and `ERBCaseMatchNode`, since the engine will never render that content, so it shows up dimmed out in the editor. <img width="1680" height="620" alt="CleanShot 2025-11-07 at 17 05 11@2x" src="https://github.com/user-attachments/assets/866b64f4-826e-4e23-b45f-77ed1e43cda9" /> <img width="1680" height="620" alt="CleanShot 2025-11-07 at 17 05 04@2x" src="https://github.com/user-attachments/assets/1ea71980-0dd9-4c61-88a1-910eb98dc45f" />
1 parent 89534a6 commit b001fed

File tree

2 files changed

+194
-1
lines changed

2 files changed

+194
-1
lines changed

javascript/packages/language-server/src/diagnostics.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
import { TextDocument } from "vscode-languageserver-textdocument"
2-
import { Connection, Diagnostic } from "vscode-languageserver/node"
2+
import { Connection, Diagnostic, DiagnosticSeverity, DiagnosticTag } from "vscode-languageserver/node"
3+
import { Visitor } from "@herb-tools/core"
4+
5+
import type {
6+
Node,
7+
ERBCaseNode,
8+
ERBCaseMatchNode,
9+
HTMLTextNode,
10+
} from "@herb-tools/core"
11+
12+
import { isHTMLTextNode } from "@herb-tools/core"
313

414
import { ParserService } from "./parser_service"
515
import { LinterService } from "./linter_service"
@@ -36,17 +46,25 @@ export class Diagnostics {
3646
} else {
3747
const parseResult = this.parserService.parseDocument(textDocument)
3848
const lintResult = await this.linterService.lintDocument(textDocument)
49+
const unreachableCodeDiagnostics = this.getUnreachableCodeDiagnostics(parseResult.document)
3950

4051
allDiagnostics = [
4152
...parseResult.diagnostics,
4253
...lintResult.diagnostics,
54+
...unreachableCodeDiagnostics,
4355
]
4456
}
4557

4658
this.diagnostics.set(textDocument, allDiagnostics)
4759
this.sendDiagnosticsFor(textDocument)
4860
}
4961

62+
private getUnreachableCodeDiagnostics(document: Node): Diagnostic[] {
63+
const collector = new UnreachableCodeCollector()
64+
collector.visit(document)
65+
return collector.diagnostics
66+
}
67+
5068
async refreshDocument(document: TextDocument) {
5169
await this.validate(document)
5270
}
@@ -67,3 +85,48 @@ export class Diagnostics {
6785
this.diagnostics.delete(textDocument)
6886
}
6987
}
88+
89+
export class UnreachableCodeCollector extends Visitor {
90+
diagnostics: Diagnostic[] = []
91+
92+
visitERBCaseNode(node: ERBCaseNode): void {
93+
this.checkUnreachableChildren(node.children)
94+
this.visitChildNodes(node)
95+
}
96+
97+
visitERBCaseMatchNode(node: ERBCaseMatchNode): void {
98+
this.checkUnreachableChildren(node.children)
99+
this.visitChildNodes(node)
100+
}
101+
102+
private checkUnreachableChildren(children: Node[]): void {
103+
for (const child of children) {
104+
if (isHTMLTextNode(child) && child.content.trim() === "") {
105+
continue
106+
}
107+
108+
const diagnostic: Diagnostic = {
109+
range: {
110+
start: {
111+
line: this.toZeroBased(child.location.start.line),
112+
character: child.location.start.column
113+
},
114+
end: {
115+
line: this.toZeroBased(child.location.end.line),
116+
character: child.location.end.column
117+
}
118+
},
119+
message: "Unreachable code: content between case and when/in is never executed",
120+
severity: DiagnosticSeverity.Hint,
121+
tags: [DiagnosticTag.Unnecessary],
122+
source: "Herb Language Server"
123+
}
124+
125+
this.diagnostics.push(diagnostic)
126+
}
127+
}
128+
129+
private toZeroBased(line: number): number {
130+
return line - 1
131+
}
132+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import dedent from "dedent"
2+
3+
import { describe, it, expect, beforeAll } from "vitest"
4+
import { DiagnosticSeverity, DiagnosticTag } from "vscode-languageserver/node"
5+
6+
import { UnreachableCodeCollector } from "../src/diagnostics"
7+
import { Herb } from "@herb-tools/node-wasm"
8+
9+
describe("Unreachable Code Diagnostics", () => {
10+
beforeAll(async () => {
11+
await Herb.load()
12+
})
13+
14+
describe("ERB case statements", () => {
15+
it("detects unreachable code between case and when", () => {
16+
const content = dedent`
17+
<% case abc %>
18+
something here that is not renderable
19+
<% when String %>
20+
actual string
21+
<% end %>
22+
`
23+
24+
const parseResult = Herb.parse(content)
25+
const collector = new UnreachableCodeCollector()
26+
collector.visit(parseResult.value)
27+
28+
expect(collector.diagnostics.length).toBeGreaterThan(0)
29+
30+
const diagnostic = collector.diagnostics[0]
31+
expect(diagnostic.message).toContain("Unreachable code")
32+
expect(diagnostic.severity).toBe(DiagnosticSeverity.Hint)
33+
expect(diagnostic.tags).toContain(DiagnosticTag.Unnecessary)
34+
expect(diagnostic.source).toBe("Herb Language Server")
35+
})
36+
37+
it("detects unreachable code in case/in statements", () => {
38+
const content = dedent`
39+
<% case abc %>
40+
<% in String %>
41+
actual string
42+
<% end %>
43+
`
44+
45+
const parseResult = Herb.parse(content)
46+
const collector = new UnreachableCodeCollector()
47+
collector.visit(parseResult.value)
48+
49+
expect(collector.diagnostics.length).toBe(0)
50+
})
51+
52+
it("detects unreachable HTML content between case and when", () => {
53+
const content = dedent`
54+
<% case status %>
55+
<div>This will never render</div>
56+
<p>Neither will this</p>
57+
<% when "active" %>
58+
<p>Active</p>
59+
<% when "inactive" %>
60+
<p>Inactive</p>
61+
<% end %>
62+
`
63+
64+
const parseResult = Herb.parse(content)
65+
const collector = new UnreachableCodeCollector()
66+
collector.visit(parseResult.value)
67+
68+
expect(collector.diagnostics.length).toBeGreaterThan(0)
69+
})
70+
71+
it("does not report diagnostics for case without unreachable children", () => {
72+
const content = dedent`
73+
<% case status %>
74+
<% when "active" %>
75+
<p>Active</p>
76+
<% when "inactive" %>
77+
<p>Inactive</p>
78+
<% else %>
79+
<p>Unknown</p>
80+
<% end %>
81+
`
82+
83+
const parseResult = Herb.parse(content)
84+
const collector = new UnreachableCodeCollector()
85+
collector.visit(parseResult.value)
86+
87+
expect(collector.diagnostics.length).toBe(0)
88+
})
89+
90+
it("detects unreachable code with mixed content", () => {
91+
const content = dedent`
92+
<% case type %>
93+
Some text
94+
<%= variable %>
95+
<span>HTML</span>
96+
<% when :foo %>
97+
<p>Foo</p>
98+
<% end %>
99+
`
100+
101+
const parseResult = Herb.parse(content)
102+
const collector = new UnreachableCodeCollector()
103+
collector.visit(parseResult.value)
104+
105+
expect(collector.diagnostics.length).toBeGreaterThan(0)
106+
})
107+
})
108+
109+
describe("nested case statements", () => {
110+
it("detects unreachable code in nested case statements", () => {
111+
const content = dedent`
112+
<% case outer %>
113+
unreachable outer
114+
<% when "a" %>
115+
<% case inner %>
116+
unreachable inner
117+
<% when "b" %>
118+
reachable
119+
<% end %>
120+
<% end %>
121+
`
122+
123+
const parseResult = Herb.parse(content)
124+
const collector = new UnreachableCodeCollector()
125+
collector.visit(parseResult.value)
126+
127+
expect(collector.diagnostics.length).toBeGreaterThanOrEqual(2)
128+
})
129+
})
130+
})

0 commit comments

Comments
 (0)