Skip to content

Commit 8a69b0d

Browse files
authored
Merge pull request #186 from elevenfive/fix/iterative-ast-conversion
Fix StackOverflowError in recursive AST conversion
2 parents 4e36a9d + ef14c35 commit 8a69b0d

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)