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.
data/lib/mdom/parser.rb CHANGED
@@ -1,285 +1,307 @@
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
+ def initialize(detector: BlockDetector.new, inline: true)
35
+ @tokenizer = Tokenizer.new(detector: detector)
36
+ @inline = inline
26
37
  end
27
38
 
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..]
39
+ def parse(source)
40
+ root = Node::Document.new
41
+ stack = [Frame.new(indent: -1, node: root, kind: :document)]
42
+ @previous = nil
43
+
44
+ @tokenizer.tokenize(source).each do |token|
45
+ if token.type == :end_marker
46
+ stack.pop while stack.size > 1
47
+ root << Node::EndMarker.new(token.value[:line], token.value[:data])
51
48
  break
52
49
  end
53
- end
54
- out
55
- end
56
50
 
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
51
+ dispatch(token, root, stack)
52
+ @previous = token
63
53
  end
64
54
 
65
- def parse
66
- Document.new(children: parse_blocks)
67
- end
55
+ drop_empty_divisions(root)
56
+ resolve_inline(root) if @inline
57
+ root
58
+ end
68
59
 
69
- def self.parse_lines(lines)
70
- new(lines).send(:parse_blocks)
60
+ private
61
+
62
+ def dispatch(token, root, stack)
63
+ case token.type
64
+ when :blank
65
+ handle_blank(token, root, stack)
66
+ when :text
67
+ handle_text(token, stack)
68
+ when :heading
69
+ place_leaf(token, stack, build_heading(token))
70
+ when :hrule
71
+ place_leaf(token, stack, Node::Hrule.new)
72
+ when :fence
73
+ place_leaf(token, stack, build_code_block(token))
74
+ when :math_block
75
+ place_leaf(token, stack, Node::MathBlock.new(token.value[:tex]))
76
+ when :table
77
+ place_leaf(token, stack, build_table(token))
78
+ when :ruby_block
79
+ place_leaf(token, stack, build_embed(token))
80
+ when :blockquote
81
+ handle_blockquote(token, stack)
82
+ when :cite
83
+ handle_cite(token, stack)
84
+ when :list_item
85
+ handle_list_item(token, stack)
86
+ when :definition
87
+ handle_definition(token, stack)
88
+ else
89
+ raise ParseError, "unknown token type #{token.type.inspect} (line #{token.line})"
71
90
  end
91
+ end
72
92
 
73
- private
74
-
75
- def eof?
76
- @pos >= @lines.length
77
- end
93
+ # 段落:连续的排版文本行合并为一个段落。软换行的折叠(CSM §1.1)与行尾
94
+ # `\` 的硬换行由 InlineParser 处理,这里只把原始行用 `\n` 连接。
95
+ def handle_text(token, stack)
96
+ parent = pop_to_parent(stack, token.indent)
97
+ last = parent.children.last
78
98
 
79
- def current
80
- @lines[@pos]
99
+ if @previous&.type == :text && @previous.indent == token.indent && last.is_a?(Node::Paragraph)
100
+ last.text = "#{last.text}\n#{token.value[:text]}"
101
+ else
102
+ parent << Node::Paragraph.new(token.value[:text])
81
103
  end
104
+ end
82
105
 
83
- def peek(offset = 1)
84
- @lines[@pos + offset]
106
+ # 块引用(内容策略 FLOW):相邻的引用行合并进**同一个段落**(软换行语义,
107
+ # CSM §1.1);更深的缩进开启块级子节点。
108
+ def handle_blockquote(token, stack)
109
+ indent = token.indent
110
+ pop_deeper(stack, indent)
111
+ stack.pop while stack.size > 1 && stack.last.indent == indent && stack.last.kind != :quote
112
+
113
+ if stack.last.kind == :quote && stack.last.indent == indent
114
+ quote = stack.last.node
115
+ else
116
+ quote = Node::Blockquote.new
117
+ stack.last.node << quote
118
+ stack.push(Frame.new(indent: indent, node: quote, kind: :quote))
85
119
  end
86
120
 
87
- def blank?(line)
88
- line.nil? || line.strip.empty?
89
- end
121
+ append_body_line(quote, token.value[:text])
122
+ quote
123
+ end
90
124
 
91
- def parse_blocks
92
- blocks = []
93
- while !eof?
94
- if blank?(current)
95
- @pos += 1
96
- next
97
- end
125
+ # 把一行正文并进 FLOW 容器的最后一个段落(软换行折叠交给 InlineParser);
126
+ # 上一个子节点不是段落(如嵌套块)时另起一个段落。空行不产生内容。
127
+ def append_body_line(node, text)
128
+ return if text.empty?
98
129
 
