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,433 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "error"
6
+
7
+ module MDOM
8
+ # 用 Ruby 官方解析器(Prism)判定「不带 `begin` 前缀的 Ruby 块」与
9
+ # 「引导块关键字段落」(CSM §3.5)。
10
+ #
11
+ # 之所以必须交给 Prism,是因为 `do` / `end` 在英语里也是常见单词,纯词法
12
+ # 无法区分「Ruby 块」与「英文散文」(CSM §3.5.1)。判定只在 CSM → AST1
13
+ # 这一侧发生(docs/IMPLEMENTATION.md §1.1);AST1 一旦产出,后续阶段不再
14
+ # 需要任何词法能力。
15
+ #
16
+ # 判定按候选块**增量 parse**(CSM §3.5.4):从候选行开始逐行增加,只解析
17
+ # 这一段,而不是整篇一次解析——文档里的英文散文可能构成语法错误,整篇解析
18
+ # 会让一处错误波及全篇。
19
+ class BlockDetector
20
+ # 会引导块的关键字(CSM §3.5.1 的 B 类)。大小写严格匹配。
21
+ GUIDING_KEYWORDS = %w[begin while until for if unless case def class module].freeze
22
+
23
+ # 关键字 → Prism 节点类。
24
+ KEYWORD_NODES = {
25
+ "begin" => Prism::BeginNode,
26
+ "while" => Prism::WhileNode,
27
+ "until" => Prism::UntilNode,
28
+ "for" => Prism::ForNode,
29
+ "if" => Prism::IfNode,
30
+ "unless" => Prism::UnlessNode,
31
+ "case" => Prism::CaseNode,
32
+ "def" => Prism::DefNode,
33
+ "class" => Prism::ClassNode,
34
+ "module" => Prism::ModuleNode
35
+ }.freeze
36
+
37
+ # 判定结果。
38
+ #
39
+ # * +end_index+ —— 块尾行在 +lines+ 中的下标(0 起);
40
+ # * +form+ —— +:begin+(B 类)/ +:free+(A 类);
41
+ # * +render+ —— 是否以 `.render` 收尾。
42
+ Detection = Struct.new(:end_index, :form, :render, keyword_init: true)
43
+
44
+ # 候选开始后无条件尝试解析的行数(不论该行是否含 `end` / `}`)。散文候选通常
45
+ # 在第一行就是硬语法错误或完整非块语句,这一小段探针能让它立刻退出,避免把
46
+ # 整篇文档拼进 code、再到每个闭合行上重复 parse(见 docs/IMPLEMENTATION.md §2.2.4)。
47
+ EARLY_PROBE_LINES = 4
48
+
49
+ # C1-min 的 A 类候选开头。`[` 还要在 #classify 里排除 `[[`(WikiLink 优先)。
50
+ CANDIDATE_PREFIXES = ["(", "{", "%", "_", "::", "["].freeze
51
+
52
+ # 行首是否落在 A 类候选的预筛范围内(字母或 CANDIDATE_PREFIXES)。
53
+ def self.candidate_start?(content)
54
+ content.match?(/\A[A-Za-z]/) ||
55
+ CANDIDATE_PREFIXES.any? { |prefix| content.start_with?(prefix) }
56
+ end
57
+
58
+ # 判定器的配置。
59
+ #
60
+ # * +free_blocks:+ —— **A 类(自由块)判定的总开关**,默认 +true+(CSM §3.5)。
61
+ # 置 +false+ 时不做任何 A 类判定,命中行按**排版**处理(CSM §3.5.3,不报错)。
62
+ # B 类(引导关键字)与 `~` 行不受影响。
63
+ # * +free_blocks_allowed:+ —— A 类的**白名单**,默认 +nil+(不限制)。给出后,
64
+ # 只有行首标识符命中的行才尝试 A 类判定:+String+ 按标识符**精确相等**匹配
65
+ # (大小写严格),+Regexp+ 对**已剥离 CSM 缩进的整行**做 +match?+(要不要锚定
66
+ # 行首由自己写的 `\A` 决定)。空数组等价于 +free_blocks: false+。
67
+ #
68
+ # 之所以要这道闸门:英文散文里「以字母开头的行」比比皆是,每行都交给 Prism
69
+ # 增量 parse 会让一篇普通文档产生几百到上万次调用(docs/IMPLEMENTATION.md §2.2.4)。
70
+ def initialize(free_blocks: true, free_blocks_allowed: nil)
71
+ @free_blocks = free_blocks
72
+ @free_blocks_allowed = free_blocks_allowed
73
+ end
74
+
75
+ # 行首是否值得进入块判定——CSM §3.5.4 的「收窄」,外加 A 类门控。
76
+ #
77
+ # Tokenizer 用它做预筛,从而对注定不会被判定的行连 +detect+ 都不必调用。
78
+ # +first+ 是**已剥离 CSM 缩进**的首行内容。B 类与引导关键字一律值得判定。
79
+ def candidate?(first)
80
+ kind, = classify(first)
81
+ return false unless kind
82
+
83
+ kind == :keyword || free_block_candidate?(first)
84
+ end
85
+
86
+ # 在 +lines+(已规范化:tab 展开、去 `\r`)中,从 +start_index+ 起判定候选块。
87
+ #
88
+ # +indent+ 是候选行的 CSM 缩进(前导空格数)。返回 +Detection+ 或 +nil+。
89
+ #
90
+ # 抛出 +CrossLevelError+,当 Prism 判定为块、但块尾行的缩进与块首行不同
91
+ # (CSM §3.5.2 条件 1)。
92
+ def detect(lines, start_index, indent)
93
+ first = drop_indent(lines[start_index], indent)
94
+ kind, keyword = classify(first)
95
+ return nil unless kind
96
+ # A 类被关掉或行首不在白名单里 → 按排版处理,且**在碰 Prism 之前**就收手。
97
+ return nil if kind == :call && !free_block_candidate?(first)
98
+
99
+ # 增量 parse 只在**候选行到同级块尾的范围内**进行(CSM §3.5.4):范围由更浅
100
+ # 的缩进收尾,**空行不中断**——块内的空行是 Ruby 的普通空行,交给 prism 决定
101
+ # 块的实际收尾。先无条件探前几行,让散文候选就地失败。
102
+ code = String.new
103
+ cursor = start_index
104
+ early_stop = [start_index + EARLY_PROBE_LINES - 1, lines.size - 1].min
105
+
106
+ while cursor <= early_stop
107
+ line = lines[cursor]
108
+ boundary = cursor > start_index && boundary_line?(line, indent)
109
+ code << drop_indent(line, indent) << "\n"
110
+
111
+ verdict = evaluate(code, lines, start_index, cursor, indent, kind, keyword,
112
+ closing: closing_line?(line))
113
+ return verdict unless verdict == :continue
114
+ return nil if boundary
115
+
116
+ cursor += 1
117
+ end
118
+ return nil if cursor >= lines.size
119
+
120
+ closers = closing_candidates(lines, cursor, indent)
121
+ return nil if closers.empty?
122
+
123
+ source, offsets = prefix_source(lines, start_index, closers.last, indent)
124
+ divide_and_conquer(source, offsets, lines, start_index, closers,
125
+ indent, kind, keyword)
126
+ end
127
+
128
+ private
129
+
130
+ # 解析一个前缀并给出结论:+Detection+ / +nil+ / +:continue+。
131
+ # +closing+ 为 false 时,完整但非块的前缀还要检查下一行是否是 Ruby 的
132
+ # leading-dot 续行(`foo\n .each do ... end`)。
133
+ def evaluate(code, lines, start_index, last, indent, kind, keyword,
134
+ closing:, result: nil)
135
+ result ||= parse(code)
136
+ return nil if result.nil?
137
+ # 错误恢复可能把散文解析成 message_loc 为空(长度为 0 / 不存在)的
138
+ # CallNode,例如 `I do this till end.`;这种节点不能作为自由块。
139
+ return nil if kind == :call && result.failure? && empty_call_message?(result)
140
+ return :continue if result.failure? && incomplete?(result, code)
141
+ return nil unless result.success?
142
+
143
+ statements = result.value.statements.body
144
+ # 顶层已经不止一条语句:第一条既已闭合,再追加行也覆盖不到整段。
145
+ return nil if statements.size >= 2
146
+
147
+ node = statements.last
148
+ return :continue if node.nil?
149
+
150
+ line_count = last - start_index + 1
151
+ loc = node.location
152
+ last_line = code.lines.last.to_s.rstrip
153
+ covers = loc.start_line == 1 &&
154
+ loc.start_column.zero? &&
155
+ loc.end_line == line_count &&
156
+ loc.end_column == last_line.bytesize
157
+ return :continue unless covers
158
+
159
+ match = kind == :keyword ? keyword_match(node, keyword) : call_match(node)
160
+ if match
161
+ assert_same_indent!(lines, start_index, last, indent)
162
+ return Detection.new(end_index: last, form: match[:form], render: match[:render])
163
+ end
164
+ return :continue if !closing && leading_continuation?(lines, last)
165
+
166
+ nil
167
+ end
168
+
169
+ # 对闭合候选行做分治:找到**最早**一个不再 incomplete 的前缀。真实块在闭合前
170
+ # 始终 incomplete,闭合后才会成为完整语句,于是可以在候选下标上二分,而不是逐个
171
+ # 闭合行调用 Prism。
172
+ def divide_and_conquer(source, offsets, lines, start_index, closers,
173
+ indent, kind, keyword)
174
+ low = 0
175
+ high = closers.size - 1
176
+ first = nil
177
+ cached = nil
178
+
179
+ while low <= high
180
+ mid = (low + high) / 2
181
+ last = closers[mid]
182
+ code = prefix(source, offsets, start_index, last)
183
+ result = parse(code)
184
+ return nil if result.nil?
185
+ return nil if kind == :call && result.failure? && empty_call_message?(result)
186
+
187
+ if result.failure? && incomplete?(result, code) && !first_statement_closed?(result)
188
+ low = mid + 1
189
+ else
190
+ first = mid
191
+ cached = [last, code, result]
192
+ high = mid - 1
193
+ end
194
+ end
195
+ return nil if first.nil?
196
+
197
+ last, code, result = cached
198
+ verdict = evaluate(code, lines, start_index, last, indent, kind, keyword,
199
+ closing: true, result: result)
200
+ return verdict unless verdict == :continue
201
+
202
+ (first + 1...closers.size).each do |index|
203
+ last = closers[index]
204
+ code = prefix(source, offsets, start_index, last)
205
+ verdict = evaluate(code, lines, start_index, last, indent, kind, keyword, closing: true)
206
+ return verdict unless verdict == :continue
207
+ end
208
+ nil
209
+ end
210
+
211
+ # 前缀末尾可能落在后续散文的未完成构造里,使整段 parse 报 incomplete;但第一个
212
+ # 顶层语句其实已经闭合。此时不能把它当成“块还没结束”而向右二分。
213
+ def first_statement_closed?(result)
214
+ node = result.value.statements.body.first
215
+ return false if node.nil?
216
+
217
+ location = node.location
218
+ return false unless location.start_line == 1 && location.start_column.zero?
219
+
220
+ result.errors.all? { |error| error.location.start_offset > location.end_offset }
221
+ end
222
+
223
+ def prefix(source, offsets, start_index, last)
224
+ source.byteslice(0, offsets[last - start_index])
225
+ end
226
+
227
+ # 一次性构造候选到最后一个闭合行的源码,并记下每行结束的字节偏移;分治的各次
228
+ # 探测只需要 byteslice,不再反复 map/join。
229
+ def prefix_source(lines, start_index, last_index, indent)
230
+ parts = Array.new(last_index - start_index + 1)
231
+ offsets = Array.new(parts.size)
232
+ bytes = 0
233
+
234
+ parts.each_index do |index|
235
+ text = drop_indent(lines[start_index + index], indent)
236
+ parts[index] = text
237
+ bytes += text.bytesize + 1
238
+ offsets[index] = bytes
239
+ end
240
+
241
+ ["#{parts.join("\n")}\n", offsets]
242
+ end
243
+
244
+ # 闭合行候选:含 `end` 或 `}`,且与旧实现使用同一套正则收窄。扫描到同级块尾
245
+ # (更浅的非空行,包含在内)为止。
246
+ def closing_candidates(lines, from, indent)
247
+ candidates = []
248
+ cursor = from
249
+
250
+ while cursor < lines.size
251
+ line = lines[cursor]
252
+ candidates << cursor if closing_line?(line)
253
+ break if boundary_line?(line, indent)
254
+
255
+ cursor += 1
256
+ end
257
+ candidates
258
+ end
259
+
260
+ def closing_line?(line)
261
+ return false unless line.include?("end") || line.include?("}")
262
+
263
+ line =~ /(([^A-Za-z0-9_-]|^)end([^A-Za-z0-9_-]|$))|(})/
264
+ end
265
+
266
+ # 非空且缩进比块首更浅的行是块尾边界;它**包含在候选范围内**,供跨级缩进校验。
267
+ def boundary_line?(line, indent)
268
+ !line.strip.empty? && indent_width(line) < indent
269
+ end
270
+
271
+ # Ruby 的 leading-dot 续行:`foo` 下一行以 `.` / `&.` 开头时仍属于同一表达式,
272
+ # 因此完整但非块的前缀不能立刻判死。空行与注释行跳过(保守处理)。
273
+ def leading_continuation?(lines, index)
274
+ cursor = index + 1
275
+
276
+ while cursor < lines.size
277
+ stripped = lines[cursor].strip
278
+ if stripped.empty? || stripped.start_with?("#")
279
+ cursor += 1
280
+ next
281
+ end
282
+
283
+ return stripped.start_with?(".") || stripped.start_with?("&.")
284
+ end
285
+ false
286
+ end
287
+
288
+ # 收窄:行首词是引导块关键字 → B 类;否则字母或 C1-min 开头 → A 类。
289
+ # `[[` 一律按 WikiLink 处理,不进入 Ruby 候选;`__END__` 留给 Tokenizer。
290
+ def classify(first)
291
+ return [nil, nil] if first == "__END__" || first.start_with?("[[")
292
+
293
+ word = first[/\A[A-Za-z_][A-Za-z0-9_]*/]
294
+ return [:keyword, word] if word && GUIDING_KEYWORDS.include?(word)
295
+ return [:call, nil] if self.class.candidate_start?(first)
296
+
297
+ [nil, nil]
298
+ end
299
+
300
+ # A 类(自由块)的候选资格:总开关 + 白名单。
301
+ #
302
+ # 与 +classify+ 共用同一条「行首标识符」定义,所以白名单里的 +String+ 与
303
+ # B 类关键字一样是**精确相等**(`"list"` 不匹配 `lists.each do`),不做前缀模糊。
304
+ def free_block_candidate?(first)
305
+ return false unless @free_blocks
306
+ return true if @free_blocks_allowed.nil?
307
+
308
+ word = first[/\A[A-Za-z_][A-Za-z0-9_]*/]
309
+ @free_blocks_allowed.any? do |entry|
310
+ entry.is_a?(Regexp) ? entry.match?(first) : entry == word
311
+ end
312
+ end
313
+
314
+ def parse(code)
315
+ Prism.parse(code)
316
+ rescue StandardError
317
+ nil
318
+ end
319
+
320
+ # 解析失败是否只是「还缺东西」:
321
+ #
322
+ # * prism 把错误报在**输入末尾**(`unexpected end-of-input`),或
323
+ # * 报成 +*_term+——缺 `end` / `)` / `]` 等收尾(`case x` 的 +case_term+ 属此类,
324
+ # 它的错误位置在构造起始处而非末尾,光看末尾看不出来)。
325
+ #
326
+ # 以上都值得再加一行试试,其余是行内已定的硬错误、加多少行也补不上。末尾空白
327
+ # (含换行)会被 prism 跳过,因此 EOF 位置取 +rstrip+ 之后的字节数。
328
+ # (+Prism::ParseError#type+ 是 prism 的内部符号,这里只当提示用,取不到就退回
329
+ # 「只看末尾」。)
330
+ def incomplete?(result, code)
331
+ eof = code.rstrip.bytesize
332
+ result.errors.any? { |error| error.location.start_offset >= eof || terminator?(error) }
333
+ end
334
+
335
+ def terminator?(error)
336
+ # error.respond_to?(:type) && error.type.to_s.include?("_term")
337
+ error.type.to_s.include?("_term") # 有大概20%的性能提升,且未产生错误
338
+ end
339
+
340
+ # 顶层语句是否恰好覆盖「整段代码」。prism 的行内列号(+end_column+)按**字节**
341
+ # 计,因此这里比的是 +bytesize+,不是字符长度——否则末行含 CJK 时会误判为不覆盖,
342
+ # 循着空行一路追加到文末。
343
+ def covers?(node, code, line_count)
344
+ loc = node.location
345
+ last_line = code.lines.last.to_s.rstrip
346
+ loc.start_line == 1 &&
347
+ loc.start_column.zero? &&
348
+ loc.end_line == line_count &&
349
+ loc.end_column == last_line.bytesize
350
+ end
351
+
352
+ # A 类:`CallNode`(一个或多个)→ 带 `closing_loc` 的 `BlockNode`。
353
+ #
354
+ # 允许链上出现多个 `CallNode`,这自然覆盖了 `end.render` 这类收尾。
355
+ def call_match(node)
356
+ render = false
357
+ if render_call?(node)
358
+ render = true
359
+ node = node.receiver
360
+ end
361
+ return nil unless node.is_a?(Prism::CallNode)
362
+
363
+ match = nil
364
+ cursor = node
365
+ while cursor.is_a?(Prism::CallNode)
366
+ # 自由块必须是**有名字的**调用;`foo.() do ... end` 这种 message_loc 为空的
367
+ # 调用不算,避免把错误恢复出的空方法名当成 Ruby 块。
368
+ return nil if empty_message_loc?(cursor)
369
+
370
+ block = cursor.block
371
+ match ||= { form: :free, render: render } if block.is_a?(Prism::BlockNode) && block.closing_loc
372
+
373
+ cursor = cursor.receiver
374
+ end
375
+ match
376
+ end
377
+
378
+ def empty_message_loc?(node)
379
+ location = node.message_loc
380
+ location.nil? || location.start_offset == location.end_offset
381
+ end
382
+
383
+ # 错误恢复出的顶层语句可能是 message_loc 为空的 CallNode(如
384
+ # `I do this till end.`);只检查顶层语句,避免遍历整棵错误恢复 AST。
385
+ # 成功结果由 #call_match 沿 receiver 链检查。
386
+ def empty_call_message?(result)
387
+ result.value.statements.body.any? do |node|
388
+ node.is_a?(Prism::CallNode) && empty_message_loc?(node)
389
+ end
390
+ end
391
+
392
+ # B 类:Prism 给出的节点类必须与首行关键字一致。
393
+ def keyword_match(node, keyword)
394
+ render = false
395
+ if render_call?(node)
396
+ render = true
397
+ node = node.receiver
398
+ end
399
+
400
+ expected = KEYWORD_NODES[keyword]
401
+ return nil unless expected && node.is_a?(expected)
402
+
403
+ { form: :begin, render: render }
404
+ end
405
+
406
+ def render_call?(node)
407
+ node.is_a?(Prism::CallNode) && node.name == :render && node.block.nil?
408
+ end
409
+
410
+ # CSM §3.5.2 条件 1:块首行与块尾行的缩进必须完全相同。
411
+ def assert_same_indent!(lines, start_index, last_index, indent)
412
+ found = indent_width(lines[last_index])
413
+ return if found == indent
414
+
415
+ raise CrossLevelError,
416
+ "ruby block opens with #{indent} space(s) of indentation " \
417
+ "(line #{start_index + 1}) but closes with #{found} (line #{last_index + 1})"
418
+ end
419
+
420
+ # 与 Tokenizer#indent_width 同一实现(见那里的说明)。
421
+ def indent_width(line)
422
+ line.index(/[^ \t]/) || line.length
423
+ end
424
+
425
+ def drop_indent(line, count)
426
+ return line if count.zero?
427
+
428
+ index = 0
429
+ index += 1 while index < count && index < line.length && line[index] == " "
430
+ line[index..]
431
+ end
432
+ end
433
+ end