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