mdom 0.2.0 → 0.3.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,584 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "prism"
4
+
5
+ require_relative "node/inline_nodes"
6
+
3
7
  module MDOM
4
- # Tokenizes a run of inline text into inline nodes: Text, Emphasis, Strong,
5
- # Code, Link, Image, Softbreak, Hardbreak and escaped characters.
8
+ # 行内解析器:把一段**行内文本**解析为行内节点数组(docs/IMPLEMENTATION.md §2.4)。
9
+ #
10
+ # 覆盖 CSM 的行内语法:行内代码(§1.5)、强调(§1.7)、增删(§1.8)、行内引用
11
+ # (§1.9)、进度与度量(§1.10)、公式(§1.11)、WikiLink(§2.1)、提及(§2.2)、
12
+ # 链接与图片(§2.3)、行首转义与行内转义(§3.11)、插值(§3.13),以及软换行
13
+ # 折叠与硬换行(§1.1)。
6
14
  #
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
15
+ # 产出的是**未求值**的节点:`#{}` 只保存表达式原文,公式只保存 TeX 原文。
16
+ # 块级结构(围栏、块级公式、透明 Ruby……)不属于本类。
17
+ #
18
+ # MDOM::InlineParser.parse("**粗** 与 *斜* 与 `code`")
19
+ class InlineParser
20
+ # 可以被 `\` 转义的字符(CommonMark 的 ASCII 标点,外加 CSM 用到的 `$`、`@`)。
21
+ ESCAPABLE = %w[` * _ { } [ ] ( ) # + - . ! " $ % & ' , / : ; < = > ? @ ^ | ~ \\].freeze
22
+
23
+ # 东亚(CJK)标点(CSM §1.1:`\n` 前是它们时不插入空格)。
24
+ CJK_PUNCTUATION = /
25
+ [\u3000-\u303F] # CJK 符号和标点(、。〈〉《》「」【】…)
26
+ |[\uFF01-\uFF0F] # 全角标点(! ~ /)
27
+ |[\uFF1A-\uFF20] # 全角标点(: ~ @)
28
+ |[\uFF3B-\uFF40] # 全角标点([ ~ `)
29
+ |[\uFF5B-\uFF65] # 全角标点({ ~ ・)
30
+ |[\u2013\u2014] # – —
31
+ |[\u2018-\u201D] # ‘ ’ “ ”
32
+ |[\u2026\u00B7\u30FB]
33
+ /x
14
34
 
15
- module_function
35
+ MENTION_RE = /\A@([A-Za-z0-9_][\w-]*)(?::([A-Za-z0-9_][\w-]*))?/
36
+ AUTOLINK_RE = /\A(?:[A-Za-z][A-Za-z0-9+.-]*:[^\s<>]*|[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+)\z/
37
+ DESTINATION_RE = /\A(\S*?)(?:\s+"([^"]*)")?\z/
38
+ RATIO_RE = %r{\A(\S+?)/(\S+)\z}
39
+
40
+ # 行内代码最多 2 个反引号(CSM §1.5)。
41
+ MAX_CODE_TICKS = 2
42
+
43
+ # 链接 `[]` 内允许的单个样式标签(CSM §2.3):开标记、闭标记、节点类。
44
+ # 长标记在前——`**` 要先于 `*` 试。
45
+ STYLE_MARKERS = [
46
+ ["**", "**", Node::Strong],
47
+ ["~~", "~~", Node::Del],
48
+ ["~+", "+~", Node::Ins],
49
+ ["*", "*", Node::Emphasis]
50
+ ].freeze
51
+
52
+ # 解析行内文本,返回行内节点数组。
53
+ #
54
+ # * +styles:+ 为 +false+ 时不再识别样式标签(`*` `**` `~~` `~+`)——样式不得
55
+ # 嵌套(CSM §1.7);
56
+ # * +literal:+ 为 +true+ 时只识别转义与 `#{}` 插值,其余一律按字面,用于
57
+ # 链接的 `[]` 内容(CSM §2.3)。
58
+ def self.parse(text, styles: true, literal: false)
59
+ new(styles: styles, literal: literal).parse(text)
60
+ end
16
61
 
17
- def parse(source)
18
- Scanner.new(source.to_s).read_nodes
62
+ def initialize(styles: true, literal: false)
63
+ @styles = styles
64
+ @literal = literal
19
65
  end
