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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +19 -0
- data/LICENSE.txt +21 -0
- data/README.md +63 -105
- data/docs/api.md +132 -0
- data/docs/{architecture.md → architecture.ja.md} +3 -3
- data/docs/{arena-usage.md → arena-usage.ja.md} +38 -30
- data/docs/{commonmark-conformance.md → commonmark-conformance.ja.md} +26 -10
- data/lib/red_quilt/cli.rb +1 -0
- data/lib/red_quilt/document.rb +4 -2
- data/lib/red_quilt/renderer/html.rb +8 -2
- data/lib/red_quilt/slug.rb +38 -0
- data/lib/red_quilt/tilt.rb +42 -0
- data/lib/red_quilt/version.rb +1 -1
- data/lib/red_quilt.rb +3 -2
- data/sig/red_quilt.rbs +199 -8
- metadata +15 -10
- data/.rspec +0 -3
- data/ast-spec.md +0 -1227
- data/mise.toml +0 -2
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` に変換)、`&` などの 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
|
-
```
|