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.
data/lib/mdom/parser.rb CHANGED
@@ -1,285 +1,335 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "error"
4
+ require_relative "inline_parser"
5
+ require_relative "node/nodes"
6
+ require_relative "tokenizer"
7
+
3
8
  module MDOM
4
- module Parser
5
- extend self
6
-
7
- HEADING_RE = /\A(\#{1,6})[ \t]+(.*?)[ \t]*\z/.freeze
8
- SETEXT_RE = /\A\s*([=-]+)\s*\z/.freeze
9
- FENCE_RE = /\A([ ]{0,3})(`{3,}|~{3,})(.*)\z/.freeze
10
- HRULE_RE = /\A\s*((\*\s*){3,}|(-\s*){3,}|(_\s*){3,})\z/.freeze
11
- QUOTE_RE = /\A[ ]{0,3}>[ \t]?(.*)\z/.freeze
12
- LIST_RE = /\A([ ]*)([-*+])([ \t]+)(.*)\z/.freeze
13
- ORDERED_RE = /\A([ ]*)(\d{1,9})([.)])([ \t]+)(.*)\z/.freeze
14
- TASK_RE = /\A\[([ xX])\][ \t]+(.*)\z/.freeze
15
-
16
- def read(source)
17
- Reader.new(normalize(source)).parse
9
+ # Token Stream → AST1(docs/IMPLEMENTATION.md §2.4)。
10
+ #
11
+ # 只负责**块结构**:按 CSM 缩进(CSM §1.1)把 token 挂成树。段落、标题等的
12
+ # 正文以原文保存,行内解析留给后续的 InlineParser。嵌入 token 生成
13
+ # +Node::RubyEmbed+ 节点,存源码原文与位置,**不求值**。
14
+ #
15
+ # MDOM::Parser.parse("# 标题\n\n正文\n")
16
+ class Parser
17
+ # 容器栈的一层。+indent+ 是该容器所在行的 CSM 缩进(根与节块用 -1,
18
+ # 表示不会被同级或更深的块弹出)。
19
+ Frame = Struct.new(:indent, :node, :kind, keyword_init: true)
20
+
21
+ # 行内容器:内容策略为 **PHRASING** 的节点(`docs/CSM.md` 元素表),它们的
22
+ # +attributes[:text]+ 会被 InlineParser 解析成行内节点。
23
+ #
24
+ # 内容策略为 FLOW 的容器(`blockquote` / `li` / `dd`)**不在此列**——它们的正文
25
+ # 先成为一个段落,直接子节点一律是块级节点。表格单元格(`th` / `td`)也是
26
+ # PHRASING。
27
+ PHRASING_CONTAINERS = %i[paragraph heading term cite table_header_cell table_cell].freeze
28
+
29
+ # 解析 CSM 源码,返回 +Node::Document+。+inline: false+ 可跳过行内解析。
30
+ def self.parse(source, **options)
31
+ new(**options).parse(source)
18
32
  end
19
33
 
20
- def normalize(source)
21
- s = source.to_s
22
- s = s.gsub(/\r\n?/, "\n").sub(/\A\n+/, "").chomp
23
- return [] if s.empty?
24
-
25
- s.split("\n", -1).map { |line| expand_leading_tabs(line) }
34
+ # 参数:
35
+ # * +detector:+ —— 块判定器,默认 +BlockDetector.new+。**自定义判定器时,A 类的
36
+ # 开关与白名单要配在判定器自己身上**,不要再传下面两个选项(同时给出会报错)。
37
+ # * +inline:+ —— 是否做行内解析;+false+ 只做块结构。
38
+ # * +free_blocks:+ —— **A 类(自由块)判定开关**(CSM §3.5),默认 +true+。
39
+ # +false+ 时不做任何 A 类判定,命中行按**排版**处理(CSM §3.5.3,不报错);
40
+ # B 类(引导关键字)与 `~` 行不受影响。
41
+ # * +free_blocks_allowed:+ —— A 类判定的**白名单**,默认 +nil+(不限制)。给出后
42
+ # 只有行首标识符命中的行才尝试 A 类判定:+String+ 精确相等,+Regexp+ 对已剥离
43
+ # 缩进的整行匹配。空数组等价于 +free_blocks: false+。
44
+ #
45
+ # 后两个选项服务于英文散文文档:那里「以字母开头的行」比比皆是,逐一交给 Prism
46
+ # 增量 parse 会产生几百到上万次调用(docs/IMPLEMENTATION.md §2.2.4)。
47
+ def initialize(detector: nil, inline: true, free_blocks: true, free_blocks_allowed: nil)
48
+ @tokenizer = Tokenizer.new(detector: resolve_detector(detector, free_blocks, free_blocks_allowed))
49
+ @inline = inline
26
50
  end
27
51
 
28
- # Expand leading tab characters in +line+ into spaces at 4-column tab
29
- # stops. This normalizes list/quote indentation so the block parser can
30
- # reason about column widths, while leaving the non-indentation content
31
- # (including tabs inside code or text) untouched. A tab stop positions the
32
- # next character at the next multiple of 4.
33
- def expand_leading_tabs(line)
34
- i = 0
35
- col = 0
36
- out = +""
37
- while i < line.length
38
- ch = line[i]
39
- case ch
40
- when " "
41
- out << " "
42
- col += 1
43
- i += 1
44
- when "\t"
45
- target = (col / 4 + 1) * 4
46
- out << " " * (target - col)
47
- col = target
48
- i += 1
49
- else
50
- out << line[i..]
52
+ def parse(source)
53
+ root = Node::Document.new
54
+ stack = [Frame.new(indent: -1, node: root, kind: :document)]
55
+ @previous = nil
56
+
57
+ @tokenizer.tokenize(source).each do |token|
58
+ if token.type == :end_marker
59
+ stack.pop while stack.size > 1
60
+ root << Node::EndMarker.new(token.value[:line], token.value[:data])
51
61
  break
52
62
  end
53
- end
54
- out
55
- end
56
-
57
- # Line-oriented block scanner. parse_blocks returns a flat array of sibmling
58
- # block nodes (and nothing else), so callers don't juggle an index.
59
- class Reader
60
- def initialize(lines)
61
- @lines = lines
62
- @pos = 0
63
- end
64
63
 
65
- def parse
66
- Document.new(children: parse_blocks)
64
+ dispatch(token, root, stack)
65
+ @previous = token
67
66
  end
68
67
 
69
- def self.parse_lines(lines)
70
- new(lines).send(:parse_blocks)
71
- end
72
-
73
- private
68
+ drop_empty_divisions(root)
69
+ resolve_inline(root) if @inline
70
+ root
71
+ end
74
72
 
75
- def eof?
76
- @pos >= @lines.length
73
+ private
74
+
75
+ # 组装块判定器:没给 +detector+ 就把两个 A 类选项用在默认判定器上;给了就要求
76
+ # 那两个选项保持默认——配置的**单一来源**,避免「传了却不生效」的静默失效。
77
+ def resolve_detector(detector, free_blocks, free_blocks_allowed)
78
+ if detector.nil?
79
+ BlockDetector.new(free_blocks: free_blocks, free_blocks_allowed: free_blocks_allowed)
80
+ else
81
+ unless free_blocks == true && free_blocks_allowed.nil?
82
+ raise ArgumentError,
83
+ "`detector:` cannot be combined with `free_blocks:` / `free_blocks_allowed:` " \
84
+ "— configure the detector itself instead"
85
+ end
86
+ detector
77
87
  end
88
+ end
78
89
 
79
- def current
80
- @lines[@pos]
90
+ def dispatch(token, root, stack)
91
+ case token.type
92
+ when :blank
93
+ handle_blank(token, root, stack)
94
+ when :text
95
+ handle_text(token, stack)
96
+ when :heading
97
+ place_leaf(token, stack, build_heading(token))
98
+ when :hrule
99
+ place_leaf(token, stack, Node::Hrule.new)
100
+ when :fence
101
+ place_leaf(token, stack, build_code_block(token))
102
+ when :math_block
103
+ place_leaf(token, stack, Node::MathBlock.new(token.value[:tex]))
104
+ when :table
105
+ place_leaf(token, stack, build_table(token))
106
+ when :ruby_block
107
+ place_leaf(token, stack, build_embed(token))
108
+ when :blockquote
109
+ handle_blockquote(token, stack)
110
+ when :cite
111
+ handle_cite(token, stack)
112
+ when :list_item
113
+ handle_list_item(token, stack)
114
+ when :definition
115
+ handle_definition(token, stack)
116
+ else
117
+ raise ParseError, "unknown token type #{token.type.inspect} (line #{token.line})"
81
118
  end
119
+ end
82
120
 
83
- def peek(offset = 1)
84
- @lines[@pos + offset]
85
- end
121
+ # 段落:连续的排版文本行合并为一个段落。软换行的折叠(CSM §1.1)与行尾
122
+ # `\` 的硬换行由 InlineParser 处理,这里只把原始行用 `\n` 连接。
123
+ def handle_text(token, stack)
124
+ parent = pop_to_parent(stack, token.indent)
125
+ last = parent.children.last
86
126
 
87
- def blank?(line)
88
- line.nil? || line.strip.empty?
127
+ if @previous&.type == :text && @previous.indent == token.indent && last.is_a?(Node::Paragraph)
128
+ last.text = "#{last.text}\n#{token.value[:text]}"
129
+ else
130
+ parent << Node::Paragraph.new(token.value[:text])
89
131
  end
132
+ end
90
133
 
91
- def parse_blocks
92
- blocks = []
93
- while !eof?
94
- if blank?(current)
95
- @pos += 1
96
- next
97
- end
98
-
99
- block = parse_block
100
- blocks << block if block
101
- end
102
- blocks
134
+ # 块引用(内容策略 FLOW):相邻的引用行合并进**同一个段落**(软换行语义,
135
+ # CSM §1.1);更深的缩进开启块级子节点。
136
+ def handle_blockquote(token, stack)
137
+ indent = token.indent
138
+ pop_deeper(stack, indent)
139
+ stack.pop while stack.size > 1 && stack.last.indent == indent && stack.last.kind != :quote
140
+
141
+ if stack.last.kind == :quote && stack.last.indent == indent
142
+ quote = stack.last.node
143
+ else
144
+ quote = Node::Blockquote.new
145
+ stack.last.node << quote
146
+ stack.push(Frame.new(indent: indent, node: quote, kind: :quote))
103
147
  end
104
148
 
105
- def parse_block
106
- line = current
149
+ append_body_line(quote, token.value[:text])
150
+ quote
151
+ end
107
152
 
108
- if (m = FENCE_RE.match(line))
109
- return parse_fenced_code(m) if fence_safe?(m)
110
- end
153
+ # 把一行正文并进 FLOW 容器的最后一个段落(软换行折叠交给 InlineParser);
154
+ # 上一个子节点不是段落(如嵌套块)时另起一个段落。空行不产生内容。
155
+ def append_body_line(node, text)
156
+ return if text.empty?
111
157
 
112
- if (om = ORDERED_RE.match(line))
113
- return parse_list(om, ordered: true)
114
- end
115
- if (um = LIST_RE.match(line))
116
- return parse_list(um, ordered: false)
117
- end
158
+ paragraph = node.children.last
159
+ if paragraph.is_a?(Node::Paragraph)
160
+ paragraph.text = "#{paragraph.text}\n#{text}"
161
+ else
162
+ node << Node::Paragraph.new(text)
163
+ end
164
+ end
118
165
 
119
- if HRULE_RE.match(line)
120
- @pos += 1
121
- return Hrule.new
122
- end
166
+ # `>-` 收尾:作为 +Node::Cite+ 子节点挂到同级引用的末尾(内容策略
167
+ # PHRASING,正文会被行内解析,CSM §1.4)。
168
+ def handle_cite(token, stack)
169
+ indent = token.indent
170
+ pop_deeper(stack, indent)
171
+
172
+ if stack.last.kind == :quote && stack.last.indent == indent
173
+ quote = stack.last.node
174
+ else
175
+ stack.pop while stack.size > 1 && stack.last.indent == indent
176
+ quote = Node::Blockquote.new
177
+ stack.last.node << quote
178
+ end
123
179
 
124
- if (q = QUOTE_RE.match(line))
125
- return parse_blockquote(q)
126
- end
180
+ quote << Node::Cite.new(token.value[:text])
181
+ end
127
182
 
128
- if (h = HEADING_RE.match(line))
129
- text = h[2].sub(/[ \t]+#+[ \t]*\z/, "").strip
130
- @pos += 1
131
- return Heading.new(h[1].length, children: inline_children(text))
132
- end
183
+ # 列表项(内容策略 FLOW):同缩进的连续项复用同一个 +Node::List+,更深缩进则
184
+ # 嵌入到上一个列表项之下(CSM §1.3 的「嵌套通过缩进控制」)。正文成为段落。
185
+ def handle_list_item(token, stack)
186
+ indent = token.indent
187
+ pop_deeper(stack, indent)
188
+ stack.pop while stack.size > 1 && stack.last.indent == indent && stack.last.kind != :list
189
+
190
+ if stack.last.kind == :list && stack.last.indent == indent
191
+ list = stack.last.node
192
+ else
193
+ list = Node::List.new(ordered: token.value[:ordered], task: token.value[:task],
194
+ marker: token.value[:marker])
195
+ stack.last.node << list
196
+ stack.push(Frame.new(indent: indent, node: list, kind: :list))
197
+ end
133
198
 
134
- if setext_heading?
135
- return parse_setext_heading
136
- end
199
+ list.attributes[:task] = true if token.value[:task]
200
+ item = Node::ListItem.new(marker: token.value[:marker], task: token.value[:task],
201
+ checked: token.value[:checked])
202
+ item << Node::Paragraph.new(token.value[:text])
203
+ list << item
204
+ stack.push(Frame.new(indent: indent, node: item, kind: :item))
205
+ item
206
+ end
137
207
 
138
- parse_paragraph
208
+ # 定义列表(CSM §1.3):`term` 行 + 其后的 `: 定义` 行。第一个 `:` 行把前一个
209
+ # 同级段落提升为定义术语,再建 +Node::DefinitionList+。
210
+ #
211
+ # +dd+ 的内容策略是 FLOW,所以正文成为段落;并且它自己成为容器栈的一层——
212
+ # 缩进的子块因此挂在**这个** +Definition+ 下(而不是 `dl` 下)。
213
+ def handle_definition(token, stack)
214
+ indent = token.indent
215
+ pop_deeper(stack, indent)
216
+ stack.pop while stack.size > 1 && stack.last.indent == indent && stack.last.kind != :deflist
217
+
218
+ if stack.last.kind == :deflist && stack.last.indent == indent
219
+ list = stack.last.node
220
+ else
221
+ list = promote_to_definition_list(stack.last.node)
222
+ stack.push(Frame.new(indent: indent, node: list, kind: :deflist))
139
223
  end
140
224
 
141
- def fence_safe?(m)
142
- return true unless m[2].start_with?("`")
225
+ definition = Node::Definition.new
226
+ definition << Node::Paragraph.new(token.value[:text])
227
+ list << definition
228
+ stack.push(Frame.new(indent: indent, node: definition, kind: :definition))
229
+ definition
230
+ end
143
231
 
144
- !m[3].include?("`")
232
+ # 空行终止当前块(CSM §1.1)。连续四个 `\n` 另起一个节块(`<div>`)。
233
+ def handle_blank(token, root, stack)
234
+ if token.value[:count] >= 3
235
+ stack.pop while stack.size > 1
236
+ division = Node::Div.new
237
+ root << division
238
+ stack.push(Frame.new(indent: -1, node: division, kind: :div))
239
+ else
240
+ stack.pop while stack.size > 1 && stack.last.kind != :div
145
241
  end
242
+ end
146
243
 
147
- def parse_fenced_code(m)
148
- indent = m[1].length
149
- marker = m[2][0]
150
- fence_len = m[2].length
151
- info = m[3].strip
152
- lang = info.empty? ? nil : info.split(/\s+/).first
153
-
154
- @pos += 1
155
- content = []
156
- while !eof?
157
- line = current
158
- if (cm = FENCE_RE.match(line)) && cm[2][0] == marker && cm[2].length >= fence_len && cm[1].length <= 3
159
- @pos += 1
160
- break
161
- end
162
- content << line[indent..].to_s
163
- @pos += 1
164
- end
165
- CodeBlock.new(content.join("\n"), lang: lang, fenced: true)
166
- end
244
+ # 叶子块:挂到「缩进严格小于自己」的最近容器下。
245
+ def place_leaf(token, stack, node)
246
+ parent = pop_to_parent(stack, token.indent)
247
+ parent << node
248
+ node
249
+ end
167
250
 
168
- def setext_heading?
169
- !blank?(current) && !HRULE_RE.match(current) && SETEXT_RE.match(peek(1).to_s)
170
- end
251
+ # 弹出所有「缩进不小于 +indent+」的容器,返回合适的父容器。
252
+ def pop_to_parent(stack, indent)
253
+ stack.pop while stack.size > 1 && stack.last.indent >= indent
254
+ stack.last.node
255
+ end
171
256
 
172
- def parse_setext_heading
173
- text = current.strip
174
- level = SETEXT_RE.match(peek(1))[1].start_with?("=") ? 1 : 2
175
- @pos += 2
176
- Heading.new(level, children: inline_children(text))
177
- end
257
+ # 只弹出「缩进严格大于 +indent+」的容器,保留同级的可复用容器。
258
+ def pop_deeper(stack, indent)
259
+ stack.pop while stack.size > 1 && stack.last.indent > indent
260
+ end
178
261
 
179
- def parse_blockquote(quote)
180
- quote_lines = []
181
- while !eof? && (m = QUOTE_RE.match(current))
182
- quote_lines << m[1]
183
- @pos += 1
184
- end
185
- Blockquote.new(children: Reader.parse_lines(quote_lines))
262
+ # 把容器里最后一个同级段落提升为定义术语。
263
+ def promote_to_definition_list(parent)
264
+ list = Node::DefinitionList.new
265
+ last = parent.children.last
266
+
267
+ if last.is_a?(Node::Paragraph)
268
+ list << Node::Term.new(last.text)
269
+ parent.replace(last, list)
270
+ else
271
+ parent << list
186
272
  end
273
+ list
274
+ end
187
275
 
188
- def parse_list(marker, ordered:)
189
- start = ordered ? marker[2].to_i : nil
190
- items = []
191
- while !eof?
192
- line = current
193
- lm = ORDERED_RE.match(line) || LIST_RE.match(line)
194
- break if lm.nil?
195
- break if (ORDERED_RE.match(line).nil?) == ordered
196
-
197
- marker_len = marker_width(lm)
198
- content = lm[5] || lm[4]
199
- task = false
200
- checked = false
201
- if (tm = TASK_RE.match(content))
202
- task = true
203
- checked = tm[1].downcase == "x"
204
- content = tm[2]
205
- end
206
- @pos += 1
207
-
208
- item_lines = [content]
209
- while !eof? && !blank?(current)
210
- l = current
211
- child = ORDERED_RE.match(l) || LIST_RE.match(l)
212
- break if child && marker_width(child) <= marker_len
213
-
214
- item_lines << l
215
- @pos += 1
216
- end
217
-
218
- li = ListItem.new
219
- Reader.parse_lines(item_lines).each { |n| li.append(n) }
220
- li.attributes[:task] = true if task
221
- li.attributes[:checked] = checked if task
222
- items << li
223
- end
276
+ def build_heading(token)
277
+ Node::Heading.new(token.value[:level], token.value[:text], closed: token.value[:closed])
278
+ end
224
279
 
225
- list = List.new(ordered: ordered, tight: true, start: start)
226
- items.each { |li| list.append(li) }
227
- list
228
- end
280
+ def build_code_block(token)
281
+ Node::CodeBlock.new(
282
+ token.value[:code],
283
+ lang: token.value[:lang],
284
+ params: token.value[:params],
285
+ fence: token.value[:fence]
286
+ )
287
+ end
229
288
 
230
- # Width in columns of a list marker prefix (indent + bullet/number + space).
231
- def marker_width(m)
232
- content = m[5] || m[4]
233
- m[0].length - content.length
234
- end
289
+ # 表格(CSM §1.12):行 → 单元格,表头行用 +TableHeaderCell+,其余用
290
+ # +TableCell+;对齐逐列取自该行生效的分隔行。
291
+ def build_table(token)
292
+ Node::Table.new(children: token.value[:rows].map { |row| build_table_row(row) })
293
+ end
235
294
 
236
- def parse_paragraph
237
- collected = []
238
- while !eof? && !blank?(current) && !block_start?(current)
239
- collected << current.strip
240
- @pos += 1
241
- end
242
- Paragraph.new(children: inline_children(collected.join("\n")))
243
- end
295
+ def build_table_row(row)
296
+ klass = row[:header] ? Node::TableHeaderCell : Node::TableCell
297
+ align = row[:align] || []
298
+ cells = row[:cells].each_with_index.map { |text, column| klass.new(text, align: align[column]) }
299
+ Node::TableRow.new(children: cells)
300
+ end
244
301
 
245
- def block_start?(line)
246
- return true if FENCE_RE.match(line)
247
- return true if LIST_RE.match(line) || ORDERED_RE.match(line)
248
- return true if HRULE_RE.match(line)
249
- return true if QUOTE_RE.match(line)
250
- return true if HEADING_RE.match(line)
302
+ def build_embed(token)
303
+ Node::RubyEmbed.new(
304
+ source: token.value[:source],
305
+ form: token.value[:form],
306
+ lines: token.value[:lines],
307
+ indent: token.value[:indent],
308
+ render: token.value[:render]
309
+ )
310
+ end
251
311
 
252
- false
253
- end
312
+ # 把行内容器(内容策略 PHRASING)的 +text+ 原文解析为行内节点,插到
313
+ # +children+ 之前。
314
+ #
315
+ # +attributes[:text]+ 保留原文(供保真回吐与调试),行内节点才是向下游交付的
316
+ # 结构。FLOW 容器不直接承载行内内容,所以这里不碰它们——它们的正文是段落,
317
+ # 段落自己会被递归解析到。
318
+ def resolve_inline(node)
319
+ node.children.each { |child| resolve_inline(child) }
320
+ return unless PHRASING_CONTAINERS.include?(node.type)
254
321
 
255
- # Parse a run of inline text into inline nodes.
256
- def inline_children(text)
257
- return [] if text.to_s.empty?
322
+ text = node.attributes[:text]
323
+ return if text.nil? || text.empty?
258
324
 
259
- nodes = InlineParser.parse(text)
260
- trim_hardbreak_text(nodes)
261
- end
325
+ InlineParser.parse(text).reverse_each { |inline| node.prepend(inline) }
326
+ end
262
327
 
263
- # A hard break is represented by two trailing spaces before the newline.
264
- # The text node emitted before it still carries those spaces; trim them so
265
- # the model has the break (not literal spaces) as its semantic marker.
266
- def trim_hardbreak_text(nodes)
267
- out = []
268
- nodes.each_with_index do |node, i|
269
- if node.text? && nodes[i + 1]&.type == :hardbreak
270
- value = node.value.sub(/ {2}\z/, "")
271
- out << (value.empty? ? nil : Text.new(value))
272
- else
273
- out << node
274
- end
275
- end
276
- out.compact
328
+ # 文档末尾由空行开出的空节块没有意义,解析完清掉。
329
+ def drop_empty_divisions(root)
330
+ root.children.dup.each do |child|
331
+ root.remove(child) if child.is_a?(Node::Div) && child.children.empty?
277
332
  end
278
333
  end
279
334
  end
280
-
281
- # Public entry point: parse +source+ into an MDOM::Document tree.
282
- def self.parse(source)
283
- Parser.read(source)
284
- end
285
335
  end