99
- block = parse_block
100
- blocks << block if block
101
- end
102
- blocks
130
+ paragraph = node.children.last
131
+ if paragraph.is_a?(Node::Paragraph)
132
+ paragraph.text = "#{paragraph.text}\n#{text}"
133
+ else
134
+ node << Node::Paragraph.new(text)
103
135
  end
136
+ end
104
137
 
105
- def parse_block
106
- line = current
107
-
108
- if (m = FENCE_RE.match(line))
109
- return parse_fenced_code(m) if fence_safe?(m)
110
- end
111
-
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
118
-
119
- if HRULE_RE.match(line)
120
- @pos += 1
121
- return Hrule.new
122
- end
138
+ # `>-` 收尾:作为 +Node::Cite+ 子节点挂到同级引用的末尾(内容策略
139
+ # PHRASING,正文会被行内解析,CSM §1.4)。
140
+ def handle_cite(token, stack)
141
+ indent = token.indent
142
+ pop_deeper(stack, indent)
143
+
144
+ if stack.last.kind == :quote && stack.last.indent == indent
145
+ quote = stack.last.node
146
+ else
147
+ stack.pop while stack.size > 1 && stack.last.indent == indent
148
+ quote = Node::Blockquote.new
149
+ stack.last.node << quote
150
+ end
123
151
 
124
- if (q = QUOTE_RE.match(line))
125
- return parse_blockquote(q)
126
- end
152
+ quote << Node::Cite.new(token.value[:text])
153
+ end
127
154
 
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
155
+ # 列表项(内容策略 FLOW):同缩进的连续项复用同一个 +Node::List+,更深缩进则
156
+ # 嵌入到上一个列表项之下(CSM §1.3 的「嵌套通过缩进控制」)。正文成为段落。
157
+ def handle_list_item(token, stack)
158
+ indent = token.indent
159
+ pop_deeper(stack, indent)
160
+ stack.pop while stack.size > 1 && stack.last.indent == indent && stack.last.kind != :list
161
+
162
+ if stack.last.kind == :list && stack.last.indent == indent
163
+ list = stack.last.node
164
+ else
165
+ list = Node::List.new(ordered: token.value[:ordered], task: token.value[:task],
166
+ marker: token.value[:marker])
167
+ stack.last.node << list
168
+ stack.push(Frame.new(indent: indent, node: list, kind: :list))
169
+ end
133
170
 
134
- if setext_heading?
135
- return parse_setext_heading
136
- end
171
+ list.attributes[:task] = true if token.value[:task]
172
+ item = Node::ListItem.new(marker: token.value[:marker], task: token.value[:task],
173
+ checked: token.value[:checked])
174
+ item << Node::Paragraph.new(token.value[:text])
175
+ list << item
176
+ stack.push(Frame.new(indent: indent, node: item, kind: :item))
177
+ item
178
+ end
137
179
 
138
- parse_paragraph
180
+ # 定义列表(CSM §1.3):`term` 行 + 其后的 `: 定义` 行。第一个 `:` 行把前一个
181
+ # 同级段落提升为定义术语,再建 +Node::DefinitionList+。
182
+ #
183
+ # +dd+ 的内容策略是 FLOW,所以正文成为段落;并且它自己成为容器栈的一层——
184
+ # 缩进的子块因此挂在**这个** +Definition+ 下(而不是 `dl` 下)。
185
+ def handle_definition(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 != :deflist
189
+
190
+ if stack.last.kind == :deflist && stack.last.indent == indent
191
+ list = stack.last.node
192
+ else
193
+ list = promote_to_definition_list(stack.last.node)
194
+ stack.push(Frame.new(indent: indent, node: list, kind: :deflist))
139
195
  end
140
196
 
141
- def fence_safe?(m)
142
- return true unless m[2].start_with?("`")
197
+ definition = Node::Definition.new
198
+ definition << Node::Paragraph.new(token.value[:text])
199
+ list << definition
200
+ stack.push(Frame.new(indent: indent, node: definition, kind: :definition))
201
+ definition
202
+ end
143
203
 
144
- !m[3].include?("`")
204
+ # 空行终止当前块(CSM §1.1)。连续四个 `\n` 另起一个节块(`<div>`)。
205
+ def handle_blank(token, root, stack)
206
+ if token.value[:count] >= 3
207
+ stack.pop while stack.size > 1
208
+ division = Node::Div.new
209
+ root << division
210
+ stack.push(Frame.new(indent: -1, node: division, kind: :div))
211
+ else
212
+ stack.pop while stack.size > 1 && stack.last.kind != :div
145
213
  end
214
+ end
146
215
 
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
216
+ # 叶子块:挂到「缩进严格小于自己」的最近容器下。
217
+ def place_leaf(token, stack, node)
218
+ parent = pop_to_parent(stack, token.indent)
219
+ parent << node
220
+ node
221
+ end
167
222
 
168
- def setext_heading?
169
- !blank?(current) && !HRULE_RE.match(current) && SETEXT_RE.match(peek(1).to_s)
170
- end
223
+ # 弹出所有「缩进不小于 +indent+」的容器,返回合适的父容器。
224
+ def pop_to_parent(stack, indent)
225
+ stack.pop while stack.size > 1 && stack.last.indent >= indent
226
+ stack.last.node
227
+ end
171
228
 
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
229
+ # 只弹出「缩进严格大于 +indent+」的容器,保留同级的可复用容器。
230
+ def pop_deeper(stack, indent)
231
+ stack.pop while stack.size > 1 && stack.last.indent > indent
232
+ end
178
233
 
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))
234
+ # 把容器里最后一个同级段落提升为定义术语。
235
+ def promote_to_definition_list(parent)
236
+ list = Node::DefinitionList.new
237
+ last = parent.children.last
238
+
239
+ if last.is_a?(Node::Paragraph)
240
+ list << Node::Term.new(last.text)
241
+ parent.replace(last, list)
242
+ else
243
+ parent << list
186
244
  end