20
66
 
21
- class Scanner
22
- def initialize(text)
23
- @text = text
24
- @len = text.length
25
- @pos = 0
67
+ def parse(text)
68
+ @text = text.to_s
69
+ @pos = 0
70
+ @nodes = []
71
+ buffer = +""
72
+
73
+ while @pos < @text.length
74
+ char = @text[@pos]
75
+ next if handle(char, buffer)
76
+
77
+ buffer << char
78
+ @pos += 1
26
79
  end
27
80
 
28
- def read_nodes
29
- nodes = []
30
- while @pos < @len
31
- node = read_node
32
- nodes << node if node
33
- end
34
- nodes
81
+ flush(buffer)
82
+ @nodes
83
+ end
84
+
85
+ private
86
+
87
+ # 按当前模式分派一个字符,返回是否已消费。
88
+ def handle(char, buffer)
89
+ return literal_char(char, buffer) if @literal
90
+
91
+ case char
92
+ when "\\" then escape(buffer)
93
+ when "`" then code_span(buffer)
94
+ when "*" then emphasis(buffer)
95
+ when "~" then tilde_mark(buffer)
96
+ when "[" then bracket(buffer)
97
+ when "!" then image(buffer)
98
+ when "<" then autolink(buffer)
99
+ when "$" then math(buffer)
100
+ when "#" then interpolation(buffer)
101
+ when "@" then mention(buffer)
102
+ when "\n" then line_break(buffer)
35
103
  end
104
+ end
36
105
 
37
- private
38
-
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
106
+ # 字面模式:只认转义、插值与换行折叠。
107
+ def literal_char(char, buffer)
108
+ case char
109
+ when "\\" then escape(buffer)
110
+ when "#" then interpolation(buffer)
111
+ when "\n" then line_break(buffer)
50
112
  end
113
+ end
51
114
 
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
115
+ # 行内文本里的 `\`:行尾是硬换行,否则按 Markdown 转义语义(CSM §3.11)。
116
+ def escape(buffer)
117
+ following = @text[@pos + 1]
118
+ return false if following.nil?
119
+
120
+ if following == "\n"
121
+ @pos += 2
122
+ emit(buffer, Node::HardBreak.new)
123
+ return true
61
124
  end
