@@ -4,6 +4,7 @@ import { IdentityPrinter } from "@herb-tools/printer"
44
55import {
66 isHTMLElementNode ,
7+ isHTMLOpenTagNode ,
78 isERBIfNode ,
89 isERBElseNode ,
910 isERBUnlessNode ,
@@ -27,12 +28,21 @@ type ConditionalNode = ERBIfNode | ERBUnlessNode | ERBCaseNode
2728
2829interface DuplicateBranchAutofixContext extends BaseAutofixContext {
2930 node : Mutable < ConditionalNode >
31+ allIdentical ?: boolean
3032}
3133
3234function 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+
3646function 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}
0 commit comments