Skip to content

Commit 7fc78b3

Browse files
authored
Linter: Support more cases for erb-no-duplicate-branch-elements (#1415)
Resolves #1395
1 parent 0780a1f commit 7fc78b3

File tree

6 files changed

+613
-81
lines changed

6 files changed

+613
-81
lines changed

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,9 @@ export class Server {
186186

187187
if (!document) return []
188188

189+
const parseResult = this.service.parserService.parseDocument(document)
190+
if (parseResult.diagnostics.length > 0) return []
191+
189192
const diagnostics = params.context.diagnostics
190193
const documentText = document.getText()
191194

javascript/packages/linter/src/rules/erb-no-duplicate-branch-elements.ts

Lines changed: 130 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { IdentityPrinter } from "@herb-tools/printer"
44

55
import {
66
isHTMLElementNode,
7+
isHTMLOpenTagNode,
78
isERBIfNode,
89
isERBElseNode,
910
isERBUnlessNode,
@@ -27,12 +28,21 @@ type ConditionalNode = ERBIfNode | ERBUnlessNode | ERBCaseNode
2728

2829
interface DuplicateBranchAutofixContext extends BaseAutofixContext {
2930
node: Mutable<ConditionalNode>
31+
allIdentical?: boolean
3032
}
3133

3234
function getSignificantNodes(statements: Node[]): Node[] {
3335
return statements.filter(node => !isPureWhitespaceNode(node))
3436
}
3537

38+
function trimWhitespaceNodes(nodes: Node[]): Node[] {
39+
let start = 0
40+
let end = nodes.length
41+
while (start < end && isPureWhitespaceNode(nodes[start])) start++
42+
while (end > start && isPureWhitespaceNode(nodes[end - 1])) end--
43+
return nodes.slice(start, end)
44+
}
45+
3646
function allEquivalentElements(nodes: Node[]): nodes is HTMLElementNode[] {
3747
if (nodes.length < 2) return false
3848
if (!nodes.every(node => isHTMLElementNode(node))) return false
@@ -172,10 +182,31 @@ class ERBNoDuplicateBranchElementsVisitor extends BaseRuleVisitor<DuplicateBranc
172182
this.markSubsequentIfNodesAsProcessed(node)
173183
}
174184

185+
if (this.allBranchesIdentical(branches)) {
186+
this.addOffense(
187+
"All branches of this conditional have identical content. The conditional can be removed.",
188+
node.location,
189+
{ node: node as Mutable<ConditionalNode>, allIdentical: true },
190+
"warning",
191+
)
192+
193+
return
194+
}
195+
175196
const state = { isFirstOffense: true }
176197
this.checkBranches(branches, node, state)
177198
}
178199

200+
private allBranchesIdentical(branches: Node[][]): boolean {
201+
if (branches.length < 2) return false
202+
203+
const first = branches[0].map(node => IdentityPrinter.print(node)).join("")
204+
205+
return branches.slice(1).every(branch =>
206+
branch.map(node => IdentityPrinter.print(node)).join("") === first
207+
)
208+
}
209+
179210
private markSubsequentIfNodesAsProcessed(node: ERBIfNode): void {
180211
let current: ERBIfNode | ERBElseNode | null = node.subsequent
181212

@@ -214,17 +245,37 @@ class ERBNoDuplicateBranchElementsVisitor extends BaseRuleVisitor<DuplicateBranc
214245

215246
for (const element of elements) {
216247
const printed = IdentityPrinter.print(element.open_tag)
217-
const autofixContext = state.isFirstOffense
218-
? { node: conditionalNode as Mutable<ConditionalNode> }
219-
: undefined
220248

221-
this.addOffense(
222-
`The \`${printed}\` element is duplicated across all branches of this conditional and can be moved outside.`,
223-
bodiesMatch ? element.location : (element?.open_tag?.location || element.location),
224-
autofixContext,
225-
)
249+
if (bodiesMatch) {
250+
const autofixContext = state.isFirstOffense
251+
? { node: conditionalNode as Mutable<ConditionalNode> }
252+
: undefined
253+
254+
this.addOffense(
255+
`The \`${printed}\` element is duplicated across all branches of this conditional and can be moved outside.`,
256+
element.location,
257+
autofixContext,
258+
)
226259

227-
state.isFirstOffense = false
260+
state.isFirstOffense = false
261+
} else {
262+
const autofixContext = state.isFirstOffense
263+
? { node: conditionalNode as Mutable<ConditionalNode> }
264+
: undefined
265+
266+
const tagNameLocation = isHTMLOpenTagNode(element.open_tag) && element.open_tag.tag_name?.location
267+
? element.open_tag.tag_name.location
268+
: element?.open_tag?.location || element.location
269+
270+
this.addOffense(
271+
`The \`${printed}\` tag is repeated across all branches with different content. Consider extracting the shared tag outside the conditional.`,
272+
tagNameLocation,
273+
autofixContext,
274+
"hint",
275+
)
276+
277+
state.isFirstOffense = false
278+
}
228279
}
229280

230281
if (!bodiesMatch && bodies.every(body => body.length > 0)) {
@@ -260,6 +311,18 @@ export class ERBNoDuplicateBranchElementsRule extends ParserRule<DuplicateBranch
260311
const branches = collectBranches(conditionalNode as ConditionalNode)
261312
if (!branches) return null
262313

314+
if (offense.autofixContext.allIdentical) {
315+
const parentInfo = findParentArray(result.value, conditionalNode as unknown as Node)
316+
if (!parentInfo) return null
317+
318+
const { array: parentArray, index: conditionalIndex } = parentInfo
319+
const firstBranchContent = trimWhitespaceNodes(branches[0])
320+
321+
parentArray.splice(conditionalIndex, 1, ...firstBranchContent)
322+
323+
return result
324+
}
325+
263326
const significantBranches = branches.map(getSignificantNodes)
264327
if (significantBranches.some(branch => branch.length === 0)) return null
265328

@@ -274,24 +337,57 @@ export class ERBNoDuplicateBranchElementsRule extends ParserRule<DuplicateBranch
274337

275338
let { array: parentArray, index: conditionalIndex } = parentInfo
276339
let hasWrapped = false
340+
let didMutate = false
341+
let failedToHoistPrefix = false
342+
let hoistedBefore = false
277343

278344
const hoistElement = (elements: HTMLElementNode[], position: "before" | "after"): void => {
345+
const actualPosition = (position === "before" && failedToHoistPrefix) ? "after" : position
279346
const bodiesMatch = elements.every(element => IdentityPrinter.print(element) === IdentityPrinter.print(elements[0]))
280347

281348
if (bodiesMatch) {
349+
if (actualPosition === "after") {
350+
const currentLengths = branches.map(b => getSignificantNodes(b as Node[]).length)
351+
if (currentLengths.some(l => l !== currentLengths[0])) return
352+
}
353+
354+
if (actualPosition === "after" && position === "before") {
355+
const isAtEnd = branches.every((branch, index) => {
356+
const nodes = getSignificantNodes(branch as Node[])
357+
358+
return nodes.length > 0 && nodes[nodes.length - 1] === elements[index]
359+
})
360+
361+
if (!isAtEnd) return
362+
}
363+
282364
for (let i = 0; i < branches.length; i++) {
283365
removeNodeFromArray(branches[i] as Node[], elements[i])
284366
}
285367

286-
if (position === "before") {
287-
parentArray.splice(conditionalIndex, 0, elements[0])
288-
conditionalIndex++
368+
if (actualPosition === "before") {
369+
parentArray.splice(conditionalIndex, 0, elements[0], createLiteral("\n"))
370+
conditionalIndex += 2
371+
hoistedBefore = true
289372
} else {
290-
parentArray.splice(conditionalIndex + 1, 0, elements[0])
373+
parentArray.splice(conditionalIndex + 1, 0, createLiteral("\n"), elements[0])
291374
}
375+
376+
didMutate = true
292377
} else {
293378
if (hasWrapped) return
294379

380+
const canWrap = branches.every((branch, index) => {
381+
const remaining = getSignificantNodes(branch)
382+
383+
return remaining.length === 1 && remaining[0] === elements[index]
384+
})
385+
386+
if (!canWrap) {
387+
if (position === "before") failedToHoistPrefix = true
388+
return
389+
}
390+
295391
for (let i = 0; i < branches.length; i++) {
296392
replaceNodeWithBody(branches[i] as Node[], elements[i])
297393
}
@@ -302,6 +398,7 @@ export class ERBNoDuplicateBranchElementsRule extends ParserRule<DuplicateBranch
302398
parentArray = wrapper.body as Node[]
303399
conditionalIndex = 1
304400
hasWrapped = true
401+
didMutate = true
305402
}
306403
}
307404

@@ -315,6 +412,25 @@ export class ERBNoDuplicateBranchElementsRule extends ParserRule<DuplicateBranch
315412
hoistElement(elements, "after")
316413
}
317414

318-
return result
415+
if (!hasWrapped && hoistedBefore) {
416+
const remaining = branches.map(branch => getSignificantNodes(branch as Node[]))
417+
418+
if (remaining.every(branch => branch.length === 1) && allEquivalentElements(remaining.map(b => b[0]))) {
419+
const elements = remaining.map(b => b[0] as HTMLElementNode)
420+
const bodiesMatch = elements.every(el => IdentityPrinter.print(el) === IdentityPrinter.print(elements[0]))
421+
422+
if (!bodiesMatch && elements.every(el => el.body.length > 0)) {
423+
for (let i = 0; i < branches.length; i++) {
424+
replaceNodeWithBody(branches[i] as Node[], elements[i])
425+
}
426+
427+
const wrapper = createWrapper(elements[0], [createLiteral("\n"), conditionalNode as unknown as Node, createLiteral("\n")])
428+
parentArray[conditionalIndex] = wrapper
429+
didMutate = true
430+
}
431+
}
432+
}
433+
434+
return didMutate ? result : null
319435
}
320436
}

javascript/packages/linter/src/rules/rule-utils.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import type {
2727
import { DEFAULT_LINT_CONTEXT } from "../types.js"
2828

2929
import type * as Nodes from "@herb-tools/core"
30+
import type { DiagnosticTag } from "@herb-tools/core"
3031
import type { UnboundLintOffense, LintContext, LintSeverity, BaseAutofixContext } from "../types.js"
3132

3233
export enum ControlFlowType {
@@ -53,7 +54,7 @@ export abstract class BaseRuleVisitor<TAutofixContext extends BaseAutofixContext
5354
* Helper method to create an unbound lint offense (without severity).
5455
* The Linter will bind severity based on the rule's config.
5556
*/
56-
protected createOffense(message: string, location: Location, autofixContext?: TAutofixContext, severity?: LintSeverity): UnboundLintOffense<TAutofixContext> {
57+
protected createOffense(message: string, location: Location, autofixContext?: TAutofixContext, severity?: LintSeverity, tags?: DiagnosticTag[]): UnboundLintOffense<TAutofixContext> {
5758
return {
5859
rule: this.ruleName,
5960
code: this.ruleName,
@@ -62,14 +63,15 @@ export abstract class BaseRuleVisitor<TAutofixContext extends BaseAutofixContext
6263
location,
6364
autofixContext,
6465
severity,
66+
tags,
6567
}
6668
}
6769

6870
/**
6971
* Helper method to add an offense to the offenses array
7072
*/
71-
protected addOffense(message: string, location: Location, autofixContext?: TAutofixContext, severity?: LintSeverity): void {
72-
this.offenses.push(this.createOffense(message, location, autofixContext, severity))
73+
protected addOffense(message: string, location: Location, autofixContext?: TAutofixContext, severity?: LintSeverity, tags?: DiagnosticTag[]): void {
74+
this.offenses.push(this.createOffense(message, location, autofixContext, severity, tags))
7375
}
7476
}
7577

@@ -515,7 +517,7 @@ export abstract class BaseLexerRuleVisitor<TAutofixContext extends BaseAutofixCo
515517
* Helper method to create an unbound lint offense (without severity).
516518
* The Linter will bind severity based on the rule's config.
517519
*/
518-
protected createOffense(message: string, location: Location, autofixContext?: TAutofixContext, severity?: LintSeverity): UnboundLintOffense<TAutofixContext> {
520+
protected createOffense(message: string, location: Location, autofixContext?: TAutofixContext, severity?: LintSeverity, tags?: DiagnosticTag[]): UnboundLintOffense<TAutofixContext> {
519521
return {
520522
rule: this.ruleName,
521523
code: this.ruleName,
@@ -524,14 +526,15 @@ export abstract class BaseLexerRuleVisitor<TAutofixContext extends BaseAutofixCo
524526
location,
525527
autofixContext,
526528
severity,
529+
tags,
527530
}
528531
}
529532

530533
/**
531534
* Helper method to add an offense to the offenses array
532535
*/
533-
protected addOffense(message: string, location: Location, autofixContext?: TAutofixContext, severity?: LintSeverity): void {
534-
this.offenses.push(this.createOffense(message, location, autofixContext, severity))
536+
protected addOffense(message: string, location: Location, autofixContext?: TAutofixContext, severity?: LintSeverity, tags?: DiagnosticTag[]): void {
537+
this.offenses.push(this.createOffense(message, location, autofixContext, severity, tags))
535538
}
536539

537540
/**
@@ -578,7 +581,7 @@ export abstract class BaseSourceRuleVisitor<TAutofixContext extends BaseAutofixC
578581
* Helper method to create an unbound lint offense (without severity).
579582
* The Linter will bind severity based on the rule's config.
580583
*/
581-
protected createOffense(message: string, location: Location, autofixContext?: TAutofixContext, severity?: LintSeverity): UnboundLintOffense<TAutofixContext> {
584+
protected createOffense(message: string, location: Location, autofixContext?: TAutofixContext, severity?: LintSeverity, tags?: DiagnosticTag[]): UnboundLintOffense<TAutofixContext> {
582585
return {
583586
rule: this.ruleName,
584587
code: this.ruleName,
@@ -587,14 +590,15 @@ export abstract class BaseSourceRuleVisitor<TAutofixContext extends BaseAutofixC
587590
location,
588591
autofixContext,
589592
severity,
593+
tags,
590594
}
591595
}
592596

593597
/**
594598
* Helper method to add an offense to the offenses array
595599
*/
596-
protected addOffense(message: string, location: Location, autofixContext?: TAutofixContext, severity?: LintSeverity): void {
597-
this.offenses.push(this.createOffense(message, location, autofixContext, severity))
600+
protected addOffense(message: string, location: Location, autofixContext?: TAutofixContext, severity?: LintSeverity, tags?: DiagnosticTag[]): void {
601+
this.offenses.push(this.createOffense(message, location, autofixContext, severity, tags))
598602
}
599603

600604
/**

javascript/packages/linter/src/types.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Diagnostic, LexResult, ParseResult, Location } from "@herb-tools/core"
22

3+
import type { DiagnosticTag } from "@herb-tools/core"
34
import type { rules } from "./rules.js"
45
import type { Node, ParserOptions } from "@herb-tools/core"
56
import type { RuleConfig } from "@herb-tools/config"
@@ -111,7 +112,7 @@ export abstract class ParserRule<TAutofixContext extends BaseAutofixContext = Ba
111112
return DEFAULT_LINTER_PARSER_OPTIONS
112113
}
113114

114-
protected createOffense(message: string, location: Location, autofixContext?: TAutofixContext, severity?: LintSeverity): UnboundLintOffense<TAutofixContext> {
115+
protected createOffense(message: string, location: Location, autofixContext?: TAutofixContext, severity?: LintSeverity, tags?: DiagnosticTag[]): UnboundLintOffense<TAutofixContext> {
115116
return {
116117
rule: this.ruleName,
117118
code: this.ruleName,
@@ -120,6 +121,7 @@ export abstract class ParserRule<TAutofixContext extends BaseAutofixContext = Ba
120121
location,
121122
autofixContext,
122123
severity,
124+
tags,
123125
}
124126
}
125127

@@ -164,7 +166,7 @@ export abstract class LexerRule<TAutofixContext extends BaseAutofixContext = Bas
164166
return DEFAULT_RULE_CONFIG
165167
}
166168

167-
protected createOffense(message: string, location: Location, autofixContext?: TAutofixContext, severity?: LintSeverity): UnboundLintOffense<TAutofixContext> {
169+
protected createOffense(message: string, location: Location, autofixContext?: TAutofixContext, severity?: LintSeverity, tags?: DiagnosticTag[]): UnboundLintOffense<TAutofixContext> {
168170
return {
169171
rule: this.ruleName,
170172
code: this.ruleName,
@@ -173,6 +175,7 @@ export abstract class LexerRule<TAutofixContext extends BaseAutofixContext = Bas
173175
location,
174176
autofixContext,
175177
severity,
178+
tags,
176179
}
177180
}
178181

@@ -241,7 +244,7 @@ export abstract class SourceRule<TAutofixContext extends BaseAutofixContext = Ba
241244
return DEFAULT_RULE_CONFIG
242245
}
243246

244-
protected createOffense(message: string, location: Location, autofixContext?: TAutofixContext, severity?: LintSeverity): UnboundLintOffense<TAutofixContext> {
247+
protected createOffense(message: string, location: Location, autofixContext?: TAutofixContext, severity?: LintSeverity, tags?: DiagnosticTag[]): UnboundLintOffense<TAutofixContext> {
245248
return {
246249
rule: this.ruleName,
247250
code: this.ruleName,
@@ -250,6 +253,7 @@ export abstract class SourceRule<TAutofixContext extends BaseAutofixContext = Ba
250253
location,
251254
autofixContext,
252255
severity,
256+
tags,
253257
}
254258
}
255259

0 commit comments

Comments
 (0)