mdom 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,185 +1,817 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "prism"
4
+ require "strscan"
5
+
6
+ require_relative "node/inline_nodes"
7
+
3
8
  module MDOM
4
- # Tokenizes a run of inline text into inline nodes: Text, Emphasis, Strong,
5
- # Code, Link, Image, Softbreak, Hardbreak and escaped characters.
9
+ # 行内解析器:把一段**行内文本**解析为行内节点数组(docs/IMPLEMENTATION.md §2.4)。
10
+ #
11
+ # 覆盖 CSM 的行内语法:行内代码(§1.5)、强调(§1.7)、增删(§1.8)、行内引用
12
+ # (§1.9)、进度与度量(§1.10)、公式(§1.11)、WikiLink(§2.1)、提及(§2.2)、
13
+ # 链接与图片(§2.3)、行首转义与行内转义(§3.11)、插值(§3.13),以及软换行
14
+ # 折叠与硬换行(§1.1)。
15
+ #
16
+ # 产出的是**未求值**的节点:`#{}` 只保存表达式原文,公式只保存 TeX 原文。
17
+ # 块级结构(围栏、块级公式、透明 Ruby……)不属于本类。
18
+ #
19
+ # === 扫描方式
20
+ #
21
+ # 扫描由 +StringScanner+(strscan,Ruby 自带)驱动:主循环用一条正则把**整段
22
+ # 普通字面**一次收下,只在遇到标记字符时才逐字符分派;查找类操作(找配对反引号、
23
+ # 找闭定界符、找 `]` ……)同样交给 +StringScanner+ 的正则搜索,而不是 Ruby 层的
24
+ # 逐字符 +while+ 循环。这样 +String#[]+ 与 +String#<<+ 的调用次数从「每字符一次」
25
+ # 降到「每段一次」。
6
26
  #
7
- # This is a pragmatic subset of CommonMark inline syntax. Code spans and
8
- # links/images are matched textually; emphasis/strong is resolved by finding
9
- # the next matching delimiter and recursing into the enclosed text. Delimiter
10
- # runs and exact flanking rules are intentionally simplified.
11
- module InlineParser
12
- ESCAPABLE = /[\x00`*_{}\[\]()#+\-.!>]/.freeze
13
- SPECIAL = /[\\`!*_\[\n]/.freeze
27
+ # 注意 +StringScanner+ 的位置是**字节**偏移,所以本类内部一律按字节定位,需要
28
+ # 单个字符时用 +String#byteslice+ 取出(+char_before+ 负责向前跨过一个多字节字符)。
29
+ #
30
+ # MDOM::InlineParser.parse("**粗** 与 *斜* 与 `code`")
31
+ class InlineParser
32
+ # 可以被 `\` 转义的字符(CommonMark 的 ASCII 标点,外加 CSM 用到的 `$`、`@`)。
33
+ ESCAPABLE = %w[` * _ { } [ ] ( ) # + - . ! " $ % & ' , / : ; < = > ? @ ^ | ~ \\].freeze
34
+
35
+ # 东亚(CJK)标点(CSM §1.1:`\n` 前是它们时不插入空格)。
36
+ CJK_PUNCTUATION = /
37
+ [\u3000-\u303F] # CJK 符号和标点(、。〈〉《》「」【】…)
38
+ |[\uFF01-\uFF0F] # 全角标点(! ~ /)
39
+ |[\uFF1A-\uFF20] # 全角标点(: ~ @)
40
+ |[\uFF3B-\uFF40] # 全角标点([ ~ `)
41
+ |[\uFF5B-\uFF65] # 全角标点({ ~ ・)
42
+ |[\u2013\u2014] # – —
43
+ |[\u2018-\u201D] # ‘ ’ “ ”
44
+ |[\u2026\u00B7\u30FB]
45
+ /x
46
+
47
+ # 提及的名字部分:前导 `@` 已由主循环消费,所以这里不再带 `@`(也不用 `\A`
48
+ # 锚定——+StringScanner#scan+ 就是从当前位置匹配)。
49
+ MENTION_SCAN_RE = /[A-Za-z0-9_][\w-]*(?::[A-Za-z0-9_][\w-]*)?/
50
+ AUTOLINK_RE = /\A(?:[A-Za-z][A-Za-z0-9+.-]*:[^\s<>]*|[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+)\z/
51
+ DESTINATION_RE = /\A(\S*?)(?:\s+"([^"]*)")?\z/
52
+ RATIO_RE = %r{\A(\S+?)/(\S+)\z}
53
+
54
+ # 行内代码最多 2 个反引号(CSM §1.5)。
55
+ MAX_CODE_TICKS = 2
56
+
57
+ # 链接 `[]` 内允许的单个样式标签(CSM §2.3):开标记、闭标记、节点类。
58
+ # 长标记在前——`**` 要先于 `*` 试。
59
+ STYLE_MARKERS = [
60
+ ["**", "**", Node::Strong],
61
+ ["~~", "~~", Node::Del],
62
+ ["~+", "+~", Node::Ins],
63
+ ["*", "*", Node::Emphasis]
64
+ ].freeze
65
+
66
+ # -- 扫描用正则 ----------------------------------------------------------
67
+ #
68
+ # 主循环先用 +LITERAL_RUN+ 整段收下普通字面;匹配不上时当前字符必是标记
69
+ # (两者互补),于是交给 +handle+ 逐字符分派。
14
70
 
15
- module_function
71
+ LITERAL_RUN = /[^\\`*~\[!<$#@\n]+/
72
+ ANY_CHAR = /./m
16
73
 
17
- def parse(source)
18
- Scanner.new(source.to_s).read_nodes
74
+ # 预筛的最低文本长度:更短的文本即使退化也只是常数级,不值得为它建索引
75
+ # (见 maybe_closes?)。
76
+ PREFILTER_MIN_BYTES = 4096
77
+
78
+ TICKS = /`+/
79
+ STARS = /\*+/
80
+ TILDES = /~~/
81
+ INS_CLOSE = /\+~/
82
+ DOLLAR = /\$/
83
+ WIKI_CLOSE = /\]\]/
84
+
85
+ # 查找类操作的「跳板」:只停在这些字符上,其余整段跳过。
86
+ EMPHASIS_JUMP = /[\\`\[!*]/
87
+ BRACKET_JUMP = /[\\`\[\]]/
88
+ PAREN_JUMP = /[\\()"']/
89
+ CLOSE_BRACKET = /\]/
90
+ CLOSE_ANGLE = />/
91
+ # 「转义对或目标字符」:找目标字符时用它一次跳过普通字符(见 last_unescaped)。
92
+ ESCAPE_OR_SQUARE = /\\|\]/
93
+ ESCAPE_OR_PAREN = /\\|\)/
94
+
95
+ # 分派要比较的 ASCII 码(扫描位置是字节偏移,用整数比较省掉一次字符串分配)。
96
+ BACKSLASH = "\\".ord
97
+ BACKTICK = "`".ord
98
+ STAR = "*".ord
99
+ OPEN_SQUARE = "[".ord
100
+ CLOSE_SQUARE = "]".ord
101
+ OPEN_PAREN = "(".ord
102
+ CLOSE_PAREN = ")".ord
103
+ EXCLAMATION = "!".ord
104
+ DQUOTE = '"'.ord
105
+ SQUOTE = "'".ord
106
+
107
+ # 解析行内文本,返回行内节点数组。
108
+ #
109
+ # * +styles:+ 为 +false+ 时不再识别样式标签(`*` `**` `~~` `~+`)——样式不得
110
+ # 嵌套(CSM §1.7);
111
+ # * +literal:+ 为 +true+ 时只识别转义与 `#{}` 插值,其余一律按字面,用于
112
+ # 链接的 `[]` 内容(CSM §2.3)。
113
+ def self.parse(text, styles: true, literal: false)
114
+ new(styles: styles, literal: literal).parse(text)
19
115
  end