62
125
 
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
126
+ return false unless ESCAPABLE.include?(following)
127
+
128
+ buffer << following
129
+ @pos += 2
130
+ true
131
+ end
132
+
133
+ # 软换行折叠(CSM §1.1):前一个字符是 CJK 标点则直接相连,否则折叠为一个空格。
134
+ def line_break(buffer)
135
+ previous = @pos.zero? ? nil : @text[@pos - 1]
136
+ @pos += 1
137
+ buffer << " " unless cjk_punctuation?(previous)
138
+ true
139
+ end
140
+
141
+ # `` `代码` `` / ``` ``代码`` ```(CSM §1.5)。内容按字面处理。
142
+ def code_span(buffer)
143
+ ticks = run_length("`")
144
+ return false if ticks > MAX_CODE_TICKS
145
+
146
+ after = skip_code_span(@pos, ticks)
147
+ return false if after.nil?
148
+
149
+ code = @text[(@pos + ticks)...(after - ticks)]
150
+ @pos = after
151
+ emit(buffer, Node::CodeSpan.new(code))
152
+ true
153
+ end
154
+
155
+ # `*斜体*` / `**加粗**`(CSM §1.7)。
156
+ #
157
+ # 三个条件同时满足才成立:定界符数量相同(`**` 只配 `**`)、开 `*` 的右边与
158
+ # 闭 `*` 的左边都是非空白、且强调不嵌套(内部不再识别 `*`)。任一不满足就把
159
+ # 整个定界符串按字面收下,不退化成更短的定界符。
160
+ def emphasis(buffer)
161
+ count = run_length("*")
162
+ return literal_emphasis(buffer, count) unless @styles && count <= 2
163
+ return literal_emphasis(buffer, count) if blank?(@text[@pos + count])
164
+
165
+ closing = find_emphasis_close(count)
166
+ return literal_emphasis(buffer, count) if closing.nil?
167
+
168
+ inner = @text[(@pos + count)...closing]
169
+ @pos = closing + count
170
+ klass = count == 2 ? Node::Strong : Node::Emphasis
171
+ emit(buffer, klass.new(children: InlineParser.parse(inner, styles: false)))
172
+ true
173
+ end
174
+
175
+ def literal_emphasis(buffer, count)
176
+ buffer << @text[@pos, count]
177
+ @pos += count
178
+ true
179
+ end
180
+
181
+ # `~~删除~~`(`<del>`)与 `~+增加+~`(`<ins>`)(CSM §1.8)。
182
+ def tilde_mark(buffer)
183
+ return false unless @styles
184
+
185
+ if @text[@pos, 2] == "~~"
186
+ closing = find_sequence("~~", @pos + 2)
187
+ return false if closing.nil?
188
+
189
+ inner = @text[(@pos + 2)...closing]
190
+ @pos = closing + 2
191
+ emit(buffer, Node::Del.new(children: InlineParser.parse(inner)))
192
+ true
193
+ elsif @text[@pos, 2] == "~+"
194
+ closing = find_sequence("+~", @pos + 2)
195
+ return false if closing.nil?
196
+
197
+ inner = @text[(@pos + 2)...closing]
198
+ @pos = closing + 2
199
+ emit(buffer, Node::Ins.new(children: InlineParser.parse(inner)))
200
+ true
201
+ else
202
+ false
203
+ end
204
+ end
205
+
206
+ # `[` 家族:WikiLink、进度条、度量、链接/行内引用。
207
+ def bracket(buffer)
208
+ if @text[@pos, 2] == "[["
209
+ wiki_link(buffer)
210
+ elsif @text[@pos, 3] == "[->"
211
+ bar(buffer, Node::Progress)
212
+ elsif @text[@pos, 2] == "[="
213
+ bar(buffer, Node::Meter)
214
+ else
215
+ link(buffer)
216
+ end
217
+ end
218
+
219
+ def wiki_link(buffer)
220
+ closing = find_sequence("]]", @pos + 2)
221
+ return false if closing.nil?
222
+
223
+ inner = @text[(@pos + 2)...closing]
224
+ @pos = closing + 2
225
+ target, _, label = inner.partition("|")
226
+ label = nil if label.empty?
227
+ # label 的策略与一般链接的文本相同:最多一个紧邻的样式标签,其余按字面
228
+ # (CSM §2.1、§2.3)。
229
+ emit(buffer, Node::WikiLink.new(target, label: label, children: link_label_nodes(label || target)))
230
+ true
231
+ end
232
+
233
+ # `[=m/n]` → `<meter>`,`[->m/n]` → `<progress>`(CSM §1.10)。
234
+ def bar(buffer, klass)
235
+ offset = klass == Node::Meter ? 2 : 3
236
+ closing = @text.index("]", @pos + offset)
237
+ return false if closing.nil?
238
+
239
+ ratio = @text[(@pos + offset)...closing]
240
+ match = RATIO_RE.match(ratio)
241
+ return false if match.nil?
242
+
243
+ @pos = closing + 1
244
+ emit(buffer, klass.new(match[1], match[2], children: literal_nodes(ratio)))
245
+ true
246
+ end
247
+
248
+ # `[文本](uri "title")` → `<a>`;`[文本](@uri)` → `<q>`(CSM §1.9、§2.3)。
249
+ def link(buffer)
250
+ closing = matching_bracket(@pos)
251
+ return false if closing.nil?
252
+ return false unless @text[closing + 1] == "("
253
+
254
+ paren = matching_paren(closing + 1)
255
+ return false if paren.nil?
256
+
257
+ label = @text[(@pos + 1)...closing]
258
+ destination = @text[(closing + 2)...paren]
259
+ @pos = paren + 1
260
+ emit(buffer, build_link(destination, label))
261
+ true
262
+ end
263
+
264
+ # `![alt](uri "title")` → `<img>`(CSM §2.3)。
265
+ def image(buffer)
266
+ return false unless @text[@pos + 1] == "["
267
+
268
+ closing = matching_bracket(@pos + 1)
269
+ return false if closing.nil?
270
+ return false unless @text[closing + 1] == "("
271
+
272
+ paren = matching_paren(closing + 1)
273
+ return false if paren.nil?
274
+
275
+ alt = @text[(@pos + 2)...closing]
276
+ destination = @text[(closing + 2)...paren]
277
+ uri, title = split_destination(destination)
278
+ @pos = paren + 1
279
+ emit(buffer, Node::Image.new(uri, alt: alt, title: title, children: literal_nodes(alt)))
280
+ true
281
+ end
282
+
283
+ # `<uri>` / `<mailto:a@b.c>` → `<a>`(CSM §2.3)。
284
+ def autolink(buffer)
285
+ closing = @text.index(">", @pos + 1)
286
+ return false if closing.nil?
287
+
288
+ inner = @text[(@pos + 1)...closing]
289
+ return false if inner.empty? || !AUTOLINK_RE.match?(inner)
290
+
291
+ @pos = closing + 1
292
+ emit(buffer, Node::Link.new(inner, children: literal_nodes(inner)))
293
+ true
294
+ end
295
+
296
+ # `$\TeX$` → 行内 `<span class="math">`(CSM §1.11)。`$$` 属于块级公式,这里按字面处理。
297
+ def math(buffer)
298
+ return false if @text[@pos + 1] == "$"
299
+
300
+ closing = find_sequence("$", @pos + 1)
301
+ return false if closing.nil?
302
+
303
+ tex = @text[(@pos + 1)...closing]
304
+ return false if tex.empty?
305
+
306
+ @pos = closing + 1
307
+ emit(buffer, Node::Math.new(tex, children: literal_nodes(tex)))
308
+ true
309
+ end
310
+
311
+ # `#{expr}` → 插值节点(CSM §3.13)。只保存表达式原文,不求值。
312
+ #
313
+ # 边界交给 Prism 判定:把从 `#{` 起的剩余文本包进一个双引号字符串字面量,
314
+ # Prism 给出的插值节点 location 就是 `#{}` 的范围。嵌套 `{}`、字符串与注释
315
+ # 里的 `}` 由 Ruby 自己的词法处理,不需要自研扫描器(CSM §3.12)。
316
+ def interpolation(buffer)
317
+ return false unless @text[@pos + 1] == "{"
318
+
319
+ closing = interpolation_end
320
+ return false if closing.nil?
321
+
322
+ expr = @text[(@pos + 2)...closing]
323
+ @pos = closing + 1
324
+ emit(buffer, Node::Interpolation.new(expr))
325
+ true
326
+ end
327
+
328
+ # `@user` / `@project:x` / `@tag:kw`(CSM §2.2)。`:` 前是作用域,其后是名字。
329
+ def mention(buffer)
330
+ return false if @pos.positive? && @text[@pos - 1].match?(/\w/)
331
+
332
+ match = MENTION_RE.match(@text[@pos..])
333
+ return false if match.nil?
334
+
335
+ @pos += match[0].length
336
+ scope = match[1]
337
+ name = match[2]
338
+ emit(buffer, Node::Mention.new(name || scope, kind: name ? scope : nil))
339
+ true
340
+ end
341
+
342
+ # 链接与行内引用的构造(CSM §1.9、§2.3)。
343
+ #
344
+ # `[]` 内容最多含**一个**样式标签,且必须紧邻方括号(`[*strong link*](url)`);
345
+ # 其余按字面(`#{}` 插值始终解析)。链接本身处在样式标签内部时,`[]` 内容
346
+ # 一律按字面——样式与链接不得互相嵌套。
347
+ def build_link(destination, label)
348
+ uri, title = split_destination(destination)
349
+ children = link_label_nodes(label)
350
+
351
+ if uri&.start_with?("@")
352
+ Node::InlineQuote.new(uri[1..], children: children)
353
+ else
354
+ Node::Link.new(uri, title: title, children: children)
355
+ end
356
+ end
357
+
358
+ def split_destination(destination)
359
+ match = DESTINATION_RE.match(destination.strip)
360
+ return [destination.strip, nil] if match.nil?
361
+
362
+ [match[1], match[2]]
363
+ end
364
+
365
+ # `[]` 内容的节点:至多一个紧邻方括号的样式标签,其余按字面。
366
+ def link_label_nodes(label)
367
+ wrapped = @styles ? wrapping_style(label) : nil
368
+ return literal_nodes(label) if wrapped.nil?
369
+
370
+ klass, inner = wrapped
371
+ [klass.new(children: literal_nodes(inner))]
372
+ end
373
+
374
+ # 整个 +label+ 恰好被一个样式标签包裹时返回 +[节点类, 内文]+,否则 +nil+。
375
+ def wrapping_style(label)
376
+ STYLE_MARKERS.each do |opener, closer, klass|
377
+ next if label.length <= opener.length + closer.length
378
+ next unless label.start_with?(opener) && label.end_with?(closer)
379
+
380
+ inner = label[opener.length, label.length - opener.length - closer.length]
381
+ next if blank?(inner[0]) || blank?(inner[-1])
382
+ # 内文不得再出现同族定界符(那会是第二个样式标签)
383
+ next if unescaped?(inner, opener[0])
384
+
385
+ return [klass, inner]
386
+ end
387
+ nil
388
+ end
389
+
390
+ def literal_nodes(text)
391
+ InlineParser.parse(text, literal: true)
392
+ end
393
+
394
+ # +text+ 里是否存在未被 `\` 转义的 +char+。
395
+ def unescaped?(text, char)
396
+ cursor = 0
397
+ while cursor < text.length
398
+ if text[cursor] == "\\"
399
+ cursor += 2
400
+ elsif text[cursor] == char
401
+ return true
72
402
  else