245
+ list
246
+ end
187
247
 
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
248
+ def build_heading(token)
249
+ Node::Heading.new(token.value[:level], token.value[:text], closed: token.value[:closed])
250
+ end
224
251
 
225
- list = List.new(ordered: ordered, tight: true, start: start)
226
- items.each { |li| list.append(li) }
227
- list
228
- end
252
+ def build_code_block(token)
253
+ Node::CodeBlock.new(
254
+ token.value[:code],
255
+ lang: token.value[:lang],
256
+ params: token.value[:params],
257
+ fence: token.value[:fence]
258
+ )
259
+ end
229
260
 
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
261
+ # 表格(CSM §1.12):行 → 单元格,表头行用 +TableHeaderCell+,其余用
262
+ # +TableCell+;对齐逐列取自该行生效的分隔行。
263
+ def build_table(token)
264
+ Node::Table.new(children: token.value[:rows].map { |row| build_table_row(row) })
265
+ end
235
266
 
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
267
+ def build_table_row(row)
268
+ klass = row[:header] ? Node::TableHeaderCell : Node::TableCell
269
+ align = row[:align] || []
270
+ cells = row[:cells].each_with_index.map { |text, column| klass.new(text, align: align[column]) }
271
+ Node::TableRow.new(children: cells)
272
+ end
244
273
 
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)
274
+ def build_embed(token)
275
+ Node::RubyEmbed.new(
276
+ source: token.value[:source],
277
+ form: token.value[:form],
278
+ lines: token.value[:lines],
279
+ indent: token.value[:indent],
280
+ render: token.value[:render]
281
+ )
282
+ end
251
283
 
252
- false
253
- end
284
+ # 把行内容器(内容策略 PHRASING)的 +text+ 原文解析为行内节点,插到
285
+ # +children+ 之前。
286
+ #
287
+ # +attributes[:text]+ 保留原文(供保真回吐与调试),行内节点才是向下游交付的
288
+ # 结构。FLOW 容器不直接承载行内内容,所以这里不碰它们——它们的正文是段落,
289
+ # 段落自己会被递归解析到。
290
+ def resolve_inline(node)
291
+ node.children.each { |child| resolve_inline(child) }
292
+ return unless PHRASING_CONTAINERS.include?(node.type)
254
293
 
255
- # Parse a run of inline text into inline nodes.
256
- def inline_children(text)
257
- return [] if text.to_s.empty?
294
+ text = node.attributes[:text]
295
+ return if text.nil? || text.empty?
258
296
 
259
- nodes = InlineParser.parse(text)
260
- trim_hardbreak_text(nodes)
261
- end
297
+ InlineParser.parse(text).reverse_each { |inline| node.prepend(inline) }
298
+ end
262
299
 
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
300
+ # 文档末尾由空行开出的空节块没有意义,解析完清掉。
301
+ def drop_empty_divisions(root)
302
+ root.children.dup.each do |child|
303
+ root.remove(child) if child.is_a?(Node::Div) && child.children.empty?
277
304
  end
278
305
  end
279
306
  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
307
  end