20
116
 
21
- class Scanner
22
- def initialize(text)
23
- @text = text
24
- @len = text.length
25
- @pos = 0
26
- end
117
+ def initialize(styles: true, literal: false)
118
+ @styles = styles
119
+ @literal = literal
120
+ end
27
121
 
28
- def read_nodes
29
- nodes = []
30
- while @pos < @len
31
- node = read_node
32
- nodes << node if node
122
+ def parse(text)
123
+ @scan = StringScanner.new(text.to_s)
124
+ @nodes = []
125
+ buffer = +""
126
+ @has_close_square = nil
127
+ @has_close_paren = nil
128
+ @close_square_ready = false
129
+ @close_paren_ready = false
130
+ @link_closer_ready = false
131
+
132
+ until @scan.eos?
133
+ run = @scan.scan(LITERAL_RUN)
134
+ if run
135
+ buffer << run
136
+ next
33
137
  end
34
- nodes
138
+
139
+ char = @scan.getch
140
+ buffer << char unless handle(char, buffer)
35
141
  end
36
142
 
37
- private
143
+ flush(buffer)
144
+ @nodes
145
+ end
38
146
 
39
- def read_node
40
- case @text[@pos]
41
- when "\\" then read_escape
42
- when "`" then read_code_span
43
- when "!" then read_link_or_image
44
- when "[" then read_link_or_image
45
- when "\n" then read_break
46
- when "*" then read_emphasis
47
- when "_" then read_emphasis
48
- else read_plain_run
49
- end
147
+ private
148
+
149
+ # 按当前模式分派一个字符(该字符**已经被消费**),返回是否已处理。
150
+ #
151
+ # 契约:处理函数返回 +false+ 时,扫描位置必须与进入时相同——调用方会把
152
+ # +char+ 按字面收下。失败路径统一走 +rewind+。
153
+ def handle(char, buffer)
154
+ return literal_char(char, buffer) if @literal
155
+
156
+ case char
157
+ when "\\" then escape(buffer)
158
+ when "`" then code_span(buffer)
159
+ when "*" then emphasis(buffer)
160
+ when "~" then tilde_mark(buffer)
161
+ when "[" then bracket(buffer)
162
+ when "!" then image(buffer)
163
+ when "<" then autolink(buffer)
164
+ when "$" then math(buffer)
165
+ when "#" then interpolation(buffer)
166
+ when "@" then mention(buffer)
167
+ when "\n" then line_break(buffer)
50
168
  end
169
+ end
51
170
 
52
- def read_escape
53
- nxt = @text[@pos + 1]
54
- if nxt && ESCAPABLE.match?(nxt)
55
- @pos += 2
56
- Text.new(nxt)
57
- else
58
- @pos += 1
59
- Text.new("\\")
60
- end
171
+ # 字面模式:只认转义、插值与换行折叠。
172
+ def literal_char(char, buffer)
173
+ case char
174
+ when "\\" then escape(buffer)
175
+ when "#" then interpolation(buffer)
176
+ when "\n" then line_break(buffer)
61
177
  end