73
- content = @text[@pos...closing].gsub(/\A +| +\z/, "").gsub(/\n/, " ")
74
- @pos = closing + count
75
- Code.new(content)
403
+ cursor += 1
76
404
  end
77
405
  end
406
+ false
407
+ end
408
+
409
+ def flush(buffer)
410
+ return if buffer.empty?
411
+
412
+ @nodes << Node::Text.new(buffer.dup)
413
+ buffer.clear
414
+ end
78
415
 
79
- def read_link_or_image
80
- image = @text[@pos] == "!"
81
- @pos += 1 if image
82
- return read_plain_run unless @text[@pos] == "["
416
+ def emit(buffer, node)
417
+ flush(buffer)
418
+ @nodes << node
419
+ end
83
420
 
84
- label_start = @pos + 1
85
- closing = @text.index("]", label_start)
86
- return read_plain_run unless closing
421
+ # 从 +@pos+ 起连续 +char+ 的个数。
422
+ def run_length(char)
423
+ run_length_at(@pos, char)
424
+ end
87
425
 
88
- label = @text[label_start...closing]
89
- return read_plain_run unless @text[closing + 1] == "("
426
+ def run_length_at(pos, char)
427
+ cursor = pos
428
+ cursor += 1 while cursor < @text.length && @text[cursor] == char
429
+ cursor - pos
430
+ end
90
431
 
