red_quilt 0.6.1 → 0.7.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/ast-spec.md DELETED
@@ -1,1227 +0,0 @@
1
- # Markdown Processor: Arena AST 設計案
2
-
3
- ## 目的
4
-
5
- RubyでMarkdown processorを実装するにあたり、通常のRubyオブジェクトツリーとしてASTを構築すると、ノード数・文字列断片・Tokenオブジェクトが大量に生成され、Markly/cmark系と比べて速度・メモリ使用量で大きく不利になりやすい。
6
-
7
- そのため、本設計では以下を目標とする。
8
-
9
- - 内部ASTはRubyオブジェクトツリーではなく、数値IDベースのArena構造で保持する
10
- - Textノードは文字列を複製せず、元ソース文字列へのspanとして保持する
11
- - 内部処理ではNodeオブジェクトを作らず、`node_id` の整数だけを取り回す
12
- - 外部APIでは必要に応じて `NodeRef` wrapper を返す
13
- - `parse` と `render_html` の経路を分け、HTML生成だけなら完全AST構築を避けられる余地を残す
14
-
15
- ## 基本方針
16
-
17
- 処理系は大きく以下の層に分ける。
18
-
19
- ```text
20
- Source
21
-
22
- Line-oriented BlockParser
23
-
24
- Arena AST with raw inline spans
25
-
26
- InlinePass
27
-
28
- Inline::Lexer → Inline::Tokens → Inline::Builder
29
-
30
- Arena AST with inline nodes
31
-
32
- Renderer / Formatter / Transformer
33
- ```
34
-
35
- APIとしては次の2系統を持つ。
36
-
37
- ```ruby
38
- doc = RedQuilt.parse(source) # Arena ASTを構築する
39
- html = RedQuilt.render_html(source) # 速度重視。AST構築を省略できる余地を持つ (現状は内部で parse 経由)
40
- ```
41
-
42
- ## 全体構成
43
-
44
- ```text
45
- lib/red_quilt/
46
- source_map.rb
47
- source_span.rb
48
-
49
- arena.rb
50
- node_ref.rb
51
- node_type.rb
52
-
53
- block_parser.rb
54
- inline_pass.rb
55
- extended_autolink_pass.rb
56
- inline/
57
- lexer.rb # 文字スキャン + token emit (StringScanner ベース)
58
- tokens.rb # Inline::Tokens (parallel array storage)
59
- token_kind.rb # token kind 定数
60
- builder.rb # token 消費 + delimiter stack + arena への node 追加
61
- flanking.rb # left/right flanking 判定ヘルパー
62
- html_entities.rb # HTML5 named entity 辞書
63
-
64
- renderer/html.rb
65
- document.rb
66
- diagnostic.rb
67
-
68
- cli.rb
69
- version.rb
70
- ```
71
-
72
- formatter / transformer は今のところ未実装 (拡張枠としてのみ言及)。
73
-
74
- インライン処理は **Lexer + Builder の二段構成**。
75
-
76
- - `Inline::Lexer`: source の文字スキャンと token emit。Arena を触らない
77
- - `Inline::Tokens`: token stream の軽量ストレージ (parallel array)
78
- - `Inline::Builder`: token stream を消費し、delimiter stack で emphasis を解決して Arena に node を追加
79
- - 二段に分けることで、CommonMark spec の delimiter stack アルゴリズムを素直に実装でき、GFM strikethrough のような拡張も DELIM_RUN の char を 1 つ増やすだけで取り込める
80
-
81
- Builder は token stream のインターフェイス (`Inline::Tokens` 互換) にだけ依存し、Lexer の実装を選ばない。
82
-
83
- ## Arena AST
84
-
85
- ### コンセプト
86
-
87
- 通常のASTは次のようにノードごとにRubyオブジェクトを作る。
88
-
89
- ```ruby
90
- Document.new([
91
- Heading.new(level: 1, children: [
92
- Text.new("Hello")
93
- ]),
94
- Paragraph.new(children: [
95
- Text.new("World")
96
- ])
97
- ])
98
- ```
99
-
100
- 本設計では、内部的にはこうしたオブジェクトツリーを作らない。
101
-
102
- 代わりに、すべてのノードを `node_id` で識別し、ノード情報を複数の配列に分けて保持する。
103
-
104
- ```text
105
- node_id = 0
106
- type[0] = DOCUMENT
107
- first_child[0] = 1
108
- next_sibling[0] = -1
109
-
110
- node_id = 1
111
- type[1] = HEADING
112
- level[1] = 1
113
- first_child[1] = 2
114
- next_sibling[1] = 3
115
-
116
- node_id = 2
117
- type[2] = TEXT
118
- source_start[2] = 2
119
- source_len[2] = 5
120
-
121
- node_id = 3
122
- type[3] = PARAGRAPH
123
- ...
124
- ```
125
-
126
- ### Arenaの基本構造
127
-
128
- 初期実装ではRubyの配列を使う。
129
-
130
- ```ruby
131
- module RedQuilt
132
- class Arena
133
- attr_reader :source
134
-
135
- def initialize(source)
136
- @source = source
137
-
138
- @type = []
139
-
140
- @parent = []
141
- @first_child = []
142
- @last_child = []
143
- @next_sibling = []
144
- @prev_sibling = []
145
-
146
- @source_start = []
147
- @source_len = []
148
-
149
- @int1 = []
150
- @int2 = []
151
- @int3 = []
152
-
153
- @str1 = []
154
- @str2 = []
155
- end
156
-
157
- def add_node(type, source_start: -1, source_len: 0, int1: 0, int2: 0, int3: 0, str1: nil, str2: nil)
158
- id = @type.length
159
-
160
- @type[id] = type
161
-
162
- @parent[id] = -1
163
- @first_child[id] = -1
164
- @last_child[id] = -1
165
- @next_sibling[id] = -1
166
- @prev_sibling[id] = -1
167
-
168
- @source_start[id] = source_start
169
- @source_len[id] = source_len
170
-
171
- @int1[id] = int1
172
- @int2[id] = int2
173
- @int3[id] = int3
174
-
175
- @str1[id] = str1
176
- @str2[id] = str2
177
-
178
- id
179
- end
180
-
181
- def append_child(parent_id, child_id)
182
- @parent[child_id] = parent_id
183
-
184
- if @first_child[parent_id] == NO_NODE
185
- @first_child[parent_id] = child_id
186
- @last_child[parent_id] = child_id
187
- else
188
- last = @last_child[parent_id]
189
- @next_sibling[last] = child_id
190
- @prev_sibling[child_id] = last
191
- @last_child[parent_id] = child_id
192
- end
193
-
194
- child_id
195
- end
196
- end
197
- end
198
- ```
199
-
200
- > 子・兄弟へのアクセス API は `raw_first_child_id` / `raw_last_child_id` / `raw_next_sibling_id` / `raw_prev_sibling_id` / `raw_parent_id` のように `raw_..._id` 命名規則を採る。`raw_` は「NO_NODE (-1) 戻り値あり」、`_id` は「node id を返す」ことの明示。詳細は `arena-usage.md`。
201
-
202
- ### なぜparallel arrayにするか
203
-
204
- ノードごとにHashやStructを作ると、ノード数に比例してRubyオブジェクトが増える。
205
-
206
- ```ruby
207
- {
208
- type: :text,
209
- start: 10,
210
- length: 5,
211
- children: []
212
- }
213
- ```
214
-
215
- のような構造は扱いやすいが、Markdownのように細かいTextノードが大量に出る処理では不利になる。
216
-
217
- parallel arrayにすると、少なくともノード自体のRubyオブジェクト生成を避けられる。
218
-
219
- ```text
220
- @type[node_id]
221
- @source_start[node_id]
222
- @source_len[node_id]
223
- @first_child[node_id]
224
- ```
225
-
226
- というアクセスになるため、内部処理は整数IDだけで完結する。
227
-
228
- ## ノード種別
229
-
230
- ノード種別はSymbolではなくInteger定数にする。
231
-
232
- ```ruby
233
- module RedQuilt
234
- module NodeType
235
- DOCUMENT = 1
236
-
237
- PARAGRAPH = 10
238
- HEADING = 11
239
- THEMATIC_BREAK = 12
240
- BLOCKQUOTE = 13
241
- LIST = 14
242
- LIST_ITEM = 15
243
- CODE_BLOCK = 16
244
- HTML_BLOCK = 17
245
- TABLE = 18
246
- TABLE_ROW = 19
247
- TABLE_CELL = 20
248
-
249
- TEXT = 100
250
- SOFTBREAK = 101
251
- HARDBREAK = 102
252
- EMPHASIS = 103
253
- STRONG = 104
254
- CODE_SPAN = 105
255
- LINK = 106
256
- IMAGE = 107
257
- HTML_INLINE = 109
258
- STRIKETHROUGH = 111
259
- end
260
- end
261
- ```
262
-
263
- > `AUTOLINK` / `ENTITY` の専用 NodeType は持たない。`<http://...>` は通常の `LINK` ノード (autolink token を Builder 側で `LINK` に変換)、`&amp;` などの entity は decoded literal を `str1` に持つ `TEXT` として表現する。
264
-
265
- 外部APIではSymbolに変換して返してもよい。
266
-
267
- ```ruby
268
- node.type
269
- #=> :paragraph
270
- ```
271
-
272
- ただし内部処理ではIntegerのまま扱う。
273
-
274
- ## ノード属性の持ち方
275
-
276
- ### 共通属性
277
-
278
- すべてのノードは以下を持つ。
279
-
280
- ```text
281
- type
282
- parent
283
- first_child
284
- last_child
285
- next_sibling
286
- prev_sibling
287
- source_start
288
- source_len
289
- ```
290
-
291
- `source_start` / `source_len` は元ソース文字列におけるbyte offsetである。
292
-
293
- line / column は保持せず、必要時に `SourceMap` から計算する。
294
-
295
- ### 汎用スロット
296
-
297
- ノード種別ごとの属性は、まずは汎用スロットで持つ。
298
-
299
- ```text
300
- int1
301
- int2
302
- int3
303
- str1
304
- str2
305
- ```
306
-
307
- 例:
308
-
309
- ```text
310
- HEADING:
311
- int1 = level
312
-
313
- LIST:
314
- int1 = ordered? 1 : 0
315
- int2 = start_number
316
- int3 = tight? 1 : 0
317
-
318
- CODE_BLOCK:
319
- str1 = info
320
- source_start/source_len = literal body span
321
-
322
- LINK:
323
- str1 = destination
324
- str2 = title
325
-
326
- IMAGE:
327
- str1 = destination
328
- str2 = title
329
-
330
- TABLE_CELL:
331
- int1 = alignment
332
- ```
333
-
334
- 初期実装ではこれで十分だが、ノード種別が増えて複雑になった場合は、専用属性テーブルを追加する。
335
-
336
- ```text
337
- @heading_level[node_id]
338
- @list_ordered[node_id]
339
- @link_destination[node_id]
340
- ```
341
-
342
- ただし、最初から専用配列を増やしすぎると実装が煩雑になるため、まずは汎用スロットで開始する。
343
-
344
- ## Source Span
345
-
346
- ### byte offsetを真の位置とする
347
-
348
- 内部位置はすべてbyte offsetで持つ。
349
-
350
- ```text
351
- source_start: byte offset
352
- source_len: byte length
353
- ```
354
-
355
- RubyのStringはUTF-8文字を含むため、文字数ベースのcolumnは高コストになりやすい。内部処理ではbyte offsetを真の値とし、line/columnは診断表示時にだけ計算する。なお、外部APIに公開する `column` 自体は文字単位とする(後述)。
356
-
357
- ### 文字インデックスとバイトオフセットの使い分け
358
-
359
- red_quiltは内部で文字インデックスとバイトオフセットを意図的に使い分ける。
360
- 両者を取り違えるとマルチバイト文字(CJK / Cyrillicなど)でHTMLが壊れる、source spanがずれる、といった事故が発生するため、レイヤーごとに「どちらの単位を使うか」を固定する。
361
-
362
- #### レイヤー別の単位
363
-
364
- | レイヤー | 単位 | 理由 |
365
- |----------|------|------|
366
- | `Arena#source_start` / `source_len` | byte | `String#byteslice` でO(len)に取り出せる |
367
- | `SourceSpan#start_byte` / `end_byte` | byte | 内部単位をそのまま公開(名前で明示) |
368
- | `Inline::Lexer` 内部位置 (`StringScanner#pos`) | byte | token の `start_byte` / `end_byte` をそのまま Arena に渡せる |
369
- | `Inline::Tokens` 各 token の位置 | byte | document 全体の絶対 byte offset |
370
- | `SourceMap#line_column` 入力 | byte | byte offset から line/column を逆引き |
371
- | `node.source_location` の column | char | 編集系ツール・診断表示はユーザーにとって文字単位の方が自然 |
372
-
373
- #### なぜこの分け方になるか
374
-
375
- Rubyの`String`には2系統のAPIがある。
376
-
377
- - 文字単位: `String#[]`, `String#index(regex)`, `String#match`, `String#length`
378
- - バイト単位: `String#byteslice`, `String#bytesize`, `String#getbyte`
379
-
380
- それぞれの性質:
381
-
382
- - `byteslice(start, len)` は O(len) で高速。Arena経由のtext取り出しというホットパスはこれが向く
383
- - 一方、文字インデックスでの`String#[]`はマルチバイト文字を含む場合O(n)になりうる
384
- - 一方、`StringScanner` は内部でバイト位置 (`pos`) を保持しつつ Ruby の Regexp anchor を使えるため、byte 単位のままアンカ付き match ができる
385
-
386
- このため:
387
-
388
- - **ホットパス(Arena保存・renderer出力)はbyte**
389
- - **Inline::Lexer も byte で進める** (`StringScanner#pos` + `String#byteindex` + binary view)
390
- - **ユーザー向け診断(column)は文字単位**
391
-
392
- という構造になる。Lexer が char index を経由しないので、char ↔ byte の取り違えが起きうる箇所が消えている。
393
-
394
- #### コーディングルール
395
-
396
- - 変数・引数の単位を**名前で明示**する
397
- - byte: `start_byte`, `end_byte`, `byte_offset`, `byte_index`
398
- - char: `char_offset`, `start_column`
399
- - inline 側で文字単位が必要になるのは、Flanking の前後 1 文字を取り出す `char_before` / `char_at` だけ。ここだけ byte → char を変換する (ASCII fast path + multibyte 時のみ byteslice)
400
- - マルチバイト文字を含む回帰テストをspecに必ず置き、文字とbyteの混在によるバグを早期検出する(例: `_пристаням_стремятся`, `日本語の*強調*テスト`)
401
-
402
- ### SourceMap
403
-
404
- `SourceMap` は改行位置の配列を持つ。
405
-
406
- ```ruby
407
- module RedQuilt
408
- class SourceMap
409
- def initialize(source)
410
- @source = source
411
- @line_starts = build_line_starts(source)
412
- end
413
-
414
- def line_column(byte_offset)
415
- # @line_startsを二分探索して line / column を返す
416
- end
417
-
418
- private
419
-
420
- def build_line_starts(source)
421
- starts = [0]
422
- pos = 0
423
-
424
- while (idx = source.index("\n", pos))
425
- starts << idx + 1
426
- pos = idx + 1
427
- end
428
-
429
- starts
430
- end
431
- end
432
- end
433
- ```
434
-
435
- ### NodeRefでの位置取得
436
-
437
- 外部APIでは以下のようにする。
438
-
439
- ```ruby
440
- node.source_span
441
- #=> #<SourceSpan start_byte=10 end_byte=20>
442
- # start_byte / end_byte は byte offset
443
-
444
- node.source_location
445
- #=> { start_line: 3, start_column: 5, end_line: 3, end_column: 15 }
446
- # line は 1-indexed, column は 0-indexed の **文字単位** (char)
447
- ```
448
-
449
- `source_location` は毎回計算するとコストが高い可能性があるため、必要時のみ計算する(SourceMapは`Document`側でメモ化される)。
450
-
451
- ## Textノード
452
-
453
- ### Textは文字列を持たない
454
-
455
- Textノードは文字列を直接持たない。
456
-
457
- ```text
458
- TEXT:
459
- source_start = 123
460
- source_len = 5
461
- ```
462
-
463
- 外部APIで `node.text` が呼ばれたときだけ切り出す。
464
-
465
- ```ruby
466
- def text(node_id)
467
- @source.byteslice(@source_start[node_id], @source_len[node_id])
468
- end
469
- ```
470
-
471
- これにより、parse時に小さいStringを大量生成することを避ける。
472
-
473
- ### HTML rendererでの扱い
474
-
475
- HTML rendererでは、Textノードの文字列を一度Ruby Stringとして切り出すのではなく、可能ならspanを直接escapeして出力する。
476
-
477
- ```ruby
478
- def render_text(id, out)
479
- start = @arena.source_start(id)
480
- len = @arena.source_len(id)
481
- escape_html_span(@arena.source, start, len, out)
482
- end
483
- ```
484
-
485
- 初期実装では `byteslice` してもよいが、性能が問題になったらspan直接処理にする。
486
-
487
- ## NodeRef
488
-
489
- ### 役割
490
-
491
- 外部APIでは、利用者に整数IDを直接触らせない。
492
-
493
- ```ruby
494
- doc.root.children.each do |node|
495
- puts node.type
496
- end
497
- ```
498
-
499
- ただし、この `node` は実体ではなく、`arena` と `node_id` を持つ軽量wrapperである。
500
-
501
- ```ruby
502
- module RedQuilt
503
- class NodeRef
504
- include Enumerable
505
-
506
- attr_reader :document, :node_id
507
-
508
- def initialize(document, node_id)
509
- @document = document
510
- @arena = document.arena
511
- @node_id = node_id
512
- end
513
-
514
- def type
515
- @arena.type_name(@node_id)
516
- end
517
-
518
- def children
519
- @arena.child_ids(@node_id).map { |id| NodeRef.new(@document, id) }
520
- end
521
-
522
- def walk(&block)
523
- return enum_for(:walk) unless block_given?
524
- yield self
525
- @arena.child_ids(@node_id).each do |id|
526
- NodeRef.new(@document, id).walk(&block)
527
- end
528
- end
529
- alias each walk
530
-
531
- def text
532
- # 子があれば concat, なければ自分の text
533
- end
534
-
535
- def source_span
536
- @arena.source_span(@node_id)
537
- end
538
-
539
- def source_location
540
- # source_span を SourceMap で line/column へ変換
541
- end
542
-
543
- def find_all(type)
544
- walk.select { |n| n.type == type }
545
- end
546
-
547
- def to_h
548
- # AST を Hash として export
549
- end
550
- end
551
- end
552
- ```
553
-
554
- ### 内部処理では使わない
555
-
556
- Renderer、Formatter、InlinePassなど内部のホットパスでは `NodeRef` を作らない。
557
-
558
- ```ruby
559
- def render_node(id, out)
560
- case @arena.type(id)
561
- when NodeType::TEXT
562
- render_text(id, out)
563
- when NodeType::PARAGRAPH
564
- render_paragraph(id, out)
565
- end
566
- end
567
- ```
568
-
569
- `NodeRef` は外部API用と割り切る。
570
-
571
- ## Document
572
-
573
- `Document` はArenaとSourceMapを保持し、AST export 系メソッド (`to_h` / `to_mdast` / `to_json`) も提供する。
574
-
575
- ```ruby
576
- module RedQuilt
577
- class Document
578
- attr_reader :source, :arena, :root_id
579
-
580
- def initialize(source, arena, root_id, allow_html: false, references: {})
581
- @source = source
582
- @arena = arena
583
- @root_id = root_id
584
- @allow_html = allow_html
585
- @references = references
586
- end
587
-
588
- def root
589
- NodeRef.new(self, @root_id)
590
- end
591
-
592
- def walk(&block)
593
- root.walk(&block)
594
- end
595
-
596
- def source_map
597
- @source_map ||= SourceMap.new(@source)
598
- end
599
-
600
- def diagnostics
601
- @diagnostics ||= []
602
- end
603
-
604
- def to_html(standalone: false, title: nil, lang: "en", css: nil)
605
- # ...
606
- end
607
-
608
- def to_ast
609
- root.to_h
610
- end
611
-
612
- def to_mdast
613
- # MDAST-compatible Hash
614
- end
615
-
616
- def to_json(*)
617
- JSON.pretty_generate(to_mdast)
618
- end
619
- end
620
- end
621
- ```
622
-
623
- 外部から扱いやすい AST が必要な場合は `to_h` / `to_mdast` / `to_json` を使う。MDAST 形式は `unifiedjs/mdast` 互換で、エディタ系・lint 系ツールにそのまま渡せる。
624
-
625
- ## Block Parser
626
-
627
- ### 方針
628
-
629
- Block parserは行指向で実装する。
630
-
631
- 担当するのは以下である。
632
-
633
- - blank line
634
- - paragraph
635
- - ATX heading
636
- - thematic break
637
- - blockquote
638
- - unordered list
639
- - ordered list
640
- - list item
641
- - fenced code block
642
- - indented code block
643
- - table
644
- - front matter
645
- - raw HTML block
646
-
647
- Inline構文はここでは処理しない。
648
-
649
- ### Paragraphの扱い
650
-
651
- paragraphは複数行をまとめて、raw inline spanとして保持する。
652
-
653
- ```markdown
654
- This is *emphasis
655
- continued here*.
656
- ```
657
-
658
- この段階では `*emphasis...*` は解釈しない。
659
-
660
- ```text
661
- PARAGRAPH:
662
- source_start = paragraph_content_start
663
- source_len = paragraph_content_len
664
- children = empty
665
- ```
666
-
667
- 後続のInlinePassでchildrenを作る。
668
-
669
- ### Headingの扱い
670
-
671
- ```markdown
672
- ## Hello *world*
673
- ```
674
-
675
- Block parserは見出しレベルとinline部分のspanだけを記録する。
676
-
677
- ```text
678
- HEADING:
679
- int1 = 2
680
- source_start = inline_start
681
- source_len = inline_len
682
- ```
683
-
684
- ### Code blockの扱い
685
-
686
- fenced code blockはinline parseしない。
687
-
688
- ````markdown
689
- ```ruby
690
- puts "*not emphasis*"
691
- ```
692
- ````
693
-
694
- Arenaには以下のように保存する。
695
-
696
- ```text
697
- CODE_BLOCK:
698
- str1 = "ruby"
699
- source_start = code_body_start
700
- source_len = code_body_len
701
- ```
702
-
703
- ### Container stack
704
-
705
- blockquoteやlistはネストするため、block parserはcontainer stackを持つ。
706
-
707
- ```text
708
- open_containers:
709
- DOCUMENT
710
- BLOCKQUOTE
711
- LIST
712
- LIST_ITEM
713
- ```
714
-
715
- 各行について、
716
-
717
- ```text
718
- 1. 既存containerに継続できるか判定
719
- 2. 継続できないcontainerを閉じる
720
- 3. 新しいblock開始を判定
721
- 4. 現在のleaf blockに行内容を追加
722
- ```
723
-
724
- という流れで処理する。
725
-
726
- ## Inline Pass
727
-
728
- ### 方針
729
-
730
- Block parserが作ったArena ASTを走査し、inline対象ノードに対してInlineParserを実行する。
731
-
732
- 対象ノード:
733
-
734
- - PARAGRAPH
735
- - HEADING
736
- - TABLE_CELL
737
- - LINK内のlabel相当部分
738
- - IMAGE内のalt相当部分
739
-
740
- 対象外ノード:
741
-
742
- - CODE_BLOCK
743
- - HTML_BLOCK
744
- - THEMATIC_BREAK
745
- - FRONT_MATTER
746
- - raw block系拡張
747
-
748
- ### 処理例
749
-
750
- ```ruby
751
- module RedQuilt
752
- class InlinePass
753
- INLINE_TARGETS = [NodeType::PARAGRAPH, NodeType::HEADING, NodeType::TABLE_CELL].freeze
754
-
755
- def initialize(document)
756
- @document = document
757
- @arena = document.arena
758
- @lexer = Inline::Lexer.new(@document.source)
759
- @tokens = Inline::Tokens.new
760
- @builder = Inline::Builder.new(@arena, @document.source, @document.references,
761
- diagnostics: @document.diagnostics)
762
- end
763
-
764
- def apply
765
- visit(@document.root_id)
766
- end
767
-
768
- private
769
-
770
- def visit(id)
771
- if INLINE_TARGETS.include?(@arena.type(id))
772
- @tokens.clear
773
- start_byte = @arena.source_start(id)
774
- end_byte = start_byte + @arena.source_len(id)
775
- @lexer.lex_into(@tokens, start_byte, end_byte)
776
- @builder.build(id, @tokens)
777
- return
778
- end
779
-
780
- child = @arena.raw_first_child_id(id)
781
- until child == -1
782
- visit(child)
783
- child = @arena.raw_next_sibling_id(child)
784
- end
785
- end
786
- end
787
- end
788
- ```
789
-
790
- `@tokens` / `@lexer` / `@builder` は document 単位で 1 つだけ作り、`@tokens.clear` で内容だけ捨てて使い回す。実装では blockquote / list 継続 prefix を取り除いた literal を持つノードのケースで一時 Lexer / Builder を作る分岐があるが、頻度が低いので省略。本ドキュメントでは設計方針のみ示す。実装詳細はソースを参照。
791
-
792
- ## Inline Lexer
793
-
794
- ### 責務
795
-
796
- - `@source` (document 全体) の `[start_byte, end_byte)` をスキャンし、token を emit する
797
- - Arena は触らない
798
- - `*` / `_` の delimiter run には flanking 判定 (can_open / can_close) を焼き込んで emit する
799
- - 内部状態は byte offset 1 本。char index は必要時だけ `byteslice` から導く
800
-
801
- ### Token kind (最小セット)
802
-
803
- ```text
804
- TEXT プレーンテキスト span
805
- ENTITY HTML entity (str1 = decoded literal)
806
- ESCAPED_CHAR backslash escape (str1 = original char)
807
- LINE_ENDING 改行 (builder で softbreak / hardbreak を判定)
808
- CODE_DELIMITER ` run (int1 = run length)
809
- DELIM_RUN * or _ run (char, count, can_open, can_close)
810
- LBRACKET [
811
- BANG_LBRACKET ![
812
- RBRACKET ]
813
- AUTOLINK_URI <scheme:...>
814
- AUTOLINK_EMAIL <addr@host>
815
- HTML_INLINE <tag ...>
816
- ```
817
-
818
- token は struct を作らず parallel array (`InlineTokens`) に格納する。各 token は `(kind, start_byte, end_byte, int1, int2, int3, str1)` を持つ。
819
-
820
- ### Token object は作らない
821
-
822
- `InlineTokens` は parallel array で、token は `token_id` (Integer) で参照する。Token struct を作らないことで、長い paragraph でも軽量に保つ。
823
-
824
- 診断や debug 用途で必要な場合のみ、Token Struct を生成する別 API を提供する。
825
-
826
- ## Inline Builder
827
-
828
- ### 責務
829
-
830
- - `Inline::Tokens` を消費し、Arena に inline node を追加する
831
- - delimiter stack で EMPHASIS / STRONG / STRIKETHROUGH を解決 (CommonMark spec 6.2 + GFM)
832
- - `[label](destination)` / `[label][ref]` / `[label]` の bracket matching
833
- - code span のマッチング (同じ run length の `CODE_DELIMITER` でクローズ)
834
-
835
- ### コンストラクタ
836
-
837
- ```ruby
838
- Inline::Builder.new(arena, source, references,
839
- track_source: true,
840
- diagnostics: nil)
841
- ```
842
-
843
- - `track_source: false` は blockquote / list 継続 prefix を取り除いた literal を入力にする場合 (`source` が document 全体ではなく再構築済み文字列のとき) に使う。この場合 Arena には source span を記録せず、TEXT は str1 に literal を持つ
844
- - `diagnostics` は `Document#diagnostics` を受け取り、unsafe URL や missing reference を append する
845
-
846
- ### 処理段階
847
-
848
- 1. **linear_pass**: token を頭から処理し、code span / link / image / autolink / HTML を解決。emphasis 系の delimiter run は暫定 TEXT ノードとして Arena に追加しつつ、`@delimiter_stack` に push。`[` / `![` / `]` は別途 `@bracket_stack` で管理する
849
- 2. **process_emphasis**: `@delimiter_stack` を後方走査して opener / closer をペアリング、EMPHASIS / STRONG / STRIKETHROUGH ノードを構築。リンク内側の delimiter は `finalize_link` 時点で `slice!` して個別に処理する
850
-
851
- ### CommonMark 互換
852
-
853
- 旧設計では「完全 CommonMark 互換は目指さない」「曖昧な delimiter run は text 扱い」と書いていたが、現行実装は **delimiter stack アルゴリズム (spec 6.2) を素直に実装**しており、CommonMark v0.31.2 全 example が pass する。
854
-
855
- それでも以下は引き続き「pragmatic」な扱い:
856
-
857
- - 極端に深いネストは Ruby のスタック / メモリで制限される
858
- - HTML inline / autolink の URL 形式は spec の正規表現に従う
859
-
860
- 処理できない構文はエラーではなく text に戻す方針は変えない。
861
-
862
- ### Text coalescing
863
-
864
- 連続する TEXT は Arena 上で 1 つのノードにまとめる。
865
-
866
- - 直前の子が TEXT で、source span が連続しているなら、新ノードを追加せず source_len を伸ばす
867
- - ENTITY / ESCAPED_CHAR は str1 を持つため、merge 時に文字列連結が必要 (現状の `append_text` と同じ思想)
868
-
869
- ノード種別は TEXT 1 種類で十分。「source span ベース」と「str1 (literal) ベース」の TEXT は、`str1` が nil かどうかで区別する。
870
-
871
- ## Renderer
872
-
873
- ### HTML Renderer
874
-
875
- Rendererは内部的に `node_id` で動く。`each_child` (ブロック直 yield) でホットパスから `NodeRef` 生成と Enumerator allocation を避ける。
876
-
877
- ```ruby
878
- module RedQuilt
879
- module Renderer
880
- class HTML
881
- def initialize(document)
882
- @document = document
883
- @arena = document.arena
884
- @out = +""
885
- end
886
-
887
- def render
888
- render_children(@document.root_id)
889
- @out
890
- end
891
-
892
- private
893
-
894
- def render_node(id)
895
- case @arena.type(id)
896
- when NodeType::PARAGRAPH
897
- @out << "<p>"
898
- render_children(id)
899
- @out << "</p>\n"
900
- when NodeType::HEADING
901
- level = @arena.int1(id)
902
- @out << "<h#{level}>"
903
- render_children(id)
904
- @out << "</h#{level}>\n"
905
- when NodeType::TEXT
906
- render_text(id)
907
- when NodeType::CODE_BLOCK
908
- render_code_block(id)
909
- end
910
- end
911
-
912
- def render_children(id)
913
- @arena.each_child(id) { |child_id| render_node(child_id) }
914
- end
915
- end
916
- end
917
- end
918
- ```
919
-
920
- ### Safe HTML by default
921
-
922
- raw HTMLはデフォルトで無効にする。
923
-
924
- ```ruby
925
- RedQuilt.render_html(source, allow_html: false)
926
- ```
927
-
928
- `allow_html: false` の場合:
929
-
930
- - HTML blockはescapeする
931
- - HTML inlineもescapeする
932
- - link destinationは危険なschemeを抑止する
933
-
934
- ### HTML fast path
935
-
936
- 将来的には、完全なArena ASTを作らずにHTMLを出すfast pathを追加する。
937
-
938
- ```ruby
939
- RedQuilt.render_html(source)
940
- ```
941
-
942
- 内部では、
943
-
944
- ```text
945
- BlockParser event
946
- -> InlineParser event
947
- -> HTMLRenderer
948
- ```
949
-
950
- にする。
951
-
952
- ただし、初期実装ではArena AST経由でよい。
953
-
954
- ## Transformer
955
-
956
- ### 初期はread-only AST
957
-
958
- Arenaはmutation可能だが、最初から自由なmutable AST APIを公開しない。
959
-
960
- 初期API:
961
-
962
- ```ruby
963
- doc.root.walk
964
- doc.root.children
965
- node.type
966
- node.text
967
- node.source_span
968
- ```
969
-
970
- ### transformは新しいDocumentを作る
971
-
972
- in-place mutationは難しいため、最初はbuilder方式にする。
973
-
974
- ```ruby
975
- new_doc = doc.transform do |builder, node|
976
- case node.type
977
- when :heading
978
- builder.heading(node.level + 1) do
979
- builder.copy_children(node)
980
- end
981
- else
982
- builder.copy(node)
983
- end
984
- end
985
- ```
986
-
987
- この方式ならArena ASTと相性がよい。
988
-
989
- ### in-place mutationは後回し
990
-
991
- 以下は後で追加する。
992
-
993
- ```ruby
994
- node.append_child(...)
995
- node.insert_before(...)
996
- node.replace_with(...)
997
- node.delete
998
- ```
999
-
1000
- 実装する場合は、以下のリンクを正しく更新する必要がある。
1001
-
1002
- ```text
1003
- parent
1004
- first_child
1005
- last_child
1006
- next_sibling
1007
- prev_sibling
1008
- ```
1009
-
1010
- ## Formatter (未実装)
1011
-
1012
- FormatterはArena ASTをMarkdownへ戻す。現状は未実装。
1013
-
1014
- 用途 (構想):
1015
-
1016
- - 曖昧なMarkdownを正規化する
1017
- - processorが扱いやすいMarkdownへ整形する
1018
- - CommonMark完全互換を目指さない代わりに、安定した出力形式を提供する
1019
-
1020
- 例:
1021
-
1022
- ```ruby
1023
- RedQuilt.format(source) # 構想
1024
- ```
1025
-
1026
- 方針:
1027
-
1028
- - headingはATX headingへ統一
1029
- - fenced code blockを使う
1030
- - list indentationを統一
1031
- - tableを整形する
1032
- - emphasisは `*em*`、strongは `**strong**` に統一
1033
- - raw HTMLは設定に応じて保持またはescapeする
1034
-
1035
- ## Diagnostics
1036
-
1037
- ### Diagnostic object
1038
-
1039
- ```ruby
1040
- class Diagnostic
1041
- attr_reader :severity, :rule, :message, :source_span
1042
- end
1043
- ```
1044
-
1045
- `Document#diagnostics` が `Diagnostic` の Array を返す。表示時に `source_span` を `SourceMap` 経由で line/column へ変換できる。
1046
-
1047
- ### 現状で報告される rule
1048
-
1049
- - `unsafe_url` — `javascript:` などの危険スキームをブロックしたとき
1050
- - `missing_reference` — `[text][ref]` で参照先 reference definition が未定義のとき
1051
-
1052
- ### 用途 (構想)
1053
-
1054
- - heading level skip
1055
- - empty link destination
1056
- - missing image alt
1057
- - unsafe HTML
1058
-
1059
- ## Performance方針
1060
-
1061
- ### 避けること
1062
-
1063
- - Token objectを全トークン分作らない
1064
- - Text nodeごとにStringを切り出さない
1065
- - nodeごとにHashを作らない
1066
- - 内部走査でNodeRefを作らない
1067
- - Regexpを細かく何度も呼ばない
1068
- - rendererで `out += ...` を使わない
1069
-
1070
- ### 使うもの
1071
-
1072
- - byte offset
1073
- - `String#getbyte`
1074
- - `String#byteslice` は必要時のみ
1075
- - `String#<<` によるappend
1076
- - node_idによる内部処理
1077
- - parallel arrays
1078
-
1079
- ### ベンチ対象
1080
-
1081
- ```text
1082
- bench/fixtures/
1083
- readme.md
1084
- article.md
1085
- long_doc.md
1086
- inline_heavy.md
1087
- table_heavy.md
1088
- code_heavy.md
1089
- ```
1090
-
1091
- 比較対象:
1092
-
1093
- - Markly
1094
- - commonmarker
1095
- - kramdown
1096
- - 自作pure Ruby backend
1097
-
1098
- 測定対象:
1099
-
1100
- - parse only
1101
- - render html
1102
- - allocated objects
1103
- - memory usage
1104
- - GC time
1105
-
1106
- ## API案
1107
-
1108
- ### Parse
1109
-
1110
- ```ruby
1111
- doc = RedQuilt.parse(source)
1112
- ```
1113
-
1114
- ### Render
1115
-
1116
- ```ruby
1117
- html = RedQuilt.render_html(source)
1118
- ```
1119
-
1120
- ### AST traversal
1121
-
1122
- ```ruby
1123
- doc.root.children.each do |node|
1124
- puts node.type
1125
- puts node.source_span
1126
- end
1127
- ```
1128
-
1129
- ### Walk
1130
-
1131
- ```ruby
1132
- doc.root.walk do |node|
1133
- puts "#{node.type}: #{node.source_span}"
1134
- end
1135
- ```
1136
-
1137
- ### Find
1138
-
1139
- ```ruby
1140
- headings = doc.root.find_all(:heading)
1141
- ```
1142
-
1143
- ### Diagnostics
1144
-
1145
- ```ruby
1146
- doc.diagnostics.each do |diagnostic|
1147
- puts diagnostic.message
1148
- end
1149
- ```
1150
-
1151
- ### Format (構想)
1152
-
1153
- ```ruby
1154
- formatted = RedQuilt.format(source) # 未実装
1155
- ```
1156
-
1157
- ## 実装フェーズ
1158
-
1159
- ### Phase 1: 素直なプロトタイプ — 完了
1160
-
1161
- - 行指向block parser
1162
- - 最小inline parser
1163
- - ノード種別とAST形状を固定
1164
-
1165
- ### Phase 2: Arena AST化 — 完了
1166
-
1167
- - ノードを `node_id` ベースにする
1168
- - parallel arraysへ移行
1169
- - Textをsource span化
1170
- - NodeRef外部APIを追加
1171
-
1172
- ### Phase 3: Renderer最適化 — 完了
1173
-
1174
- - HTML rendererを `node_id` ベースにする
1175
- - Text spanを直接escapeする
1176
- - safe-by-default の HTML escape / URL scheme チェックを実装
1177
-
1178
- ### Phase 4: Inline Lexer / Builder への再構成 — 完了
1179
-
1180
- - `InlineParser` / `InlineScanner` を削除し、`Inline::Lexer` + `Inline::Builder` の二段構成へ移行済み
1181
- - substring 連鎖と base_offset 計算を排除し、Lexer は document 全体の絶対 byte offset で動く (`StringScanner#pos` + `String#byteindex` on binary view)
1182
- - Builder は delimiter stack (CommonMark spec 6.2) で emphasis を解決
1183
- - GFM strikethrough (`~~`) も DELIM_RUN の char に `~` を追加するだけで対応
1184
- - inline-heavy benchmark で最大 30x の速度改善
1185
-
1186
- ### Phase 5: HTML fast path — 未着手
1187
-
1188
- - AST構築なしでHTMLを出すevent rendererを検討
1189
- - `RedQuilt.render_html` の高速化を狙う (現状は AST 経由)
1190
- - `RedQuilt.parse` はArena ASTを返す
1191
-
1192
- ## まとめ
1193
-
1194
- 本設計では、Markdown ASTをRubyオブジェクトツリーとして表現せず、内部的にはArenaに格納された数値IDの集合として扱う。
1195
-
1196
- ```text
1197
- 内部:
1198
- node_id
1199
- parallel arrays
1200
- source span
1201
- no Token object
1202
- no Node object on hot path
1203
-
1204
- 外部:
1205
- Document
1206
- NodeRef
1207
- Enumerator
1208
- source_span
1209
- diagnostics
1210
- ```
1211
-
1212
- これにより、RubyらしいAST APIを提供しつつ、parse/render時のallocationを抑える。
1213
-
1214
- Markly/cmarkと完全に同等の速度をpure Rubyだけで達成するのは難しいが、この設計なら少なくとも以下を狙える。
1215
-
1216
- - kramdown的なpure Ruby processorより現代的な低allocation設計
1217
- - 通常文書で体感上遜色ない速度
1218
- - 将来的なnative fast pathの追加
1219
- - AST/diagnostics/formatter/transformerを備えたMarkdown document processor
1220
-
1221
- 最終的な位置づけは以下である。
1222
-
1223
- ```text
1224
- A pragmatic Markdown document processor for Ruby,
1225
- with a low-allocation arena AST, source spans,
1226
- and safe HTML rendering.
1227
- ```