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.
@@ -0,0 +1,200 @@
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 do].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
+ # 在 +lines+(已规范化:tab 展开、去 `\r`)中,从 +start_index+ 起判定候选块。
45
+ #
46
+ # +indent+ 是候选行的 CSM 缩进(前导空格数)。返回 +Detection+ 或 +nil+。
47
+ #
48
+ # 抛出 +CrossLevelError+,当 Prism 判定为块、但块尾行的缩进与块首行不同
49
+ # (CSM §3.5.2 条件 1)。
50
+ def detect(lines, start_index, indent)
51
+ first = drop_indent(lines[start_index], indent)
52
+ kind, keyword = classify(first)
53
+ return nil unless kind
54
+
55
+ # 增量 parse 只在**候选所在的 CSM 块范围内**进行(CSM §3.5.4):块由空行
56
+ # 或更浅的缩进收尾。逐行增加,直到解析成为以候选行起始的完整顶层语句,
57
+ # 立即给出结论——不做整篇一次解析,散文里的语法错误不会波及全篇。
58
+ (start_index..block_end(lines, start_index, indent)).each do |last|
59
+ line_count = last - start_index + 1
60
+ code = (start_index..last).map { |i| drop_indent(lines[i], indent) }.join("\n")
61
+ result = parse(code)
62
+ next unless result
63
+
64
+ node = result.value.statements.body.last
65
+ next if node.nil?
66
+ # 顶层语句必须恰好从候选行开始、到当前行结束;一旦成立就说明
67
+ # 「从候选行起的一个完整 Ruby 语句到此为止」,无论是否匹配块结构,
68
+ # 都不必再往下扩展。
69
+ next unless covers?(node, code, line_count)
70
+
71
+ match = kind == :keyword ? keyword_match(node, keyword) : call_match(node)
72
+ return nil unless match
73
+
74
+ assert_same_indent!(lines, start_index, last, indent)
75
+ assert_no_heredoc!(code, start_index, last)
76
+ return Detection.new(end_index: last, form: match[:form], render: match[:render])
77
+ end
78
+ nil
79
+ end
80
+
81
+ private
82
+
83
+ # 候选所在的 CSM 块的最后一行(CSM §1.1:块由空行或更浅的缩进收尾)。
84
+ #
85
+ # 更浅缩进的那一行**包含在内**——块尾缩进与块首不同时要能按 §3.5.2 报错,
86
+ # 而不是静默降级成排版。
87
+ def block_end(lines, start_index, indent)
88
+ (start_index + 1...lines.size).each do |i|
89
+ return i - 1 if lines[i].strip.empty?
90
+ return i if indent_width(lines[i]) < indent
91
+ end
92
+ lines.size - 1
93
+ end
94
+
95
+ # 收窄:行首词是引导块关键字 → B 类;否则行首是字母 → A 类;否则不判定。
96
+ def classify(first)
97
+ word = first[/\A[A-Za-z_][A-Za-z0-9_]*/]
98
+ return [nil, nil] unless word
99
+
100
+ if GUIDING_KEYWORDS.include?(word)
101
+ [:keyword, word]
102
+ elsif first.match?(/\A[A-Za-z]/)
103
+ [:call, nil]
104
+ else
105
+ [nil, nil]
106
+ end
107
+ end
108
+
109
+ def parse(code)
110
+ result = Prism.parse(code)
111
+ result.success? ? result : nil
112
+ rescue StandardError
113
+ nil
114
+ end
115
+
116
+ # 顶层语句是否恰好覆盖「整段代码」。
117
+ def covers?(node, code, line_count)
118
+ loc = node.location
119
+ last_line = code.lines.last.to_s.rstrip
120
+ loc.start_line == 1 &&
121
+ loc.start_column.zero? &&
122
+ loc.end_line == line_count &&
123
+ loc.end_column == last_line.length
124
+ end
125
+
126
+ # A 类:`CallNode`(一个或多个)→ 带 `closing_loc` 的 `BlockNode`。
127
+ #
128
+ # 允许链上出现多个 `CallNode`,这自然覆盖了 `end.render` 这类收尾。
129
+ def call_match(node)
130
+ render = false
131
+ if render_call?(node)
132
+ render = true
133
+ node = node.receiver
134
+ end
135
+ return nil unless node.is_a?(Prism::CallNode)
136
+
137
+ cursor = node
138
+ while cursor.is_a?(Prism::CallNode)
139
+ block = cursor.block
140
+ return { form: :free, render: render } if block.is_a?(Prism::BlockNode) && block.closing_loc
141
+
142
+ cursor = cursor.receiver
143
+ end
144
+ nil
145
+ end
146
+
147
+ # B 类:Prism 给出的节点类必须与首行关键字一致。
148
+ def keyword_match(node, keyword)
149
+ render = false
150
+ if render_call?(node)
151
+ render = true
152
+ node = node.receiver
153
+ end
154
+
155
+ expected = KEYWORD_NODES[keyword]
156
+ return nil unless expected && node.is_a?(expected)
157
+
158
+ { form: :begin, render: render }
159
+ end
160
+
161
+ def render_call?(node)
162
+ node.is_a?(Prism::CallNode) && node.name == :render && node.block.nil?
163
+ end
164
+
165
+ # CSM §3.5.2 条件 1:块首行与块尾行的缩进必须完全相同。
166
+ def assert_same_indent!(lines, start_index, last_index, indent)
167
+ found = indent_width(lines[last_index])
168
+ return if found == indent
169
+
170
+ raise CrossLevelError,
171
+ "ruby block opens with #{indent} space(s) of indentation " \
172
+ "(line #{start_index + 1}) but closes with #{found} (line #{last_index + 1})"
173
+ end
174
+
175
+ # CSM §3.12 / 附录 E:heredoc(`<<~SQL` 等)**明确不支持**。既然块判定本来就
176
+ # 要交给 Prism,就顺便让它把 heredoc 也认出来——被判定为块的内容里出现
177
+ # +HEREDOC_START+ 就报错,而不是当普通块收下。
178
+ def assert_no_heredoc!(code, start_index, last_index)
179
+ return unless heredoc?(code)
180
+
181
+ raise UnsupportedHeredocError,
182
+ "heredoc is not supported (CSM §3.12): " \
183
+ "line #{start_index + 1}..#{last_index + 1}"
184
+ end
185
+
186
+ def heredoc?(code)
187
+ Prism.lex(code).value.any? { |token, _state| token.type.to_s == "HEREDOC_START" }
188
+ end
189
+
190
+ def indent_width(line)
191
+ line[/\A[ \t]*/].to_s.length
192
+ end
193
+
194
+ def drop_indent(line, count)
195
+ index = 0
196
+ index += 1 while index < count && index < line.length && line[index] == " "
197
+ line[index..]
198
+ end
199
+ end
200
+ end