91
- dest_end = @text.index(")", closing + 1)
92
- return read_plain_run unless dest_end
432
+ # 从 +pos+(一个反引号序列的起点)跳到配对的结束反引号之后;找不到返回 nil。
433
+ def skip_code_span(pos, ticks)
434
+ cursor = pos + ticks
435
+ while cursor < @text.length
436
+ if @text[cursor] == "`"
437
+ run = run_length_at(cursor, "`")
438
+ return cursor + run if run == ticks
93
439
 
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)
440
+ cursor += run
98
441
  else
99
- Link.new(dest, title: title, children: InlineParser.parse(label))
442
+ cursor += 1
100
443
  end
101
444
  end
445
+ nil
446
+ end
102
447
 
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]
448
+ # 找可以充当闭定界符的 `*`:长度必须与 +count+ 相同,且左侧是非空白
449
+ # (CSM §1.7)。跳过转义、行内代码与链接——它们的 `*` 不算定界符。
450
+ def find_emphasis_close(count)
451
+ cursor = @pos + count
452
+ while cursor < @text.length
453
+ case @text[cursor]
454
+ when "\\" then cursor += 2
455
+ when "`" then cursor = skip_code_span(cursor, run_length_at(cursor, "`")) || @text.length
456
+ when "[", "!"
457
+ cursor = link_extent(cursor) || cursor + 1
458
+ when "*"
459
+ run = run_length_at(cursor, "*")
460
+ return cursor if run == count && !blank?(@text[cursor - 1])
461
+
462
+ cursor += run
463
+ else cursor += 1
111
464
  end
112
- [body, title]
113
465
  end
466
+ nil
467
+ end
114
468
 
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
469
+ # 找下一个未被转义的 +sequence+。
470
+ def find_sequence(sequence, from)
471
+ cursor = from
472
+ while (found = @text.index(sequence, cursor))
473
+ return found if found.zero? || @text[found - 1] != "\\"
474
+
475
+ cursor = found + 1
122
476
  end
