Skip to content

Commit ef14c35

Browse files
elevenfiveclaude
andcommitted
Fix StackOverflowError in recursive AST conversion
Replace the recursive convert() function with an iterative implementation using an explicit stack, preventing StackOverflowError on deeply nested or long markdown documents (e.g. Android's ~1MB default thread stack). Siblings are now traversed in a loop and children are pushed onto an explicit ArrayDeque, producing the identical AstNode tree structure. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4e36a9d commit ef14c35

2 files changed

Lines changed: 327 additions & 94 deletions

File tree

richtext-commonmark/src/commonJvmAndroid/kotlin/com/halilibo/richtext/commonmark/AstNodeConvert.kt

Lines changed: 144 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,104 @@ import org.commonmark.node.ThematicBreak
7070
import org.commonmark.parser.Parser
7171

7272
/**
73-
* Converts common-markdown tree to AstNode tree in a recursive fashion.
73+
* Holds the data for a pending conversion task in the iterative tree traversal.
74+
*/
75+
private class ConvertWorkItem(
76+
val startNode: Node,
77+
val parentAstNode: AstNode?,
78+
val initialPrev: AstNode?,
79+
val onFirstCreated: (AstNode?) -> Unit
80+
)
81+
82+
/**
83+
* Maps a CommonMark [Node] to its corresponding [AstNodeType].
84+
* Returns null for unrecognized node types (CustomNode, CustomBlock, etc.).
85+
*/
86+
private fun convertNodeType(node: Node): AstNodeType? = when (node) {
87+
is BlockQuote -> AstBlockQuote
88+
is BulletList -> AstUnorderedList(bulletMarker = node.bulletMarker)
89+
is Code -> AstCode(literal = node.literal)
90+
is Document -> AstDocument
91+
is Emphasis -> AstEmphasis(delimiter = node.openingDelimiter)
92+
is FencedCodeBlock -> AstFencedCodeBlock(
93+
literal = node.literal,
94+
fenceChar = node.fenceChar,
95+
fenceIndent = node.fenceIndent,
96+
fenceLength = node.fenceLength,
97+
info = node.info
98+
)
99+
is HardLineBreak -> AstHardLineBreak
100+
is Heading -> AstHeading(
101+
level = node.level
102+
)
103+
is ThematicBreak -> AstThematicBreak
104+
is HtmlInline -> AstHtmlInline(
105+
literal = node.literal
106+
)
107+
is HtmlBlock -> AstHtmlBlock(
108+
literal = node.literal
109+
)
110+
is Image -> {
111+
if (node.destination == null) {
112+
null
113+
}
114+
else {
115+
AstImage(
116+
title = node.title ?: "",
117+
destination = node.destination
118+
)
119+
}
120+
}
121+
is IndentedCodeBlock -> AstIndentedCodeBlock(
122+
literal = node.literal
123+
)
124+
is Link -> AstLink(
125+
title = node.title ?: "",
126+
destination = node.destination
127+
)
128+
is ListItem -> AstListItem
129+
is OrderedList -> AstOrderedList(
130+
startNumber = node.startNumber,
131+
delimiter = node.delimiter
132+
)
133+
is Paragraph -> AstParagraph
134+
is SoftLineBreak -> AstSoftLineBreak
135+
is StrongEmphasis -> AstStrongEmphasis(
136+
delimiter = node.openingDelimiter
137+
)
138+
is Text -> AstText(
139+
literal = node.literal
140+
)
141+
is LinkReferenceDefinition -> AstLinkReferenceDefinition(
142+
title = node.title ?: "",
143+
destination = node.destination,
144+
label = node.label
145+
)
146+
is TableBlock -> AstTableRoot
147+
is TableHead -> AstTableHeader
148+
is TableBody -> AstTableBody
149+
is TableRow -> AstTableRow
150+
is TableCell -> AstTableCell(
151+
header = node.isHeader,
152+
alignment = when (node.alignment) {
153+
LEFT -> AstTableCellAlignment.LEFT
154+
CENTER -> AstTableCellAlignment.CENTER
155+
RIGHT -> AstTableCellAlignment.RIGHT
156+
null -> AstTableCellAlignment.LEFT
157+
else -> AstTableCellAlignment.LEFT
158+
}
159+
)
160+
is Strikethrough -> AstStrikethrough(
161+
node.openingDelimiter
162+
)
163+
is CustomNode -> null
164+
is CustomBlock -> null
165+
else -> null
166+
}
167+
168+
/**
169+
* Converts common-markdown tree to AstNode tree iteratively using an explicit stack,
170+
* avoiding StackOverflowError on deeply nested or long markdown documents.
74171
*/
75172
internal fun convert(
76173
node: Node?,
@@ -79,107 +176,60 @@ internal fun convert(
79176
): AstNode? {
80177
node ?: return null
81178

82-
val nodeLinks = AstNodeLinks(
83-
parent = parentNode,
84-
previous = previousNode,
85-
)
179+
var result: AstNode? = null
180+
val stack = ArrayDeque<ConvertWorkItem>()
181+
stack.addLast(ConvertWorkItem(node, parentNode, previousNode) { result = it })
86182

87-
val newNodeType: AstNodeType? = when (node) {
88-
is BlockQuote -> AstBlockQuote
89-
is BulletList -> AstUnorderedList(bulletMarker = node.bulletMarker)
90-
is Code -> AstCode(literal = node.literal)
91-
is Document -> AstDocument
92-
is Emphasis -> AstEmphasis(delimiter = node.openingDelimiter)
93-
is FencedCodeBlock -> AstFencedCodeBlock(
94-
literal = node.literal,
95-
fenceChar = node.fenceChar,
96-
fenceIndent = node.fenceIndent,
97-
fenceLength = node.fenceLength,
98-
info = node.info
99-
)
100-
is HardLineBreak -> AstHardLineBreak
101-
is Heading -> AstHeading(
102-
level = node.level
103-
)
104-
is ThematicBreak -> AstThematicBreak
105-
is HtmlInline -> AstHtmlInline(
106-
literal = node.literal
107-
)
108-
is HtmlBlock -> AstHtmlBlock(
109-
literal = node.literal
110-
)
111-
is Image -> {
112-
if (node.destination == null) {
113-
null
183+
while (stack.isNotEmpty()) {
184+
val item = stack.removeLast()
185+
186+
var prev: AstNode? = null
187+
var firstCreated: AstNode? = null
188+
var cmNode: Node? = item.startNode
189+
var nullTypeNode: Node? = null
190+
191+
// Iterate through siblings instead of recursing
192+
while (cmNode != null) {
193+
val nodeType = convertNodeType(cmNode)
194+
val newNode = nodeType?.let {
195+
AstNode(it, AstNodeLinks(
196+
parent = item.parentAstNode,
197+
previous = prev ?: item.initialPrev
198+
))
114199
}
115-
else {
116-
AstImage(
117-
title = node.title ?: "",
118-
destination = node.destination
119-
)
200+
201+
if (newNode != null) {
202+
if (firstCreated == null) firstCreated = newNode
203+
prev?.links?.next = newNode
204+
205+
// Push child processing onto the explicit stack instead of recursing
206+
val child = cmNode.firstChild
207+
if (child != null) {
208+
stack.addLast(ConvertWorkItem(child, newNode, null) { newNode.links.firstChild = it })
209+
}
210+
211+
prev = newNode
212+
cmNode = cmNode.next
213+
} else {
214+
// Unrecognized node type — stop sibling chain (preserves original behavior)
215+
nullTypeNode = cmNode
216+
cmNode = null
120217
}
121218
}
122-
is IndentedCodeBlock -> AstIndentedCodeBlock(
123-
literal = node.literal
124-
)
125-
is Link -> AstLink(
126-
title = node.title ?: "",
127-
destination = node.destination
128-
)
129-
is ListItem -> AstListItem
130-
is OrderedList -> AstOrderedList(
131-
startNumber = node.startNumber,
132-
delimiter = node.delimiter
133-
)
134-
is Paragraph -> AstParagraph
135-
is SoftLineBreak -> AstSoftLineBreak
136-
is StrongEmphasis -> AstStrongEmphasis(
137-
delimiter = node.openingDelimiter
138-
)
139-
is Text -> AstText(
140-
literal = node.literal
141-
)
142-
is LinkReferenceDefinition -> AstLinkReferenceDefinition(
143-
title = node.title ?: "",
144-
destination = node.destination,
145-
label = node.label
146-
)
147-
is TableBlock -> AstTableRoot
148-
is TableHead -> AstTableHeader
149-
is TableBody -> AstTableBody
150-
is TableRow -> AstTableRow
151-
is TableCell -> AstTableCell(
152-
header = node.isHeader,
153-
alignment = when (node.alignment) {
154-
LEFT -> AstTableCellAlignment.LEFT
155-
CENTER -> AstTableCellAlignment.CENTER
156-
RIGHT -> AstTableCellAlignment.RIGHT
157-
null -> AstTableCellAlignment.LEFT
158-
else -> AstTableCellAlignment.LEFT
159-
}
160-
)
161-
is Strikethrough -> AstStrikethrough(
162-
node.openingDelimiter
163-
)
164-
is CustomNode -> null
165-
is CustomBlock -> null
166-
else -> null
167-
}
168-
169-
val newNode = newNodeType?.let {
170-
AstNode(newNodeType, nodeLinks)
171-
}
172219

173-
if (newNode != null) {
174-
newNode.links.firstChild = convert(node.firstChild, parentNode = newNode, previousNode = null)
175-
newNode.links.next = convert(node.next, parentNode = parentNode, previousNode = newNode)
176-
}
220+
// Set lastChild on the parent, matching the original recursive behavior
221+
if (nullTypeNode != null) {
222+
if (nullTypeNode.next == null) {
223+
item.parentAstNode?.links?.lastChild = null
224+
}
225+
} else {
226+
item.parentAstNode?.links?.lastChild = prev
227+
}
177228

178-
if (node.next == null) {
179-
parentNode?.links?.lastChild = newNode
229+
item.onFirstCreated(firstCreated)
180230
}
181231

182-
return newNode
232+
return result
183233
}
184234

185235
public actual class CommonmarkAstNodeParser actual constructor(

0 commit comments

Comments
 (0)