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.
@@ -0,0 +1,458 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+
5
+ require_relative "error"
6
+ require_relative "block_detector"
7
+
8
+ module MDOM
9
+ # 行级 Tokenizer:按 CSM §3 的分类规则逐行分类(docs/IMPLEMENTATION.md §2.1)。
10
+ #
11
+ # 分类顺序(优先级从高到低):**围栏 → 表格 → 块级公式 → `\` 转义 → `~` →
12
+ # 候选嵌入 → 排版**。
13
+ # `~` 的判定必须先于候选嵌入——`~ list.each do |item|` 是 `~` 行,不能按候选
14
+ # 行交给 Prism。
15
+ #
16
+ # 产出的最小 Token 集合见 docs/IMPLEMENTATION.md §2.3。对嵌入,Tokenizer 只
17
+ # 交出**源码原文 + 位置 + 形态**,不判断哪行是赋值、哪行要渲染——「是否注入」
18
+ # 完全由嵌入代码是否调用 `render` 决定(CSM §3.3)。
19
+ class Tokenizer
20
+ # 一个行级 token。+indent+ 是该行的 CSM 缩进(前导空格数)。
21
+ Token = Struct.new(:type, :line, :indent, :value, keyword_init: true)
22
+
23
+ # 缩进单位:1 个制表符或 4 个空格(CSM §1.1)。制表符按下表展开为空格。
24
+ INDENT_UNIT = 4
25
+
26
+ FENCE_RE = /\A(`{3,6})(.*)\z/
27
+ TILDE_RE = /\A~ (.*)\z/
28
+ # ATX 标题。第 3 组捕获可选的收尾 `#`(含其前导空白),CSM §1.2 的 `<hgroup>`
29
+ # 用法要求收尾 `#` 与引导 `#` 数量相同。
30
+ HEADING_RE = /\A(\#{1,6})[ \t]+(.*?)([ \t]+\#{1,6})?[ \t]*\z/
31
+ HRULE_RE = /\A-{3,72}\z/
32
+ # CSM §1.3 只定义一种无序列表标记(`- `)与一种有序形式(`N. `)。
33
+ ULIST_RE = /\A-[ \t]+(.*)\z/
34
+ OLIST_RE = /\A(\d{1,9})\.[ \t]+(.*)\z/
35
+ TASK_RE = /\A\[([ xX])\][ \t]+(.*)\z/
36
+ QUOTE_RE = /\A> ?(.*)\z/
37
+ CITE_RE = /\A>- ?(.*)\z/
38
+ DEF_RE = /\A:[ \t]+(.*)\z/
39
+
40
+ # CSM §1.12:表格行以 `|` 开头和结尾;单元格由未转义的 `|` 分割。
41
+ TABLE_ROW_RE = /\A\|.*\|\z/
42
+ # 分隔行的单元格:一个或多个 `-`,可选 `:` 前缀 / 后缀。
43
+ SEPARATOR_CELL_RE = /\A:?-+:?\z/
44
+ # 单元格内容:一个转义对(`\` + 任意字符)或一个非 `|` 字符,成段出现。
45
+ CELL_RUN = /(?:\\.|[^|])+/m
46
+ CELL_SEPARATOR = /\|/
47
+
48
+ # CSM §1.11:块级公式。`$$…$$` 一行成块,或 `$$` 独占一行的包围式。
49
+ MATH_FENCE = "$$"
50
+ FULL_LINE_MATH_RE = /\A\$\$(.+)\$\$\z/
51
+
52
+ END_MARKER = "__END__"
53
+
54
+ # 行首 `\` 后若是这些字符之一,`\` 同时承担行内转义,必须留给 InlineParser。
55
+ # 与 InlineParser::ESCAPABLE 保持一致(tokenizer_test 有同步断言)。
56
+ INLINE_ESCAPABLE = %w[` * _ { } [ ] ( ) # + - . ! " $ % & ' , / : ; < = > ? @ ^ | ~ \\].freeze
57
+
58
+ def initialize(detector: BlockDetector.new)
59
+ @detector = detector
60
+ end
61
+
62
+ # 把 CSM 源码切成 token 数组。
63
+ def tokenize(source)
64
+ lines = normalize(source)
65
+ tokens = []
66
+ index = 0
67
+
68
+ while index < lines.size
69
+ line = lines[index]
70
+ indent = indent_width(line)
71
+ content = line[indent..] || ""
72
+ line_number = index + 1
73
+
74
+ if content.empty?
75
+ index = emit_blank(tokens, lines, index)
76
+ next
77
+ end
78
+
79
+ fence = FENCE_RE.match(content)
80
+ if fence && indent.zero?
81
+ index = emit_fence(tokens, lines, index, indent, line_number, fence)
82
+ next
83
+ end
84
+
85
+ # 表格:连续的同缩进表格行合成一个 token(CSM §1.12)。
86
+ if table_row?(content)
87
+ index = emit_table(tokens, lines, index, indent, line_number)
88
+ next
89
+ end
90
+
91
+ # 块级公式(CSM §1.11):`$$…$$` 一行,或 `$$` 独占一行的包围式。
92
+ # 与围栏一样,「层级方式」不允许缩进,因此只在顶层识别。
93
+ if indent.zero? && content == MATH_FENCE
94
+ index = emit_fenced_math(tokens, lines, index, line_number)
95
+ next
96
+ end
97
+
98
+ if indent.zero? && full_line_math?(content)
99
+ tokens << math_token(content, line_number)
100
+ index += 1
101
+ next
102
+ end
103
+
104
+ # `\` 行首转义:只跳过块级判定。若 `\` 后是行内可转义字符,则把它留给
105
+ # InlineParser(`\*` ≠ 强调);否则 `\` 只是块级转义标记,剥离
106
+ # (`\while` → `while`)。CSM §3.11。
107
+ if content.start_with?("\\")
108
+ escaped = content.length > 1 && INLINE_ESCAPABLE.include?(content[1])
109
+ tokens << text_token(escaped ? content : content[1..], line_number, indent)
110
+ index += 1
111
+ next
112
+ end
113
+
114
+ tilde = TILDE_RE.match(content)
115
+ if tilde
116
+ tokens << Token.new(
117
+ type: :ruby_block, line: line_number, indent: indent,
118
+ value: { source: tilde[1], form: :tilde, lines: (line_number..line_number),
119
+ indent: indent, render: false }
120
+ )
121
+ index += 1
122
+ next
123
+ end
124
+
125
+ if BlockDetector.candidate_start?(content) && candidate?(content)
126
+ detection = @detector.detect(lines, index, indent)
127
+ if detection
128
+ index = emit_ruby_block(tokens, lines, index, indent, line_number, detection)
129
+ next
130
+ end
131
+ end
132
+
133
+ if indent.zero? && content == END_MARKER
134
+ tokens << Token.new(
135
+ type: :end_marker, line: line_number, indent: indent,
136
+ value: { line: line_number, data: join_tail(lines, index + 1) }
137
+ )
138
+ break
139
+ end
140
+
141
+ tokens << layout_token(content, line_number, indent)
142
+ index += 1
143
+ end
144
+
145
+ tokens
146
+ end
147
+
148
+ private
149
+
150
+ # 块判定前的预筛(CSM §3.5.4 的收窄 + A 类门控)。
151
+ #
152
+ # 判定器响应 +candidate?+ 时先问一句,从而对注定不会被判定的行——英文散文,
153
+ # 或不在 A 类白名单里的行——连 +detect+ 都不必调用,**零次 Prism 调用**。
154
+ # 自定义判定器没有这个谓词时按「值得判定」处理,行为与从前一致。
155
+ #
156
+ # +content+ 是**已剥离 CSM 缩进**的首行内容。
157
+ def candidate?(content)
158
+ return true unless @detector.respond_to?(:candidate?)
159
+
160
+ @detector.candidate?(content)
161
+ end
162
+
163
+ def normalize(source)
164
+ text = source.to_s
165
+ # 绝大多数源没有 `\r`:省掉一次全量 gsub 与它那份中间串。
166
+ text = text.gsub(/\r\n?/, "\n") if text.include?("\r")
167
+ text = text.sub(/\n+\z/, "")
168
+ return [] if text.empty?
169
+
170
+ text.split("\n", -1).map { |line| expand_leading_tabs(line) }
171
+ end
172
+
173
+ # 把行首的制表符展开为空格(制表位每 4 列一个),行中内容原样保留。
174
+ def expand_leading_tabs(line)
175
+ return line unless line.include?("\t")
176
+
177
+ width = 0
178
+ index = 0
179
+ while index < line.length
180
+ case line[index]
181
+ when " " then width += 1
182
+ when "\t" then width += INDENT_UNIT - (width % INDENT_UNIT)
183
+ else break
184
+ end
185
+ index += 1
186
+ end
187
+ return line if index.zero?
188
+
189
+ (" " * width) + (line[index..] || "")
190
+ end
191
+
192
+ # 合并连续空行,count 是空行行数(1 → 新块,3 → 新节块)。
193
+ def emit_blank(tokens, lines, index)
194
+ cursor = index
195
+ cursor += 1 while cursor < lines.size && content_of(lines[cursor]).empty?
196
+ tokens << Token.new(type: :blank, line: index + 1, indent: 0, value: { count: cursor - index })
197
+ cursor
198
+ end
199
+
200
+ # 围栏代码块整体跳过,不做任何嵌入判定(CSM §1.5)。
201
+ def emit_fence(tokens, lines, index, indent, line_number, match)
202
+ marker = match[1]
203
+ info = match[2].strip
204
+ code = []
205
+ cursor = index + 1
206
+ closed = false
207
+
208
+ while cursor < lines.size
209
+ if content_of(lines[cursor]) == marker
210
+ closed = true
211
+ break
212
+ end
213
+ code << lines[cursor]
214
+ cursor += 1
215
+ end
216
+
217
+ raise UnclosedFenceError, "unclosed code fence opened at line #{line_number}" unless closed
218
+
219
+ lang, params = split_info(info)
220
+ tokens << Token.new(
221
+ type: :fence, line: line_number, indent: indent,
222
+ value: { fence: marker.length, lang: lang, params: params, code: code.join("\n") }
223
+ )
224
+ cursor + 1
225
+ end
226
+
227
+ # -- 表格(CSM §1.12)---------------------------------------------------------
228
+
229
+ # 一行是否属于表格:以 `|` 开头和结尾。
230
+ def table_row?(content)
231
+ content.rstrip.match?(TABLE_ROW_RE)
232
+ end
233
+
234
+ # 收集连续的同缩进表格行,解析成行数组后产出一个 +:table+ token。
235
+ #
236
+ # 行的载荷是 +{ rows: [...] }+,每行 +{ cells:, header:, align: }+:
237
+ # * +cells+ —— 单元格原文(交给 Parser 做行内解析);
238
+ # * +header+ —— 是否表头(由分隔行把前一行提升而来);
239
+ # * +align+ —— 该行各列的对齐(+nil+ / +:left+ / +:center+ / +:right+)。
240
+ def emit_table(tokens, lines, index, indent, line_number)
241
+ cursor = index
242
+ cursor += 1 while cursor < lines.size && indent_width(lines[cursor]) == indent &&
243
+ table_row?(content_of(lines[cursor]))
244
+
245
+ source = (index...cursor).map { |i| content_of(lines[i]).rstrip }
246
+ tokens << Token.new(type: :table, line: line_number, indent: indent,
247
+ value: { rows: parse_table(source) })
248
+ cursor
249
+ end
250
+
251
+ # 逐行解析表格:分隔行把**前面那一行**提升为表头,并更新其后各行的对齐。
252
+ # 分隔行自身不产生数据行(CSM §1.12)。
253
+ def parse_table(lines)
254
+ rows = []
255
+ align = []
256
+
257
+ lines.each do |line|
258
+ if separator_row?(line)
259
+ align = separator_aligns(line)
260
+ rows.last[:header] = true if rows.any?
261
+ rows.last[:align] = align if rows.any?
262
+ next
263
+ end
264
+
265
+ rows << { cells: split_cells(line), header: false, align: align }
266
+ end
267
+
268
+ rows
269
+ end
270
+
271
+ # 每个单元格都是 `-` 串(可带 `:`)的行是分隔行。
272
+ def separator_row?(line)
273
+ cells = split_cells(line)
274
+ cells.any? && cells.all? { |cell| cell.match?(SEPARATOR_CELL_RE) }
275
+ end
276
+
277
+ def separator_aligns(line)
278
+ split_cells(line).map { |cell| alignment_of(cell) }
279
+ end
280
+
281
+ # `:---` → 左;`---:` → 右;`:--:` → 居中;`---` → 默认。
282
+ def alignment_of(cell)
283
+ return :center if cell.start_with?(":") && cell.end_with?(":")
284
+ return :left if cell.start_with?(":")
285
+ return :right if cell.end_with?(":")
286
+
287
+ nil
288
+ end
289
+
290
+ # 按**未转义的** `|` 切出单元格;首尾的 `|` 不产生空单元格。`\|` 原样留在
291
+ # 单元格里,交给 InlineParser 按 CSM §3.11 的转义语义处理。
292
+ #
293
+ # 用 StringScanner 一次收下一整段单元格内容(`\\.` 或非 `|` 的连续串),
294
+ # 而不是逐字符判断——表格行的单元格往往很长,这一点很划算。
295
+ def split_cells(line)
296
+ scanner = StringScanner.new(line)
297
+ cells = []
298
+ cell = line[0, 0] # 空字符串,继承 line 的编码
299
+
300
+ until scanner.eos?
301
+ if (run = scanner.scan(CELL_RUN))
302
+ cell << run
303
+ elsif scanner.skip(CELL_SEPARATOR)
304
+ cells << cell
305
+ cell = line[0, 0]
306
+ else
307
+ break # 到不了:CELL_RUN 覆盖除 `|` 之外的所有字符
308
+ end
309
+ end
310
+
311
+ cells << cell
312
+
313
+ # 移除尾部未转义 | 产生的空单元格。
314
+ # 注意:先判断尾部,再处理首部,避免 "|" 这种输入留下多余空串。
315
+ cells.pop if cells.size > 1 && cells.last.empty?
316
+
317
+ # 行首的 | 不可能被前面的反斜杠转义
318
+ cells.shift if line.start_with?("|") && !cells.empty? && cells.first.empty?
319
+
320
+ cells.map!(&:strip)
321
+ cells
322
+ end
323
+
324
+ # -- 块级公式(CSM §1.11)-----------------------------------------------------
325
+
326
+ def full_line_math?(content)
327
+ content.strip.match?(FULL_LINE_MATH_RE)
328
+ end
329
+
330
+ def math_token(content, line_number)
331
+ Token.new(type: :math_block, line: line_number, indent: 0,
332
+ value: { tex: full_line_math(content) })
333
+ end
334
+
335
+ def full_line_math(content)
336
+ content.strip[FULL_LINE_MATH_RE, 1].strip
337
+ end
338
+
339
+ # `$$` 独占一行的包围式:收集到下一个独占一行的 `$$`。行的内容原样保留
340
+ # (字面,与围栏一致);CSM §1.11。
341
+ def emit_fenced_math(tokens, lines, index, line_number)
342
+ body = []
343
+ cursor = index + 1
344
+
345
+ while cursor < lines.size && content_of(lines[cursor]) != MATH_FENCE
346
+ body << lines[cursor]
347
+ cursor += 1
348
+ end
349
+
350
+ raise UnclosedMathError, "unclosed block math opened at line #{line_number}" if cursor >= lines.size
351
+
352
+ tokens << Token.new(type: :math_block, line: line_number, indent: 0,
353
+ value: { tex: body.join("\n").strip })
354
+ cursor + 1
355
+ end
356
+
357
+ def emit_ruby_block(tokens, lines, index, indent, line_number, detection)
358
+ last = detection.end_index
359
+ source = (index..last).map { |i| drop_indent(lines[i], indent) }.join("\n")
360
+ tokens << Token.new(
361
+ type: :ruby_block, line: line_number, indent: indent,
362
+ value: { source: source, form: detection.form, lines: (line_number..(last + 1)),
363
+ indent: indent, render: detection.render }
364
+ )
365
+ last + 1
366
+ end
367
+
368
+ def layout_token(content, line_number, indent)
369
+ if (match = HEADING_RE.match(content))
370
+ return Token.new(type: :heading, line: line_number, indent: indent,
371
+ value: heading_value(match))
372
+ end
373
+
374
+ return Token.new(type: :hrule, line: line_number, indent: indent, value: {}) if HRULE_RE.match?(content)
375
+
376
+ if (match = CITE_RE.match(content))
377
+ return Token.new(type: :cite, line: line_number, indent: indent, value: { text: match[1] })
378
+ end
379
+
380
+ if (match = QUOTE_RE.match(content))
381
+ return Token.new(type: :blockquote, line: line_number, indent: indent, value: { text: match[1] })
382
+ end
383
+
384
+ if (match = ULIST_RE.match(content))
385
+ return list_item_token(match[1], line_number, indent, "-")
386
+ end
387
+
388
+ if (match = OLIST_RE.match(content))
389
+ return list_item_token(match[2], line_number, indent, "#{match[1]}.")
390
+ end
391
+
392
+ if (match = DEF_RE.match(content))
393
+ return Token.new(type: :definition, line: line_number, indent: indent, value: { text: match[1] })
394
+ end
395
+
396
+ text_token(content, line_number, indent)
397
+ end
398
+
399
+ # 标题载荷:收尾 `#` 与引导 `#` 数量相同才算「收尾」(CSM §1.2),否则那些
400
+ # `#` 只是标题正文的一部分。
401
+ def heading_value(match)
402
+ level = match[1].length
403
+ closer = match[3]
404
+ closed = !closer.nil? && closer.strip.length == level
405
+ text = match[2]
406
+ text = "#{text}#{closer}" if !closed && !closer.nil?
407
+ { level: level, text: text, closed: closed }
408
+ end
409
+
410
+ def list_item_token(text, line_number, indent, marker)
411
+ ordered = marker.end_with?(".")
412
+ task = TASK_RE.match(text)
413
+ value =
414
+ if task
415
+ { text: task[2], marker: marker, ordered: ordered, task: true, checked: task[1].casecmp?("x") }
416
+ else
417
+ { text: text, marker: marker, ordered: ordered, task: false, checked: nil }
418
+ end
419
+ Token.new(type: :list_item, line: line_number, indent: indent, value: value)
420
+ end
421
+
422
+ def text_token(text, line_number, indent)
423
+ Token.new(type: :text, line: line_number, indent: indent, value: { text: text })
424
+ end
425
+
426
+ # 围栏 info string:第一个词是语言,其余是参数。
427
+ def split_info(info)
428
+ return [nil, nil] if info.empty?
429
+
430
+ lang, _separator, params = info.partition(/\s+/)
431
+ [lang.empty? ? nil : lang, params.empty? ? nil : params]
432
+ end
433
+
434
+ def join_tail(lines, from)
435
+ return nil if from >= lines.size
436
+
437
+ lines[from..].join("\n")
438
+ end
439
+
440
+ def content_of(line)
441
+ drop_indent(line, indent_width(line))
442
+ end
443
+
444
+ # 前导空白(空格或制表符)的宽度。用 +index+ 找第一个非空白字符——比
445
+ # 「匹配一段空白再取长度」少一次子串分配。
446
+ def indent_width(line)
447
+ line.index(/[^ \t]/) || line.length
448
+ end
449
+
450
+ def drop_indent(line, count)
451
+ return line if count.zero?
452
+
453
+ index = 0
454
+ index += 1 while index < count && index < line.length && line[index] == " "
455
+ line[index..] || ""
456
+ end
457
+ end
458
+ end