Skip to content

Commit f2b2bb6

Browse files
committed
DEV: Convert the MediaWiki inline parser to byte-offset jump scanning
The inline parser walked its input one character at a time — a String allocation per character (909/post on the corpus) — and consecutive_apostrophes_at sliced the entire remaining input on every probe (374/post, O(n²) bytes across an apostrophe run). Character indices also made every index/slice O(pos) on multibyte input. The main loop now jumps between interesting bytes (' [ <) with one byteindex search and copies each skipped span in a single byteslice; apostrophe runs are counted with getbyte; the HTML tag match anchors at the cursor via a \G regex with byteindex instead of slicing the rest of the input; and all offsets are byte offsets (boundary invariant: jumps land on ASCII matches, advances step over ASCII bytes or whole matches). Corpus bench: mw_parse 94.6 → 32.0 µs/post ASCII and 137.5 → 40.4 multibyte; 1583 → ~170 objects/post. Output is unchanged — I diffed the rendered Markdown of all 400 corpus posts plus hand-picked Unicode edge cases (Cyrillic apostrophe names, French elisions, Japanese link targets, unterminated constructs after multibyte text) between the old and new implementation: zero differences, including unknown_tags. New specs place multibyte text before every construct, shaped so a byte/char-index mixup would land on an alphanumeric byte and change the outcome. Two leftovers mutant caught: the failure-branch `@pos = start + count` reset in parse_apostrophe_formatting is gone (the rewritten collect_until_apostrophes no longer trashes @pos on failure, so the reset — and the start parameter — were dead), and consecutive_apostrophes_at's `count = 0` seed goes on the ignore list (count = 1 is equivalent because every call site sits on an apostrophe byte).
1 parent 84b54f2 commit f2b2bb6

3 files changed

Lines changed: 175 additions & 56 deletions

File tree

lib/markbridge/parsers/media_wiki/inline_parser.rb

Lines changed: 94 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ module MediaWiki
77
# Handles bold ('''), italic (''), links ([[...]]), external links ([...]),
88
# and HTML inline tags via an InlineTagRegistry.
99
#
10+
# The parser works in *byte* offsets (byteindex/byteslice/getbyte):
11+
# character indices are O(pos) on multibyte input in CRuby, and
12+
# per-character probes allocate a String each. The main loop jumps
13+
# between interesting bytes with a single regex search and copies
14+
# the skipped span in one slice. Invariant: +@pos+ always sits on a
15+
# character boundary — jumps land on ASCII matches and advances
16+
# step over ASCII bytes or whole matches.
17+
#
1018
# @example With custom registry
1119
# registry = InlineTagRegistry.build_from_default do |r|
1220
# r.register("mark", :formatting, AST::Bold)
@@ -15,6 +23,19 @@ module MediaWiki
1523
class InlineParser
1624
MAX_INLINE_DEPTH = 20
1725

26+
# Bytes the main loop reacts to: apostrophes ('), link openers ([)
27+
# and HTML tag openers (<). ASCII-only, so a byteindex match always
28+
# lands on a character boundary.
29+
INTERESTING = /['\[<]/
30+
APOSTROPHE = 39 # '
31+
BRACKET_OPEN = 91 # [
32+
private_constant :INTERESTING, :APOSTROPHE, :BRACKET_OPEN
33+
34+
# Matches an HTML-like tag at the search position (\G with
35+
# byteindex anchors at the offset).
36+
HTML_TAG_AT_CURSOR = %r{\G<(/?)([a-z]+)(?: [^>]*)?\s*(/?)>}i
37+
private_constant :HTML_TAG_AT_CURSOR
38+
1839
# @return [Hash{String => Integer}] tag-name → occurrence count for
1940
# HTML-like inline tags whose names are not registered. Shared
2041
# with nested InlineParser instances so depth-recursive parses
@@ -34,56 +55,66 @@ def initialize(handlers: nil, depth: 0, unknown_tags: nil)
3455
def parse(text, parent:)
3556
@input = text
3657
@pos = 0
37-
@length = text.length
58+
@length = text.bytesize
3859
@parent = parent
3960
@text_buffer = +""
4061

41-
while @pos < @length
42-
char = @input[@pos]
43-
44-
case char
45-
when "'"
46-
consecutive_apostrophes_at(@pos) >= 2 ? parse_bold_italic : append_literal(char)
47-
when "["
48-
flush_text
49-
@input[@pos + 1] == "[" ? parse_internal_link : parse_external_link
50-
when "<"
51-
flush_text
52-
parse_html_tag
53-
else
54-
append_literal(char)
55-
end
62+
while (span_end = @input.byteindex(INTERESTING, @pos))
63+
# Unconditional on purpose: when the interesting byte sits at
64+
# @pos the slice is empty and both lines are no-ops.
65+
@text_buffer << @input.byteslice(@pos, span_end - @pos)
66+
@pos = span_end
67+
68+
dispatch_interesting_byte
5669
end
5770

71+
# Trailing text after the last interesting byte; appending the
72+
# empty slice at end-of-input is a no-op.
73+
@text_buffer << @input.byteslice(@pos, @length - @pos)
5874
flush_text
5975
end
6076

6177
private
6278

63-
def append_literal(char)
64-
@text_buffer << char
65-
@pos += 1
79+
# Precondition: the byte at +@pos+ matched INTERESTING.
80+
def dispatch_interesting_byte
81+
case @input.getbyte(@pos)
82+
when APOSTROPHE
83+
if consecutive_apostrophes_at(@pos) >= 2
84+
parse_bold_italic
85+
else
86+
@text_buffer << "'"
87+
@pos += 1
88+
end
89+
when BRACKET_OPEN
90+
flush_text
91+
@input.getbyte(@pos + 1) == BRACKET_OPEN ? parse_internal_link : parse_external_link
92+
else # "<" — the only remaining INTERESTING byte
93+
flush_text
94+
parse_html_tag
95+
end
6696
end
6797

68-
# Precondition: caller has verified @input[@pos..@pos+1] is "''".
98+
# Precondition: caller has verified at least two apostrophes at @pos.
6999
def parse_bold_italic
70-
start = @pos
71100
count = [consecutive_apostrophes_at(@pos), 5].min
72101
flush_text
73102
@pos += count
74-
parse_apostrophe_formatting(count, start)
103+
parse_apostrophe_formatting(count)
75104
end
76105

77106
# Parse apostrophe-delimited formatting (bold, italic, or bold+italic).
107+
# Entered with @pos just past the opening apostrophes.
78108
#
79109
# @param apostrophe_count [Integer] number of apostrophes (2, 3, or 5)
80-
# @param start [Integer] position before the opening apostrophes
81-
def parse_apostrophe_formatting(apostrophe_count, start)
110+
def parse_apostrophe_formatting(apostrophe_count)
82111
content = collect_until_apostrophes(apostrophe_count)
83112

84113
unless content
114+
# @pos already sits just past the opening apostrophes
115+
# (start + apostrophe_count): the caller advanced it, and
116+
# collect_until_apostrophes leaves it untouched on failure.
85117
@text_buffer << ("'" * apostrophe_count)
86-
@pos = start + apostrophe_count
87118
return
88119
end
89120

@@ -124,44 +155,50 @@ def parse_inner_content(content, parent:)
124155
).parse(content, parent:)
125156
end
126157

127-
# Collect text until we find n consecutive apostrophes.
128-
# Returns the collected content string or nil if not found.
158+
# Collect text until we find n consecutive apostrophes, hopping
159+
# from apostrophe run to apostrophe run instead of scanning per
160+
# position. Returns the collected content string, or nil (the
161+
# exhausted while loop) if no closing run exists.
129162
#
130163
# @param count [Integer] number of consecutive apostrophes to match
131164
# @return [String, nil]
132165
def collect_until_apostrophes(count)
133166
start = @pos
134-
while @pos < @length
135-
if consecutive_apostrophes_at(@pos) >= count
136-
content = @input[start...@pos]
137-
@pos += count
167+
probe = @pos
168+
while (index = @input.byteindex("'", probe))
169+
run = consecutive_apostrophes_at(index)
170+
if run >= count
171+
content = @input.byteslice(start, index - start)
172+
@pos = index + count
138173
return content
139174
end
140-
@pos += 1
175+
probe = index + run
141176
end
142177
end
143178

144179
# Count consecutive apostrophes starting at position.
180+
# getbyte past end-of-input returns nil, which ends the run.
145181
#
146182
# @param pos [Integer]
147183
# @return [Integer]
148184
def consecutive_apostrophes_at(pos)
149-
@input[pos..].each_char.take_while { |c| c == "'" }.length
185+
count = 0
186+
count += 1 while @input.getbyte(pos + count) == APOSTROPHE
187+
count
150188
end
151189

152190
# Parse [[internal link]] or [[target|display text]].
153191
def parse_internal_link
154192
@pos += 2 # skip [[
155193
start = @pos
156194

157-
# Find closing ]]
158-
close_pos = @input.index("]]", @pos)
195+
close_pos = @input.byteindex("]]", @pos)
159196
unless close_pos
160197
@text_buffer << "[["
161198
return
162199
end
163200

164-
content = @input[start...close_pos]
201+
content = @input.byteslice(start, close_pos - start)
165202
@pos = close_pos + 2
166203

167204
target, display = content.split("|", 2)
@@ -178,14 +215,13 @@ def parse_external_link
178215
@pos += 1 # skip [
179216
start = @pos
180217

181-
# Find closing ]
182-
close_pos = @input.index("]", @pos)
218+
close_pos = @input.byteindex("]", @pos)
183219
unless close_pos
184220
@text_buffer << "["
185221
return
186222
end
187223

188-
content = @input[start...close_pos]
224+
content = @input.byteslice(start, close_pos - start)
189225
@pos = close_pos + 1
190226

191227
# Split on first space: URL followed by optional display text
@@ -199,13 +235,13 @@ def parse_external_link
199235
end
200236

201237
def parse_html_tag
202-
tag_match = @input[@pos..].match(%r{\A<(/?)([a-z]+)(?: [^>]*)?\s*(/?)>}i)
203-
unless tag_match
238+
unless @input.byteindex(HTML_TAG_AT_CURSOR, @pos)
204239
@text_buffer << "<"
205240
@pos += 1
206241
return
207242
end
208243

244+
tag_match = Regexp.last_match
209245
full_match = tag_match[0]
210246
closing = !tag_match[1].empty?
211247
self_closing = !tag_match[3].empty?
@@ -238,25 +274,25 @@ def dispatch_html_tag(entry, tag_name, full_match)
238274
when :formatting
239275
handle_paired_tag(tag_name, full_match, entry.element_class)
240276
when :self_closing
241-
@pos += full_match.length
277+
@pos += full_match.bytesize
242278
@parent << entry.element_class.new
243279
end
244280
end
245281

246282
# Advance position and buffer the match as literal text.
247283
def advance_as_text(full_match)
248284
@text_buffer << full_match
249-
@pos += full_match.length
285+
@pos += full_match.bytesize
250286
end
251287

252288
# Handle <nowiki>...</nowiki> - preserves content as literal text.
253289
def handle_nowiki_tag(full_match)
254-
@pos += full_match.length
255-
close_pos = @input.index("</nowiki>", @pos)
290+
@pos += full_match.bytesize
291+
close_pos = @input.byteindex("</nowiki>", @pos)
256292

257293
if close_pos
258-
@text_buffer << @input[@pos...close_pos]
259-
@pos = close_pos + "</nowiki>".length
294+
@text_buffer << @input.byteslice(@pos, close_pos - @pos)
295+
@pos = close_pos + "</nowiki>".bytesize
260296
else
261297
@text_buffer << full_match
262298
end
@@ -265,15 +301,15 @@ def handle_nowiki_tag(full_match)
265301
# Handle paired raw tags like <code>...</code> and <pre>...</pre>.
266302
# Content inside is not parsed for wiki markup.
267303
def handle_paired_raw_tag(tag_name, full_match, element_class)
268-
@pos += full_match.length
304+
@pos += full_match.bytesize
269305
close_tag = "</#{tag_name}>"
270-
close_pos = @input.index(close_tag, @pos)
306+
close_pos = @input.byteindex(close_tag, @pos)
271307

272308
if close_pos
273309
element = element_class.new
274-
element << AST::Text.new(@input[@pos...close_pos])
310+
element << AST::Text.new(@input.byteslice(@pos, close_pos - @pos))
275311
@parent << element
276-
@pos = close_pos + close_tag.length
312+
@pos = close_pos + close_tag.bytesize
277313
else
278314
@text_buffer << full_match
279315
end
@@ -282,26 +318,28 @@ def handle_paired_raw_tag(tag_name, full_match, element_class)
282318
# Handle paired formatting tags like <s>, <u>, <sup>, <sub>.
283319
# Content inside IS parsed for wiki markup.
284320
def handle_paired_tag(tag_name, full_match, element_class)
285-
@pos += full_match.length
321+
@pos += full_match.bytesize
286322
close_tag = "</#{tag_name}>"
287-
close_pos = @input.index(close_tag, @pos)
323+
close_pos = @input.byteindex(close_tag, @pos)
288324

289325
if close_pos
290326
element = element_class.new
291-
parse_inner_content(@input[@pos...close_pos], parent: element)
327+
parse_inner_content(@input.byteslice(@pos, close_pos - @pos), parent: element)
292328
@parent << element
293-
@pos = close_pos + close_tag.length
329+
@pos = close_pos + close_tag.bytesize
294330
else
295331
@text_buffer << full_match
296332
end
297333
end
298334

299335
# Flush accumulated text buffer to the parent as a Text node.
336+
# AST::Text copies the (mutable) buffer, so clearing it afterwards
337+
# reuses the allocated capacity for the next span.
300338
def flush_text
301339
return if @text_buffer.empty?
302340

303341
@parent << AST::Text.new(@text_buffer)
304-
@text_buffer = +""
342+
@text_buffer.clear
305343
end
306344
end
307345
end

mutant.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,15 @@ matcher:
216216
- Markbridge::Parsers::MediaWiki::InlineParser#initialize
217217
- Markbridge::Parsers::MediaWiki::InlineParser#parse_inner_content
218218

219+
# InlineParser#consecutive_apostrophes_at's `count = 0` seed. The
220+
# `count = 1` mutation is equivalent because every call site sits
221+
# on an apostrophe byte (the dispatch case arm, byteindex("'")
222+
# results, and parse_bold_italic's precondition), so skipping the
223+
# zeroth-byte check changes nothing. Killing it would require
224+
# calling the helper on a non-apostrophe position, which no public
225+
# input can produce.
226+
- Markbridge::Parsers::MediaWiki::InlineParser#consecutive_apostrophes_at
227+
219228
# MediaWiki Parser#initialize's block_given? conditional and the
220229
# InlineTagRegistry kwarg plumbing. Mutations on `if block_given?`
221230
# (`if true` / `if false`) and on the `inline_tag_registry ||`

0 commit comments

Comments
 (0)