178
+ end
62
179
 
63
- def read_code_span
64
- count = 1
65
- count += 1 while @text[@pos + count] == "`"
66
- closer = "`" * count
67
- @pos += count
68
- closing = @text.index(closer, @pos)
69
- if closing.nil?
70
- @pos -= count
71
- read_plain_run
72
- else
73
- content = @text[@pos...closing].gsub(/\A +| +\z/, "").gsub(/\n/, " ")
74
- @pos = closing + count
75
- Code.new(content)
76
- end
180
+ # 行内文本里的 `\`:行尾是硬换行,否则按 Markdown 转义语义(CSM §3.11)。
181
+ def escape(buffer)
182
+ following = @scan.check(ANY_CHAR)
183
+ return false if following.nil?
184
+
185
+ if following == "\n"
186
+ @scan.skip(ANY_CHAR)
187
+ emit(buffer, Node::HardBreak.new)
188
+ return true
189
+ end
190
+
191
+ return false unless ESCAPABLE.include?(following)
192
+
193
+ @scan.skip(ANY_CHAR)
194
+ buffer << following
195
+ true
196
+ end
197
+
198
+ # 软换行折叠(CSM §1.1):前一个字符是 CJK 标点则直接相连,否则折叠为一个空格。
199
+ def line_break(buffer)
200
+ buffer << " " unless cjk_punctuation?(char_before(@scan.pos, 1))
201
+ true
202
+ end
203
+
204
+ # `` `代码` `` / ``` ``代码`` ```(CSM §1.5)。内容按字面处理。
205
+ def code_span(buffer)
206
+ start = @scan.pos
207
+ ticks = 1 + @scan.skip(TICKS).to_i
208
+ return rewind(start) if ticks > MAX_CODE_TICKS
209
+
210
+ closing = find_run(TICKS, ticks)
211
+ return rewind(start) if closing.nil?
212
+
213
+ body_start = start + ticks - 1
214
+ code = @scan.string.byteslice(body_start, closing - body_start)
215
+ @scan.pos = closing + ticks
216
+ emit(buffer, Node::CodeSpan.new(code))
217
+ true
218
+ end
219
+
220
+ # `*斜体*` / `**加粗**`(CSM §1.7)。
221
+ #
222
+ # 三个条件同时满足才成立:定界符数量相同(`**` 只配 `**`)、开 `*` 的右边与
223
+ # 闭 `*` 的左边都是非空白、且强调不嵌套(内部不再识别 `*`)。任一不满足就把
224
+ # 整个定界符串按字面收下,不退化成更短的定界符。
225
+ def emphasis(buffer)
226
+ count = 1 + @scan.skip(STARS).to_i
227
+ # 查找会移动扫描位置,失败路径要回到这里,所以先把内容起点定住。
228
+ body_start = @scan.pos
229
+ return literal_emphasis(buffer, count, body_start) unless @styles && count <= 2
230
+ return literal_emphasis(buffer, count, body_start) if blank?(@scan.check(ANY_CHAR))
231
+
232
+ closing = find_emphasis_close(count, body_start)
233
+ return literal_emphasis(buffer, count, body_start) if closing.nil?
234
+
235
+ inner = @scan.string.byteslice(body_start, closing - body_start)
236
+ @scan.pos = closing + count
237
+ klass = count == 2 ? Node::Strong : Node::Emphasis
238
+ emit(buffer, klass.new(children: InlineParser.parse(inner, styles: false)))
239
+ true
240
+ end
241
+
242
+ # 定界符不成立,整串按字面收下(CSM §1.7 不作退化),扫描位置回到 +from+。
243
+ def literal_emphasis(buffer, count, from)
244
+ @scan.pos = from
245
+ buffer << ("*" * count)
246
+ true
247
+ end
248
+
249
+ # `~~删除~~`(`<del>`)与 `~+增加+~`(`<ins>`)(CSM §1.8)。
250
+ def tilde_mark(buffer)
251
+ return false unless @styles
252
+
253
+ start = @scan.pos
254
+ if @scan.check(/~/)
255
+ @scan.skip(/~/)
256
+ body_start = @scan.pos
257
+ closing = find_sequence(TILDES, body_start)
258
+ return rewind(start) if closing.nil?
259
+
260
+ inner = @scan.string.byteslice(body_start, closing - body_start)
261
+ @scan.pos = closing + 2
262
+ emit(buffer, Node::Del.new(children: InlineParser.parse(inner)))
263
+ true
264
+ elsif @scan.check(/\+/)
265
+ @scan.skip(/\+/)
266
+ body_start = @scan.pos
267
+ closing = find_sequence(INS_CLOSE, body_start)
268
+ return rewind(start) if closing.nil?
269
+
270
+ inner = @scan.string.byteslice(body_start, closing - body_start)
271
+ @scan.pos = closing + 2
272
+ emit(buffer, Node::Ins.new(children: InlineParser.parse(inner)))
273
+ true
274
+ else
275
+ false
276
+ end
277
+ end
278
+
279
+ # `[` 家族:WikiLink、进度条、度量、链接/行内引用。整族共用同一个失败回退点——
280
+ # 失败时只消费掉的那个 `[` 由调用方按字面收下。
281
+ def bracket(buffer)
282
+ start = @scan.pos
283
+ if @scan.check(/\[/)
284
+ @scan.skip(/\[/)
285
+ wiki_link(buffer, start)
286
+ elsif @scan.check(/->/)
287
+ @scan.skip(/->/)
288
+ bar(buffer, Node::Progress, start)
289
+ elsif @scan.check(/=/)
290
+ @scan.skip(/=/)
291
+ bar(buffer, Node::Meter, start)
292
+ else
293
+ link(buffer)
294
+ end
295
+ end
296
+
297
+ def wiki_link(buffer, start)
298
+ body_start = @scan.pos
299
+ closing = find_sequence(WIKI_CLOSE, body_start)
300
+ return rewind(start) if closing.nil?
301
+
302
+ inner = @scan.string.byteslice(body_start, closing - body_start)
303
+ @scan.pos = closing + 2
304
+ target, _, label = inner.partition("|")
305
+ label = nil if label.empty?
306
+ # label 的策略与一般链接的文本相同:最多一个紧邻的样式标签,其余按字面
307
+ # (CSM §2.1、§2.3)。
308
+ emit(buffer, Node::WikiLink.new(target, label: label, children: link_label_nodes(label || target)))
309
+ true
310
+ end
311
+
312
+ # `[=m/n]` → `<meter>`,`[->m/n]` → `<progress>`(CSM §1.10)。
313
+ def bar(buffer, klass, start)
314
+ found = @scan.scan_until(CLOSE_BRACKET)
315
+ return rewind(start) if found.nil?
316
+
317
+ ratio = found.byteslice(0, found.bytesize - 1)
318
+ match = RATIO_RE.match(ratio)
319
+ return rewind(start) if match.nil?
320
+
321
+ emit(buffer, klass.new(match[1], match[2], children: literal_nodes(ratio)))
322
+ true
323
+ end
324
+
325
+ # `[文本](uri "title")` → `<a>`;`[文本](@uri)` → `<q>`(CSM §1.9、§2.3)。
326
+ def link(buffer)
327
+ start = @scan.pos
328
+ return rewind(start) unless maybe_link?(start - 1)
329
+
330
+ closing = matching_bracket(start - 1)
331
+ return rewind(start) if closing.nil?
332
+ return rewind(start) unless @scan.string.getbyte(closing + 1) == OPEN_PAREN
333
+
334
+ paren = matching_paren(closing + 1)
335
+ return rewind(start) if paren.nil?
336
+
337
+ string = @scan.string
338
+ label = string.byteslice(start, closing - start)
339
+ destination = string.byteslice(closing + 2, paren - closing - 2)
340
+ @scan.pos = paren + 1
341
+ emit(buffer, build_link(destination, label))
342
+ true
343
+ end
344
+
345
+ # `![alt](uri "title")` → `<img>`(CSM §2.3)。
346
+ def image(buffer)
347
+ start = @scan.pos
348
+ return rewind(start) unless @scan.check(/\[/)
349
+
350
+ @scan.skip(/\[/)
351
+ open = @scan.pos - 1
352
+ return rewind(start) unless maybe_link?(open)
353
+
354
+ closing = matching_bracket(open)
355
+ return rewind(start) if closing.nil?
356
+ return rewind(start) unless @scan.string.getbyte(closing + 1) == OPEN_PAREN
357
+
358
+ paren = matching_paren(closing + 1)
359
+ return rewind(start) if paren.nil?
360
+
361
+ string = @scan.string
362
+ alt = string.byteslice(open + 1, closing - open - 1)
363
+ destination = string.byteslice(closing + 2, paren - closing - 2)
364
+ uri, title = split_destination(destination)
365
+ @scan.pos = paren + 1
366
+ emit(buffer, Node::Image.new(uri, alt: alt, title: title, children: literal_nodes(alt)))
367
+ true
368
+ end
369
+
370
+ # `<uri>` / `<mailto:a@b.c>` → `<a>`(CSM §2.3)。
371
+ def autolink(buffer)
372
+ start = @scan.pos
373
+ found = @scan.scan_until(CLOSE_ANGLE)
374
+ return rewind(start) if found.nil?
375
+
376
+ inner = found.byteslice(0, found.bytesize - 1)
377
+ return rewind(start) if inner.empty? || !AUTOLINK_RE.match?(inner)
378
+
379
+ emit(buffer, Node::Link.new(inner, children: literal_nodes(inner)))
380
+ true
381
+ end
382
+
383
+ # `$\TeX$` → 行内 `<span class="math">`(CSM §1.11)。`$$` 属于块级公式,这里按字面处理。
384
+ def math(buffer)
385
+ start = @scan.pos
386
+ return rewind(start) if @scan.check(DOLLAR)
387
+
388
+ closing = find_sequence(DOLLAR, start)
389
+ return rewind(start) if closing.nil?
390
+
391
+ tex = @scan.string.byteslice(start, closing - start)
392
+ return rewind(start) if tex.empty?
393
+
394
+ @scan.pos = closing + 1
395
+ emit(buffer, Node::Math.new(tex, children: literal_nodes(tex)))
396
+ true
397
+ end
398
+
399
+ # `#{expr}` → 插值节点(CSM §3.13)。只保存表达式原文,不求值。
400
+ #
401
+ # 边界交给 Prism 判定:把从 `#{` 起的剩余文本包进一个双引号字符串字面量,
402
+ # Prism 给出的插值节点 location 就是 `#{}` 的范围。嵌套 `{}`、字符串与注释
403
+ # 里的 `}` 由 Ruby 自己的词法处理,不需要自研扫描器(CSM §3.12)。
404
+ #
405
+ # 位置算式(`start` 是 `{` 的字节位置,`length` 是 `#{expr}` 的字节长度):
406
+ # `#{expr}` 的起点是 `start - 1`,表达式是它去掉 `#{` 与 `}` 的中间段。
407
+ def interpolation(buffer)
408
+ start = @scan.pos
409
+ return rewind(start) unless @scan.check(/\{/)
410
+
411
+ length = interpolation_end(@scan.rest)
412
+ return rewind(start) if length.nil?
413
+
414
+ expr = @scan.string.byteslice(start + 1, length - 3)
415
+ @scan.pos = start + length - 1
416
+ emit(buffer, Node::Interpolation.new(expr))
417
+ true
418
+ end
419
+
420
+ # `@user` / `@project:x` / `@tag:kw`(CSM §2.2)。`:` 前是作用域,其后是名字。
421
+ #
422
+ # 前一个字符是词字符时不算提及(`a@b` 里的 `@` 只是文本)。
423
+ def mention(buffer)
424
+ return false if char_before(@scan.pos, 1)&.match?(/\w/)
425
+
426
+ matched = @scan.scan(MENTION_SCAN_RE)
427
+ return false if matched.nil?
428
+
429
+ scope, _, name = matched.partition(":")
430
+ emit(buffer, Node::Mention.new(name.empty? ? scope : name, kind: name.empty? ? nil : scope))
431
+ true
432
+ end
433
+
434
+ # 链接与行内引用的构造(CSM §1.9、§2.3)。
435
+ #
436
+ # `[]` 内容最多含**一个**样式标签,且必须紧邻方括号(`[*strong link*](url)`);
437
+ # 其余按字面(`#{}` 插值始终解析)。链接本身处在样式标签内部时,`[]` 内容
438
+ # 一律按字面——样式与链接不得互相嵌套。
439
+ def build_link(destination, label)
440
+ uri, title = split_destination(destination)
441
+ children = link_label_nodes(label)
442
+
443
+ if uri&.start_with?("@")
444
+ Node::InlineQuote.new(uri[1..], children: children)
445
+ else
446
+ Node::Link.new(uri, title: title, children: children)
77
447
  end
448
+ end
449
+
450
+ def split_destination(destination)
451
+ match = DESTINATION_RE.match(destination.strip)
452
+ return [destination.strip, nil] if match.nil?
453
+
454
+ [match[1], match[2]]
455
+ end
456
+
457
+ # `[]` 内容的节点:至多一个紧邻方括号的样式标签,其余按字面。
458
+ def link_label_nodes(label)
459
+ wrapped = @styles ? wrapping_style(label) : nil
460
+ return literal_nodes(label) if wrapped.nil?
78
461
 
79
- def read_link_or_image
80
- image = @text[@pos] == "!"
81
- @pos += 1 if image
82
- return read_plain_run unless @text[@pos] == "["
462
+ klass, inner = wrapped
463
+ [klass.new(children: literal_nodes(inner))]
464
+ end
83
465
 
84
- label_start = @pos + 1
85
- closing = @text.index("]", label_start)
86
- return read_plain_run unless closing
466
+ # 整个 +label+ 恰好被一个样式标签包裹时返回 +[节点类, 内文]+,否则 +nil+。
467
+ def wrapping_style(label)
468
+ STYLE_MARKERS.each do |opener, closer, klass|
469
+ next if label.length <= opener.length + closer.length
470
+ next unless label.start_with?(opener) && label.end_with?(closer)
87
471
 
88
- label = @text[label_start...closing]
89
- return read_plain_run unless @text[closing + 1] == "("
472
+ inner = label[opener.length, label.length - opener.length - closer.length]
473
+ next if blank?(inner[0]) || blank?(inner[-1])
474
+ # 内文不得再出现同族定界符(那会是第二个样式标签)
475
+ next if unescaped?(inner, opener[0])
90
476
 
91
- dest_end = @text.index(")", closing + 1)
92
- return read_plain_run unless dest_end
477
+ return [klass, inner]
478
+ end
479
+ nil
480
+ end
93
481
 
94
- dest, title = split_destination(@text[(closing + 2)...dest_end])
95
- @pos = dest_end + 1
96
- if image
97
- Image.new(dest, alt: label, title: title)
482
+ def literal_nodes(text)
483
+ InlineParser.parse(text, literal: true)
484
+ end
485
+
486
+ # +text+ 里是否存在未被 `\` 转义的 +char+。
487
+ def unescaped?(text, char)
488
+ cursor = 0
489
+ while cursor < text.length
490
+ if text[cursor] == "\\"
491
+ cursor += 2
492
+ elsif text[cursor] == char
493
+ return true
98
494
  else
99
- Link.new(dest, title: title, children: InlineParser.parse(label))
495
+ cursor += 1
100
496
  end
101
497
  end
498
+ false
499
+ end
500
+
501
+ def flush(buffer)
502
+ return if buffer.empty?
102
503
 
103
- def split_destination(raw)
104
- raw = raw.strip
105
- raw = raw[1..-2] if raw.start_with?("<") && raw.end_with?(">")
106
- body = raw
107
- title = nil
108
- if (m = /\A(.+?)\s+(?:"([^"]*)"|'([^']*)'|\(([^)]*)\))\z/.match(raw))
109
- body = m[1]
110
- title = m[2] || m[3] || m[4]
504
+ @nodes << Node::Text.new(buffer.dup)
505
+ buffer.clear
506
+ end
507
+
508
+ def emit(buffer, node)
509
+ flush(buffer)
510
+ @nodes << node
511
+ end
512
+
513
+ # -- 扫描器辅助 ----------------------------------------------------------
514
+
515
+ # 让扫描位置回到 +position+ 并返回 +false+:处理函数的统一失败出口。
516
+ def rewind(position)
517
+ @scan.pos = position
518
+ false
519
+ end
520
+
521
+ # 把扫描位置向前推进到 +position+,越界时停在文末——`\` 正好是最后一个字符时,
522
+ # 「跳过转义对」的 +2 会多出一格(+StringScanner#pos=+ 对越界值直接抛
523
+ # +RangeError+)。
524
+ def advance_to(position)
525
+ size = @scan.string.bytesize
526
+ @scan.pos = position > size ? size : position
527
+ end
528
+
529
+ # 两级预筛的第一个问题:整段文本里到底有没有 `]`(C 层的 +include?+,一次,极廉)。
530
+ def any_close_square?
531
+ @has_close_square = @scan.string.include?("]") if @has_close_square.nil?
532
+ @has_close_square
533
+ end
534
+
535
+ # 同上,问的是 `)`。
536
+ def any_close_paren?
537
+ @has_close_paren = @scan.string.include?(")") if @has_close_paren.nil?
538
+ @has_close_paren
539
+ end
540
+
541
+ # +open+ 之后是否**可能**存在配对的闭定界符。
542
+ #
543
+ # 两级:先排除「整段根本没有该字符」;只有 +PREFILTER_MIN_BYTES+ 以上的文本才
544
+ # 进一步查「最后一个位置」——收集位置是 Ruby 层循环,短文本(绝大多数行内段)
545
+ # 哪怕退化也只是常数级,不值得付这个固定成本。
546
+ def maybe_closes?(has_any, last_position, open)
547
+ return false unless has_any
548
+ return true if @scan.string.bytesize < PREFILTER_MIN_BYTES
549
+
550
+ last = last_position
551
+ !last.nil? && last > open
552
+ end
553
+
554
+ # 链接/图片的乐观预筛:+open+(`[` 的位置)之后必须存在「未被转义、且后面紧跟
555
+ # `(`」的 `]`——否则既不可能是链接也不可能是图片。
556
+ #
557
+ # 少了这一步,`"[(" * 2000 + ")]" * 2000` 这类输入仍会退化:预筛只看「有没有 `]`」
558
+ # 是拦不住的(`]` 很多),而 `matching_bracket` 仍要一个个扫。
559
+ def maybe_link?(open)
560
+ return false unless any_close_square?
561
+ return true if @scan.string.bytesize < PREFILTER_MIN_BYTES
562
+
563
+ last = last_link_closer
564
+ !last.nil? && last > open
565
+ end
566
+
567
+ # 未被转义的、后面紧跟 `(` 的最后一个 `]` 的位置(没有则 +nil+)。
568
+ def last_link_closer
569
+ unless @link_closer_ready
570
+ @last_link_closer = last_unescaped(CLOSE_SQUARE, ESCAPE_OR_SQUARE) do |position|
571
+ @scan.string.getbyte(position + 1) == OPEN_PAREN
111
572
  end
112
- [body, title]
113
- end
114
-
115
- # A newline preceded by two spaces is a hard break; otherwise soft break.
116
- def read_break
117
- # Two spaces immediately before the newline => hard break.
118
- before = @text[0...@pos]
119
- hard = before.end_with?(" ")
120
- @pos += 1
121
- hard ? Hardbreak.new : Softbreak.new
122
- end
123
-
124
- def read_emphasis
125
- # Double marker -> strong, single -> emphasis.
126
- if @text[@pos, 2] == "**" || @text[@pos, 2] == "__"
127
- open = @text[@pos, 2]
128
- idx = @pos + 2
129
- closer = find_closer(idx, open[0], open.length)
130
- if closer
131
- inner = @text[idx...closer]
132
- @pos = closer + open.length
133
- Strong.new(children: InlineParser.parse(inner))
134
- else
135
- read_plain_run
136
- end
573
+ @link_closer_ready = true
574
+ end
575
+ @last_link_closer
576
+ end
577
+
578
+ # 未被转义的最后一个 `]` 的字节位置(没有则 +nil+),供 +matching_bracket+ 预筛。
579
+ # 惰性算一次,之后每次预筛只是一次整数比较——不能让它拖慢常见的「配对成功」路径。
580
+ def last_close_square
581
+ unless @close_square_ready
582
+ @last_close_square = last_unescaped(CLOSE_SQUARE, ESCAPE_OR_SQUARE)
583
+ @close_square_ready = true
584
+ end
585
+ @last_close_square
586
+ end
587
+
588
+ # 未被转义的最后一个 `)` 的位置,供 +matching_paren+ 预筛。
589
+ def last_close_paren
590
+ unless @close_paren_ready
591
+ @last_close_paren = last_unescaped(CLOSE_PAREN, ESCAPE_OR_PAREN)
592
+ @close_paren_ready = true
593
+ end
594
+ @last_close_paren
595
+ end
596
+
597
+ # 按 +matching_*+ 的扫描规则找「未被转义」的最后一个 +byte+ 的位置。
598
+ #
599
+ # 只求**保守**:代码串、引号内的出现也一并记入(宁可说「有」)——预筛只用于排除
600
+ # 「剩余文本里根本不存在该字符」这种必然无解的情形。
601
+ def last_unescaped(byte, pattern)
602
+ string = @scan.string
603
+ return nil unless string.include?(byte.chr)
604
+
605
+ scanner = StringScanner.new(string)
606
+ last = nil
607
+ while (ahead = scanner.exist?(pattern))
608
+ at = scanner.pos + ahead - scanner.matched_size
609
+ if string.getbyte(at) == byte
610
+ last = at if !block_given? || yield(at)
611
+ scanner.pos = at + 1
612
+ elsif at + 2 <= string.bytesize
613
+ scanner.pos = at + 2 # 转义对:连下一个字符一起跳过
137
614
  else
138
- open_ch = @text[@pos]
139
- idx = @pos + 1
140
- # A single marker must close on a lone delimiter, not part of "**".
141
- closer = find_closer(idx, open_ch, 1, skip_double: true)
142
- if closer
143
- inner = @text[idx...closer]
144
- @pos = closer + 1
145
- Emphasis.new(children: InlineParser.parse(inner))
146
- else
147
- read_plain_run
148
- end
615
+ scanner.pos = string.bytesize
616
+ end
617
+ end
618
+ last
619
+ end
620
+
621
+ # 从当前位置向后搜索 +pattern+:找到就返回匹配**起点**的字节位置,并把扫描位置
622
+ # 移到匹配之后;找不到返回 +nil+。
623
+ #
624
+ # 用 +exist?+ 而不是 +skip_until+——后者会把「跳过的整段」建成字符串返回,
625
+ # 而这里只要位置。
626
+ def seek(pattern)
627
+ ahead = @scan.exist?(pattern)
628
+ return nil if ahead.nil?
629
+
630
+ start = @scan.pos + ahead - @scan.matched_size
631
+ @scan.pos += ahead
632
+ start
633
+ end
634
+
635
+ # 从当前位置找长度恰好为 +count+ 的 +re+ 串,返回其起点;没有则 +nil+。
636
+ # (不等于 +count+ 的整串会被跳过,与原逐字符实现一致。)
637
+ def find_run(pattern, count)
638
+ cursor = @scan.pos
639
+ while seek(pattern)
640
+ return @scan.pos - @scan.matched_size if @scan.matched_size == count
641
+ end
642
+ @scan.pos = cursor
643
+ nil
644
+ end
645
+
646
+ # 从一个反引号串之后找**等长**的关闭串,返回其之后的位置;找不到 +nil+。
647
+ def skip_code_span_after(ticks)
648
+ while seek(TICKS)
649
+ return @scan.pos if @scan.matched_size == ticks
650
+ end
651
+ nil
652
+ end
653
+
654
+ # 找可以充当闭定界符的 `*`:长度必须与 +count+ 相同,且左侧是非空白
655
+ # (CSM §1.7)。跳过转义、行内代码与链接——它们的 `*` 不算定界符。
656
+ def find_emphasis_close(count, from)
657
+ @scan.pos = from
658
+ while (found = seek(EMPHASIS_JUMP))
659
+ case @scan.string.getbyte(found)
660
+ when BACKSLASH
661
+ advance_to(found + 2)
662
+ when BACKTICK
663
+ @scan.pos = found
664
+ @scan.pos = skip_code_span_after(@scan.skip(TICKS).to_i) || @scan.string.bytesize
665
+ when OPEN_SQUARE, EXCLAMATION
666
+ @scan.pos = link_extent(found) || found + 1
667
+ when STAR
668
+ @scan.pos = found
669
+ run = @scan.skip(STARS).to_i
670
+ return found if run == count && !blank?(char_before(found))
149
671
  end
150
672
  end
673
+ nil
674
+ end
675
+
676
+ # 找下一个未被转义的 +re+ 匹配,返回起点字节位置;找不到 +nil+。
677
+ # 失败时扫描位置落在文末,调用方负责 +rewind+。
678
+ def find_sequence(pattern, from)
679
+ @scan.pos = from
680
+ while (found = seek(pattern))
681
+ return found unless char_before(found) == "\\"
682
+ end
683
+ nil
684
+ end
685
+
686
+ # 从 `[` 的位置找配对的 `]`,跳过嵌套、转义与行内代码。
687
+ def matching_bracket(open)
688
+ # 乐观预筛:`[` 之后没有未被转义的 `]` 时必然找不到配对。少了这一步,
689
+ # 「大量未闭合 `[`」的输入会退化成 O(n²)——每个 `[` 都要扫到文末。
690
+ return nil unless maybe_closes?(any_close_square?, last_close_square, open)
151
691
 
152
- # Find the index of a closing delimiter, scanning from +from+. With
153
- # +skip_double+, a doubled delimiter is skipped (so "*a **b** c*" resolves
154
- # the outer emphasis to the final lone '*', recursing into the strong).
155
- def find_closer(from, ch, width, skip_double: false)
156
- i = from
157
- while i < @len
158
- if @text[i] == ch
159
- if width > 1
160
- return i if @text[i, width] == ch * width
161
- elsif @text[i, 2] == ch * 2
162
- i += 2 # skip doubled marker entirely
163
- next
164
- elsif @text[i, width] == ch * width
165
- return i
166
- end
167
- end
168
- i += 1
692
+ depth = 0
693
+ @scan.pos = open
694
+ while (found = seek(BRACKET_JUMP))
695
+ case @scan.string.getbyte(found)
696
+ when BACKSLASH
697
+ advance_to(found + 2)
698
+ when BACKTICK
699
+ @scan.pos = found
700
+ @scan.pos = skip_code_span_after(@scan.skip(TICKS).to_i) || @scan.string.bytesize
701
+ when OPEN_SQUARE
702
+ depth += 1
703
+ when CLOSE_SQUARE
704
+ depth -= 1
705
+ return found if depth.zero?
169
706
  end
170
- nil
171
707
  end
708
+ nil
709
+ end
710
+
711
+ def matching_paren(open)
712
+ return nil unless maybe_closes?(any_close_paren?, last_close_paren, open)
713
+
714
+ matching_pair(open, OPEN_PAREN, CLOSE_PAREN)
715
+ end
716
+
717
+ # 若 +pos+(`[` 或 `!` 的位置)处是一个链接/图片,返回它之后的位置;否则 +nil+。
718
+ def link_extent(pos)
719
+ open = @scan.string.getbyte(pos) == EXCLAMATION ? pos + 1 : pos
720
+ return nil unless @scan.string.getbyte(open) == OPEN_SQUARE
721
+ return nil unless maybe_link?(open)
722
+
723
+ closing = matching_bracket(open)
724
+ return nil if closing.nil? || @scan.string.getbyte(closing + 1) != OPEN_PAREN
172
725
 
173
- def read_plain_run
174
- start = @pos
175
- while @pos < @len && !SPECIAL.match?(@text[@pos])
176
- @pos += 1
726
+ paren = matching_paren(closing + 1)
727
+ paren && paren + 1
728
+ end
729
+
730
+ # 从 +open+(开括号的位置)找配对闭括号;跳过嵌套、反斜杠转义与引号内的括号。
731
+ # 定界符用 ASCII 码比较(扫描位置是字节偏移)。
732
+ def matching_pair(open, opener, closer)
733
+ depth = 0
734
+ quote = nil
735
+ @scan.pos = open
736
+ while (found = seek(PAREN_JUMP))
737
+ byte = @scan.string.getbyte(found)
738
+ if byte == BACKSLASH
739
+ advance_to(found + 2)
740
+ next
741
+ end
742
+ if quote
743
+ quote = nil if byte == quote
744
+ next
177
745
  end
178
- # Guarantee forward progress so a special char that couldn't be parsed
179
- # as its own construct is treated as literal text.
180
- @pos += 1 if @pos == start
181
- Text.new(@text[start...@pos])
746
+
747
+ case byte
748
+ when DQUOTE, SQUOTE then quote = byte
749
+ when opener then depth += 1
750
+ when closer
751
+ depth -= 1
752
+ return found if depth.zero?
753
+ end
754
+ end
755
+ nil
756
+ end
757
+
758
+ # +position+(默认当前位置)之前第 +offset+ 个字符(+0+ = 紧邻的上一个字符,
759
+ # 调用方传 +1+ 表示「跳过紧邻的那一个再看」);越过文本开头时返回 +nil+。
760
+ # UTF-8 下最多回退 3 个字节,不做多余分配。
761
+ def char_before(position = @scan.pos, offset = 0)
762
+ string = @scan.string
763
+ index = position
764
+ (offset + 1).times do
765
+ return nil if index <= 0
766
+
767
+ index -= 1
768
+ index -= 1 while index.positive? && (string.getbyte(index) & 0xC0) == 0x80
769
+ end
770
+
771
+ finish = position
772
+ string.byteslice(index, finish - index)
773
+ end
774
+
775
+ def cjk_punctuation?(char)
776
+ !char.nil? && CJK_PUNCTUATION.match?(char)
777
+ end
778
+
779
+ # 空格或换行(含制表符)。强调定界符的内侧不能是它们(CSM §1.7)。
780
+ def blank?(char)
781
+ char.nil? || char.match?(/\s/)
782
+ end
783
+
784
+ # 用 Prism 找出从 +rest+(`#{` 之后的剩余文本)起的 `#{...}` 的**字节长度**;
785
+ # 不是插值则返回 nil。
786
+ #
787
+ # 构造出的字面量里,插值之后若出现裸 `"`,整段就不再是合法 Ruby——Prism
788
+ # 会报错,但错误恢复仍给出插值节点,边界照常可读。代价是每个插值解析一次
789
+ # 剩余文本(各次解析互不影响)。
790
+ def interpolation_end(rest)
791
+ source = +'"#'
792
+ source << rest
793
+ source << '"'
794
+ result = Prism.parse(source)
795
+ segment = first_embedded_statement(result.value)
796
+ return nil if segment.nil?
797
+
798
+ slice = segment.location.slice
799
+ # 未闭合的 `#{` 会一直吞到字符串结尾,此时的 slice 不以 `}` 收尾。
800
+ return nil unless slice.end_with?("}")
801
+
802
+ slice.bytesize
803
+ end
804
+
805
+ # 先序遍历,取第一个字符串插值节点。
806
+ def first_embedded_statement(node)
807
+ return nil unless node
808
+ return node if node.is_a?(Prism::EmbeddedStatementsNode)
809
+
810
+ node.compact_child_nodes.each do |child|
811
+ found = first_embedded_statement(child)
812
+ return found if found
182
813
  end
814
+ nil
183
815
  end
184
816
  end
185
817
  end