477
+ nil
478
+ end
123
479
 
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
137
- 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
480
+ # 从 `[` 的位置找配对的 `]`,跳过嵌套、转义与行内代码。
481
+ def matching_bracket(open)
482
+ depth = 0
483
+ cursor = open
484
+ while cursor < @text.length
485
+ case @text[cursor]
486
+ when "\\" then cursor += 2
487
+ when "`" then cursor = skip_code_span(cursor, run_length_at(cursor, "`")) || @text.length
488
+ when "[" then depth += 1
489
+ cursor += 1
490
+ when "]"
491
+ depth -= 1
492
+ return cursor if depth.zero?
493
+
494
+ cursor += 1
495
+ else cursor += 1
149
496
  end
150
497
  end
498
+ nil
499
+ end
500
+
501
+ def matching_paren(open)
502
+ matching_pair(open, "(", ")")
503
+ end
504
+
505
+ # 若 +pos+(`[` 或 `!`)处是一个链接/图片,返回它之后的下标;否则 +nil+。
506
+ def link_extent(pos)
507
+ open = @text[pos] == "!" ? pos + 1 : pos
508
+ return nil unless @text[open] == "["
509
+
510
+ closing = matching_bracket(open)
511
+ return nil if closing.nil? || @text[closing + 1] != "("
151
512
 
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
513
+ paren = matching_paren(closing + 1)
514
+ paren && paren + 1
515
+ end
516
+
517
+ # 从 +open+(开括号的位置)找配对闭括号;跳过嵌套、反斜杠转义与引号内的括号。
518
+ def matching_pair(open, opener, closer)
519
+ depth = 0
520
+ quote = nil
521
+ cursor = open
522
+ while cursor < @text.length
523
+ char = @text[cursor]
524
+ if char == "\\"
525
+ cursor += 2
526
+ next
527
+ end
528
+ if quote
529
+ quote = nil if char == quote
530
+ cursor += 1
531
+ next
169
532
  end
170
- nil
171
- end
172
533
 
173
- def read_plain_run
174
- start = @pos
175
- while @pos < @len && !SPECIAL.match?(@text[@pos])
176
- @pos += 1
534
+ case char
535
+ when "\"", "'" then quote = char
536
+ when opener then depth += 1
537
+ when closer
538
+ depth -= 1
539
+ return cursor if depth.zero?
177
540
  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])
541
+ cursor += 1
542
+ end
543
+ nil
544
+ end
545
+
546
+ def cjk_punctuation?(char)
547
+ !char.nil? && CJK_PUNCTUATION.match?(char)
548
+ end
549
+
550
+ # 空格或换行(含制表符)。强调定界符的内侧不能是它们(CSM §1.7)。
551
+ def blank?(char)
552
+ char.nil? || char.match?(/\s/)
553
+ end
554
+
555
+ # 用 Prism 找出与当前位置的 `#{` 配对的 `}` 的下标;不是插值则返回 nil。
556
+ #
557
+ # 构造出的字面量里,插值之后若出现裸 `"`,整段就不再是合法 Ruby——Prism
558
+ # 会报错,但错误恢复仍给出插值节点,边界照常可读。代价是每个插值解析一次
559
+ # 剩余文本(各次解析互不影响)。
560
+ def interpolation_end
561
+ result = Prism.parse("\"#{@text[@pos..]}\"")
562
+ segment = first_embedded_statement(result.value)
563
+ return nil if segment.nil?
564
+
565
+ slice = segment.location.slice
566
+ # 未闭合的 `#{` 会一直吞到字符串结尾,此时的 slice 不以 `}` 收尾。
567
+ return nil unless slice.end_with?("}")
568
+
569
+ @pos + slice.length - 1
570
+ end
571
+
572
+ # 先序遍历,取第一个字符串插值节点。
573
+ def first_embedded_statement(node)
574
+ return nil unless node
575
+ return node if node.is_a?(Prism::EmbeddedStatementsNode)
576
+
577
+ node.compact_child_nodes.each do |child|
578
+ found = first_embedded_statement(child)
579
+ return found if found
182
580
  end
581
+ nil
183
582
  end
184
583
  end
185
584
  end