Skip to content

Commit 701fcab

Browse files
committed
Fix infinite loop when rendering multiple groups of adjacent inline elements separated by line breaks
Fixes: #978
1 parent 3909cc8 commit 701fcab

File tree

2 files changed

+186
-4
lines changed

2 files changed

+186
-4
lines changed

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

Lines changed: 162 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1792,7 +1792,7 @@ export class FormatPrinter extends Printer {
17921792
}
17931793
}
17941794

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

17971797
if (!match) {
17981798
return {
@@ -1899,7 +1899,7 @@ export class FormatPrinter extends Printer {
18991899
if (isNode(child, HTMLTextNode)) {
19001900
const trimmed = child.content.trim()
19011901

1902-
if (trimmed && /^[.!?:;]/.test(trimmed)) {
1902+
if (trimmed && /^[.!?:;%]/.test(trimmed)) {
19031903
const wrapWidth = this.maxLineLength - this.indent.length
19041904
const result = this.tryMergePunctuationText(inlineContent, trimmed, wrapWidth)
19051905

@@ -1963,22 +1963,180 @@ export class FormatPrinter extends Printer {
19631963
}).join("")
19641964
}
19651965

1966+
/**
1967+
* Count adjacent inline elements starting from a specific index
1968+
* Skips indices that are already processed
1969+
*/
1970+
private countAdjacentInlineElementsFromIndex(
1971+
children: Node[],
1972+
startIndex: number,
1973+
processedIndices: Set<number>
1974+
): number {
1975+
let count = 0
1976+
let lastSignificantIndex = -1
1977+
1978+
for (let i = startIndex; i < children.length; i++) {
1979+
const child = children[i]
1980+
1981+
if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) {
1982+
continue
1983+
}
1984+
1985+
if (processedIndices.has(i)) {
1986+
break
1987+
}
1988+
1989+
const isInlineOrERB =
1990+
(isNode(child, HTMLElementNode) && isInlineElement(getTagName(child))) ||
1991+
isNode(child, ERBContentNode)
1992+
1993+
if (!isInlineOrERB) {
1994+
break
1995+
}
1996+
1997+
if (lastSignificantIndex >= 0 && hasWhitespaceBetween(children, lastSignificantIndex, i)) {
1998+
break
1999+
}
2000+
2001+
count++
2002+
lastSignificantIndex = i
2003+
}
2004+
2005+
return count
2006+
}
2007+
2008+
/**
2009+
* Render adjacent inline elements starting from a specific index
2010+
* Similar to renderAdjacentInlineElements but works from an arbitrary start point
2011+
*/
2012+
private renderAdjacentInlineElementsFromIndex(
2013+
children: Node[],
2014+
startIndex: number,
2015+
count: number,
2016+
alreadyProcessed: Set<number>
2017+
): { processedIndices: Set<number>; lastIndex: number } {
2018+
let inlineContent = ""
2019+
let processedCount = 0
2020+
let lastProcessedIndex = -1
2021+
const processedIndices = new Set<number>()
2022+
2023+
for (let index = startIndex; index < children.length && processedCount < count; index++) {
2024+
const child = children[index]
2025+
2026+
if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) {
2027+
continue
2028+
}
2029+
2030+
if (alreadyProcessed.has(index)) {
2031+
continue
2032+
}
2033+
2034+
if (isNode(child, HTMLElementNode) && isInlineElement(getTagName(child))) {
2035+
inlineContent += this.renderInlineElementAsString(child)
2036+
processedCount++
2037+
lastProcessedIndex = index
2038+
processedIndices.add(index)
2039+
2040+
if (inlineContent && isLineBreakingElement(child)) {
2041+
this.pushWithIndent(inlineContent)
2042+
inlineContent = ""
2043+
}
2044+
} else if (isNode(child, ERBContentNode)) {
2045+
inlineContent += this.renderERBAsString(child)
2046+
processedCount++
2047+
lastProcessedIndex = index
2048+
processedIndices.add(index)
2049+
}
2050+
}
2051+
2052+
if (lastProcessedIndex >= 0) {
2053+
for (let index = lastProcessedIndex + 1; index < children.length; index++) {
2054+
const child = children[index]
2055+
2056+
if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) {
2057+
continue
2058+
}
2059+
2060+
if (alreadyProcessed.has(index)) {
2061+
break
2062+
}
2063+
2064+
if (isNode(child, ERBContentNode)) {
2065+
inlineContent += this.renderERBAsString(child)
2066+
processedIndices.add(index)
2067+
lastProcessedIndex = index
2068+
continue
2069+
}
2070+
2071+
if (isNode(child, HTMLTextNode)) {
2072+
const trimmed = child.content.trim()
2073+
2074+
if (trimmed && /^[.!?:;%]/.test(trimmed)) {
2075+
const wrapWidth = this.maxLineLength - this.indent.length
2076+
const result = this.tryMergePunctuationText(inlineContent, trimmed, wrapWidth)
2077+
2078+
inlineContent = result.mergedContent
2079+
processedIndices.add(index)
2080+
lastProcessedIndex = index
2081+
2082+
if (result.shouldStop) {
2083+
if (inlineContent) {
2084+
this.pushWithIndent(inlineContent)
2085+
}
2086+
2087+
result.wrappedLines.forEach(line => this.push(line))
2088+
2089+
return { processedIndices, lastIndex: lastProcessedIndex }
2090+
}
2091+
}
2092+
}
2093+
2094+
break
2095+
}
2096+
}
2097+
2098+
if (inlineContent) {
2099+
this.pushWithIndent(inlineContent)
2100+
}
2101+
2102+
return {
2103+
processedIndices,
2104+
lastIndex: lastProcessedIndex >= 0 ? lastProcessedIndex : startIndex + count - 1
2105+
}
2106+
}
2107+
19662108
/**
19672109
* Visit remaining children after processing adjacent inline elements
2110+
* Recursively handles groups of adjacent inline elements
19682111
*/
19692112
private visitRemainingChildren(children: Node[], processedIndices: Set<number>): void {
1970-
for (let index = 0; index < children.length; index++) {
2113+
let index = 0
2114+
2115+
while (index < children.length) {
19712116
const child = children[index]
19722117

19732118
if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) {
2119+
index++
19742120
continue
19752121
}
19762122

19772123
if (processedIndices.has(index)) {
2124+
index++
19782125
continue
19792126
}
19802127

1981-
this.visit(child)
2128+
const adjacentCount = this.countAdjacentInlineElementsFromIndex(children, index, processedIndices)
2129+
2130+
if (adjacentCount >= 2) {
2131+
const { processedIndices: newProcessedIndices, lastIndex } =
2132+
this.renderAdjacentInlineElementsFromIndex(children, index, adjacentCount, processedIndices)
2133+
2134+
newProcessedIndices.forEach(i => processedIndices.add(i))
2135+
index = lastIndex + 1
2136+
} else {
2137+
this.visit(child)
2138+
index++
2139+
}
19822140
}
19832141
}
19842142

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

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

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

0 commit comments

Comments
 (0)