@@ -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
0 commit comments