Skip to content

Commit 68a2054

Browse files
ydahmarcoroth
andauthored
Formatter: Fix infinite loop when rendering multiple groups of adjacent inline elements (#1008)
Fixes: #978 --------- Co-authored-by: Marco Roth <marco.roth@intergga.ch>
1 parent f25021e commit 68a2054

File tree

3 files changed

+70
-12
lines changed

3 files changed

+70
-12
lines changed

javascript/packages/formatter/src/format-helpers.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -398,19 +398,24 @@ export function isContentPreserving(element: HTMLElementNode | HTMLOpenTagNode |
398398
}
399399

400400
/**
401-
* Count consecutive inline elements/ERB at the start of children (with no whitespace between)
401+
* Count consecutive inline elements/ERB with no whitespace between them.
402+
* Starts from startIndex and skips indices in processedIndices.
402403
*/
403-
export function countAdjacentInlineElements(children: Node[]): number {
404+
export function countAdjacentInlineElements(children: Node[], startIndex = 0, processedIndices?: Set<number>): number {
404405
let count = 0
405406
let lastSignificantIndex = -1
406407

407-
for (let i = 0; i < children.length; i++) {
408+
for (let i = startIndex; i < children.length; i++) {
408409
const child = children[i]
409410

410411
if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) {
411412
continue
412413
}
413414

415+
if (processedIndices?.has(i)) {
416+
break
417+
}
418+
414419
const isInlineOrERB = (isNode(child, HTMLElementNode) && isInlineElement(getTagName(child))) || isNode(child, ERBContentNode)
415420

416421
if (!isInlineOrERB) {

javascript/packages/formatter/src/format-printer.ts

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1881,7 +1881,7 @@ export class FormatPrinter extends Printer {
18811881
}
18821882
}
18831883

1884-
const match = trimmedText.match(/^[.!?:;]+/)
1884+
const match = trimmedText.match(/^[.!?:;%]+/)
18851885

18861886
if (!match) {
18871887
return {
@@ -1940,19 +1940,23 @@ export class FormatPrinter extends Printer {
19401940
/**
19411941
* Render adjacent inline elements together on one line
19421942
*/
1943-
private renderAdjacentInlineElements(children: Node[], count: number): { processedIndices: Set<number> } {
1943+
private renderAdjacentInlineElements(children: Node[], count: number, startIndex = 0, alreadyProcessed?: Set<number>): { processedIndices: Set<number>; lastIndex: number } {
19441944
let inlineContent = ""
19451945
let processedCount = 0
19461946
let lastProcessedIndex = -1
19471947
const processedIndices = new Set<number>()
19481948

1949-
for (let index = 0; index < children.length && processedCount < count; index++) {
1949+
for (let index = startIndex; index < children.length && processedCount < count; index++) {
19501950
const child = children[index]
19511951

19521952
if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) {
19531953
continue
19541954
}
19551955

1956+
if (alreadyProcessed?.has(index)) {
1957+
continue
1958+
}
1959+
19561960
if (isNode(child, HTMLElementNode) && isInlineElement(getTagName(child))) {
19571961
inlineContent += this.renderInlineElementAsString(child)
19581962
processedCount++
@@ -1979,21 +1983,27 @@ export class FormatPrinter extends Printer {
19791983
continue
19801984
}
19811985

1986+
if (alreadyProcessed?.has(index)) {
1987+
break
1988+
}
1989+
19821990
if (isNode(child, ERBContentNode)) {
19831991
inlineContent += this.renderERBAsString(child)
19841992
processedIndices.add(index)
1993+
lastProcessedIndex = index
19851994
continue
19861995
}
19871996

19881997
if (isNode(child, HTMLTextNode)) {
19891998
const trimmed = child.content.trim()
19901999

1991-
if (trimmed && /^[.!?:;]/.test(trimmed)) {
2000+
if (trimmed && /^[.!?:;%]/.test(trimmed)) {
19922001
const wrapWidth = this.maxLineLength - this.indent.length
19932002
const result = this.tryMergePunctuationText(inlineContent, trimmed, wrapWidth)
19942003

19952004
inlineContent = result.mergedContent
19962005
processedIndices.add(index)
2006+
lastProcessedIndex = index
19972007

19982008
if (result.shouldStop) {
19992009
if (inlineContent) {
@@ -2002,7 +2012,7 @@ export class FormatPrinter extends Printer {
20022012

20032013
result.wrappedLines.forEach(line => this.push(line))
20042014

2005-
return { processedIndices }
2015+
return { processedIndices, lastIndex: lastProcessedIndex }
20062016
}
20072017
}
20082018
}
@@ -2015,7 +2025,10 @@ export class FormatPrinter extends Printer {
20152025
this.pushWithIndent(inlineContent)
20162026
}
20172027

2018-
return { processedIndices }
2028+
return {
2029+
processedIndices,
2030+
lastIndex: lastProcessedIndex >= 0 ? lastProcessedIndex : startIndex + count - 1
2031+
}
20192032
}
20202033

20212034
/**
@@ -2054,21 +2067,37 @@ export class FormatPrinter extends Printer {
20542067
}
20552068

20562069
/**
2057-
* Visit remaining children after processing adjacent inline elements
2070+
* Visit remaining children after processing adjacent inline elements.
2071+
* Detects and renders subsequent groups of adjacent inline elements.
20582072
*/
20592073
private visitRemainingChildren(children: Node[], processedIndices: Set<number>): void {
2060-
for (let index = 0; index < children.length; index++) {
2074+
let index = 0
2075+
2076+
while (index < children.length) {
20612077
const child = children[index]
20622078

20632079
if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) {
2080+
index++
20642081
continue
20652082
}
20662083

20672084
if (processedIndices.has(index)) {
2085+
index++
20682086
continue
20692087
}
20702088

2071-
this.visit(child)
2089+
const adjacentCount = countAdjacentInlineElements(children, index, processedIndices)
2090+
2091+
if (adjacentCount >= 2) {
2092+
const { processedIndices: newProcessedIndices, lastIndex } =
2093+
this.renderAdjacentInlineElements(children, adjacentCount, index, processedIndices)
2094+
2095+
newProcessedIndices.forEach(i => processedIndices.add(i))
2096+
index = lastIndex + 1
2097+
} else {
2098+
this.visit(child)
2099+
index++
2100+
}
20722101
}
20732102
}
20742103

javascript/packages/formatter/test/erb/erb.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,30 @@ describe("@herb-tools/formatter", () => {
269269
expect(secondFormat).toBe(result)
270270
})
271271

272+
test("issue 978: does not duplicate content with multiple adjacent inline/ERB groups separated by br", () => {
273+
const input = dedent`
274+
<p class="text-reversed">
275+
<strong><%= t("admin.tickets.form.savings") %></strong><%= ticket.ticketable.savings_percentage %>%
276+
<br>
277+
<strong><%= t("admin.tickets.form.price_per_ticket") %></strong><%= format_price(ticket.ticketable.price_per_ticket) %>
278+
</p>
279+
`
280+
281+
const result = formatter.format(input)
282+
283+
expect(result).toBe(dedent`
284+
<p class="text-reversed">
285+
<strong><%= t("admin.tickets.form.savings") %></strong><%= ticket.ticketable.savings_percentage %>%
286+
<br>
287+
<strong><%= t("admin.tickets.form.price_per_ticket") %></strong><%= format_price(ticket.ticketable.price_per_ticket) %>
288+
</p>
289+
`)
290+
291+
// Test idempotency
292+
const secondFormat = formatter.format(result)
293+
expect(secondFormat).toBe(result)
294+
})
295+
272296
test("https://github.com/hanakai-rb/site/blob/8adc128d9d464f3e37615be2aa29d57979904533/app/templates/pages/home.html.erb", () => {
273297
const input = dedent`
274298
<h1>Hanakai</h1>

0 commit comments

Comments
 (0)