red_quilt 0.8.0 → 0.9.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 +76 -0
- data/docs/api.md +23 -2
- data/lib/red_quilt/arena.rb +21 -0
- data/lib/red_quilt/block_parser.rb +29 -6
- data/lib/red_quilt/blockquote.rb +15 -2
- data/lib/red_quilt/code_block.rb +8 -2
- data/lib/red_quilt/inline/builder.rb +7 -7
- data/lib/red_quilt/inline/lexer.rb +4 -4
- data/lib/red_quilt/inline_pass.rb +15 -2
- data/lib/red_quilt/list.rb +13 -4
- data/lib/red_quilt/node_ref.rb +88 -2
- data/lib/red_quilt/renderer/mdast.rb +8 -4
- data/lib/red_quilt/source_map.rb +45 -5
- data/lib/red_quilt/version.rb +1 -1
- data/sig/red_quilt.rbs +18 -1
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 211bfba5adede6552fb64c3dfadf6c895325af2c58a577eadb86c8d426a3dd0b
|
|
4
|
+
data.tar.gz: 2e542ae344e626dc5a1bf0198b80761dfac3317b48f11011569235e5418048c9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8526155c616d353741dd68ad7740a0ad3d18e08139c239a6d2ff2d48198721e4b1a84a8da3eca98dae033d1da73a59817a766fe0b3bf89391897559a0fc37047
|
|
7
|
+
data.tar.gz: 272a4918326acc93dc8699308a8597c9e621f98518f0307ef7a8f2eed9ba03dc3153f2fbea641e6b02eb76af0adf612a7d8e8fca7b08360c130d1e34b7b5266c
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,82 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.9.0] - 2026-07-20
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **Breaking: source positions now follow the unist Point convention**, which
|
|
15
|
+
cmark sourcepos and mdast both use. Three related changes:
|
|
16
|
+
- `source_location` columns are 1-based (they were 0-based; lines were
|
|
17
|
+
already 1-based). Both are counted in characters, and `end` remains
|
|
18
|
+
exclusive.
|
|
19
|
+
- Block spans cover the block as authored rather than its content alone.
|
|
20
|
+
`# H1` now starts at the `#`, `> quote` at the `>`, a list item at its
|
|
21
|
+
bullet, a fenced code block includes both fences, and a setext heading
|
|
22
|
+
includes its `===` / `---` underline.
|
|
23
|
+
- Leading indent is excluded from block spans, so ` text` starts at
|
|
24
|
+
column 3 rather than column 1. This applies to ATX and setext headings,
|
|
25
|
+
paragraphs, blockquotes, fenced code blocks, lists, list items, and
|
|
26
|
+
thematic breaks.
|
|
27
|
+
- The fenced code block change also fixes a wrong *line* number: the span
|
|
28
|
+
used to start at the first content line, reporting the block one line
|
|
29
|
+
below its opening fence.
|
|
30
|
+
|
|
31
|
+
Every expected value was verified against commonmark.js and
|
|
32
|
+
mdast-util-from-markdown; RedQuilt now agrees with mdast on all of them
|
|
33
|
+
except an unclosed fence, where the two references disagree and RedQuilt
|
|
34
|
+
follows cmark. Callers that added 1 to columns, or that relied on a
|
|
35
|
+
heading span excluding `# `, need updating.
|
|
36
|
+
|
|
37
|
+
`NodeRef#text` is unaffected: a heading's text remains its content, without
|
|
38
|
+
the marker.
|
|
39
|
+
|
|
40
|
+
- **Breaking: `NodeRef#info` returns nil for nodes that are not code
|
|
41
|
+
blocks**, where 0.8.0 returned `""`. A code block written without an info
|
|
42
|
+
string still returns `""`, so the two cases are now distinguishable. This
|
|
43
|
+
matches the new attribute accessors, which all use nil for "this node's
|
|
44
|
+
type does not carry the attribute".
|
|
45
|
+
- Lowered the minimum supported Ruby from 3.3 to 3.1. The only 3.1
|
|
46
|
+
incompatibility was `String#byteindex` (added in Ruby 3.2), used in two
|
|
47
|
+
inline hot paths; both operate on a binary (`String#b`) view of the source,
|
|
48
|
+
where `String#index` returns the same byte offsets. CI now runs the test
|
|
49
|
+
suite on 3.1 through 4.0.
|
|
50
|
+
- `Gemfile.lock` is no longer committed. A lockfile resolved on one Ruby pins
|
|
51
|
+
development gem versions that do not exist for the others (e.g. `rdoc 8.0.0`
|
|
52
|
+
and `rbs 4.0.3` have no Ruby 3.1 release), so a single committed lockfile
|
|
53
|
+
cannot serve the supported range. Each Ruby now resolves its own.
|
|
54
|
+
|
|
55
|
+
### Fixed
|
|
56
|
+
|
|
57
|
+
- `source_location` reported wrong line numbers for any source containing a
|
|
58
|
+
multibyte character. The line-start table is indexed by byte offset, but it
|
|
59
|
+
was built with `String#index`, which counts characters, so every line after
|
|
60
|
+
the first multibyte character drifted. Existing multibyte coverage did not
|
|
61
|
+
catch this because it used single-line sources.
|
|
62
|
+
- `Renderer::MDAST` emitted positions that violated the unist spec: `column`
|
|
63
|
+
was 0-based where unist requires 1-based, and `offset` was a byte index
|
|
64
|
+
where unist requires a 0-based character index. The latter made every
|
|
65
|
+
offset after a multibyte character wrong.
|
|
66
|
+
|
|
67
|
+
### Added
|
|
68
|
+
|
|
69
|
+
- Node attribute accessors on `NodeRef`: `heading_level`, `list_ordered?`,
|
|
70
|
+
`list_start`, `list_tight?`, `list_delimiter`, `link_destination`,
|
|
71
|
+
`link_title`, `footnote_label`, `footnote_number`, and `header?`. Each
|
|
72
|
+
returns nil when the node's type does not carry the attribute, so callers
|
|
73
|
+
can walk a document branching on `#type` and read that type's fields
|
|
74
|
+
directly. Previously the only public route was `#to_h`, which builds a Hash
|
|
75
|
+
for the whole subtree — 5391 objects for a 257-node list where these
|
|
76
|
+
accessors allocate none.
|
|
77
|
+
|
|
78
|
+
These wrappers are the safe way to read attributes. The `Arena` accessors
|
|
79
|
+
they delegate to are the raw layer and skip the type check, and several
|
|
80
|
+
attributes share a storage column, so reading one off a mismatched node
|
|
81
|
+
there returns another field's value rather than nil (`Arena#link_destination`
|
|
82
|
+
on a paragraph returns the paragraph's text).
|
|
83
|
+
|
|
84
|
+
## [0.8.0] - 2026-06-24
|
|
85
|
+
|
|
10
86
|
### Added
|
|
11
87
|
|
|
12
88
|
- `NodeRef#info`: returns the fence info string of a code block (e.g. `ruby`
|
data/docs/api.md
CHANGED
|
@@ -50,14 +50,35 @@ node.children # Array[NodeRef]
|
|
|
50
50
|
node.walk # Enumerator[NodeRef] or { |node| ... } block
|
|
51
51
|
node.find_all(:link) # Array[NodeRef] with matching type
|
|
52
52
|
node.text # String (concatenated child text)
|
|
53
|
-
|
|
53
|
+
|
|
54
|
+
# Node attributes, by type. Each returns nil when the node's type does not
|
|
55
|
+
# carry the attribute, so you can branch on #type and read that type's own
|
|
56
|
+
# fields without building a Hash per node.
|
|
57
|
+
node.info # CODE_BLOCK: fence info, e.g. "ruby". "" when the
|
|
58
|
+
# block was written without one.
|
|
59
|
+
node.heading_level # HEADING: 1..6
|
|
60
|
+
node.list_ordered? # LIST: true for "1.", false for "-"
|
|
61
|
+
node.list_start # LIST: start number of an ordered list
|
|
62
|
+
node.list_tight? # LIST: tight vs loose
|
|
63
|
+
node.list_delimiter # LIST: delimiter as authored, e.g. "-" or "."
|
|
64
|
+
node.link_destination # LINK / IMAGE: destination URL
|
|
65
|
+
node.link_title # LINK / IMAGE: title, nil when absent
|
|
66
|
+
node.footnote_label # FOOTNOTE_DEFINITION / FOOTNOTE_REFERENCE: label
|
|
67
|
+
node.footnote_number # FOOTNOTE_DEFINITION / FOOTNOTE_REFERENCE: 1-based
|
|
68
|
+
node.header? # TABLE_ROW / TABLE_CELL: part of the header row?
|
|
54
69
|
|
|
55
70
|
# Position information (byte offset)
|
|
56
71
|
node.source_span # SourceSpan with start_byte, end_byte
|
|
57
72
|
|
|
58
73
|
# Position information (line/column)
|
|
59
74
|
node.source_location # { start_line, start_column, end_line, end_column }
|
|
60
|
-
# line
|
|
75
|
+
# line and column are both 1-indexed and counted in
|
|
76
|
+
# characters, following the unist Point convention that
|
|
77
|
+
# cmark sourcepos and mdast use. `end` is exclusive: it
|
|
78
|
+
# is the position just past the node's last character.
|
|
79
|
+
# Block spans cover the block as authored, including
|
|
80
|
+
# markers ("# H1" starts at the "#", "```" fences are
|
|
81
|
+
# part of the code block), but excluding leading indent.
|
|
61
82
|
|
|
62
83
|
# AST export
|
|
63
84
|
node.to_h # Export subtree as Hash[Symbol, untyped]
|
data/lib/red_quilt/arena.rb
CHANGED
|
@@ -250,6 +250,20 @@ module RedQuilt
|
|
|
250
250
|
@int1[id]
|
|
251
251
|
end
|
|
252
252
|
|
|
253
|
+
# HEADING: byte range of the heading text alone, excluding the `#` marker
|
|
254
|
+
# and any closing hashes. The node's source_span covers the heading as
|
|
255
|
+
# authored (for position reporting), so inline parsing needs this narrower
|
|
256
|
+
# range to avoid lexing the marker as content. Setext headings carry no
|
|
257
|
+
# marker prefix and leave this zeroed, in which case the span is the
|
|
258
|
+
# content range.
|
|
259
|
+
def heading_content_start(id)
|
|
260
|
+
@int2[id]
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def heading_content_len(id)
|
|
264
|
+
@int3[id]
|
|
265
|
+
end
|
|
266
|
+
|
|
253
267
|
# LIST: ordered (`1.`) vs bullet (`-`).
|
|
254
268
|
def list_ordered?(id)
|
|
255
269
|
@int1[id] == 1
|
|
@@ -347,6 +361,13 @@ module RedQuilt
|
|
|
347
361
|
start_byte = @source_start[id]
|
|
348
362
|
return nil if start_byte.nil? || start_byte.negative?
|
|
349
363
|
|
|
364
|
+
# An ATX heading's span deliberately covers its `#` marker so that
|
|
365
|
+
# positions point at the heading as authored. Text is the content
|
|
366
|
+
# alone, so slice the narrower content range instead.
|
|
367
|
+
if @type[id] == NodeType::HEADING && @int2[id].positive?
|
|
368
|
+
return @source.byteslice(@int2[id], @int3[id])
|
|
369
|
+
end
|
|
370
|
+
|
|
350
371
|
@source.byteslice(start_byte, @source_len[id])
|
|
351
372
|
end
|
|
352
373
|
|
|
@@ -69,7 +69,11 @@ module RedQuilt
|
|
|
69
69
|
append_heading(parent_id, line, heading, transformed)
|
|
70
70
|
index += 1
|
|
71
71
|
elsif thematic_break?(content)
|
|
72
|
-
|
|
72
|
+
break_start = line.start_byte + Indentation.leading_ws_bytes(content)
|
|
73
|
+
@arena.append_child(parent_id,
|
|
74
|
+
@arena.add_node(NodeType::THEMATIC_BREAK,
|
|
75
|
+
source_start: break_start,
|
|
76
|
+
source_len: line.end_byte - break_start))
|
|
73
77
|
index += 1
|
|
74
78
|
elsif @footnotes && (footnote = FootnoteDefinition.match(content))
|
|
75
79
|
index = @footnote_parser.parse(lines, index, footnote, @footnotes, @root_id)
|
|
@@ -188,11 +192,17 @@ module RedQuilt
|
|
|
188
192
|
|
|
189
193
|
def append_heading(parent_id, line, heading, transformed)
|
|
190
194
|
content = heading[:content].to_s.rstrip
|
|
191
|
-
|
|
195
|
+
# The span covers the heading as written — `#` marker, content, and any
|
|
196
|
+
# closing hashes — matching cmark sourcepos and mdast. Leading indent is
|
|
197
|
+
# excluded: ` # H1` starts at the `#`, not at the line start.
|
|
198
|
+
source_start = line.start_byte + heading[:marker_start].to_i
|
|
199
|
+
content_start = line.start_byte + heading[:content_start]
|
|
192
200
|
node_id = @arena.add_node(NodeType::HEADING,
|
|
193
201
|
source_start: source_start,
|
|
194
|
-
source_len:
|
|
202
|
+
source_len: line.end_byte - source_start,
|
|
195
203
|
int1: heading[:level],
|
|
204
|
+
int2: content_start,
|
|
205
|
+
int3: content.bytesize,
|
|
196
206
|
str1: transformed ? content : nil)
|
|
197
207
|
@arena.append_child(parent_id, node_id)
|
|
198
208
|
end
|
|
@@ -201,6 +211,7 @@ module RedQuilt
|
|
|
201
211
|
paragraph_lines = []
|
|
202
212
|
start_index = index
|
|
203
213
|
setext_level = nil
|
|
214
|
+
setext_underline = nil
|
|
204
215
|
while index < lines.length
|
|
205
216
|
line = lines[index]
|
|
206
217
|
break if line.blank
|
|
@@ -212,6 +223,7 @@ module RedQuilt
|
|
|
212
223
|
# thematic break.
|
|
213
224
|
if paragraph_lines.any? && !line.lazy_continuation && (level = setext_underline_level(line.content))
|
|
214
225
|
setext_level = level
|
|
226
|
+
setext_underline = line
|
|
215
227
|
index += 1
|
|
216
228
|
break
|
|
217
229
|
end
|
|
@@ -246,13 +258,21 @@ module RedQuilt
|
|
|
246
258
|
stripped[-1] = stripped[-1].sub(/[ \t]+\z/, "") if stripped.any?
|
|
247
259
|
indent_was_stripped = stripped.zip(paragraph_lines).any? { |s, l| s.length != l.content.length }
|
|
248
260
|
text = stripped.join("\n")
|
|
249
|
-
|
|
261
|
+
# Spans start at the first non-whitespace byte: leading indent is not
|
|
262
|
+
# part of the block as authored. Both cmark and mdast report ` text`
|
|
263
|
+
# as starting at column 3.
|
|
264
|
+
first_line = paragraph_lines.first
|
|
265
|
+
start_byte = first_line.start_byte + Indentation.leading_ws_bytes(first_line.content)
|
|
250
266
|
end_byte = paragraph_lines.last.end_byte
|
|
251
267
|
|
|
252
268
|
if setext_level
|
|
269
|
+
# The span runs through the `===` / `---` underline, which is part of
|
|
270
|
+
# the heading as authored — cmark and mdast both report it that way.
|
|
271
|
+
# The text is carried by str1, so widening the span does not leak the
|
|
272
|
+
# underline into the heading's content.
|
|
253
273
|
heading_id = @arena.add_node(NodeType::HEADING,
|
|
254
274
|
source_start: start_byte,
|
|
255
|
-
source_len: end_byte - start_byte,
|
|
275
|
+
source_len: setext_underline.end_byte - start_byte,
|
|
256
276
|
int1: setext_level,
|
|
257
277
|
str1: text.strip)
|
|
258
278
|
@arena.append_child(parent_id, heading_id)
|
|
@@ -340,7 +360,10 @@ module RedQuilt
|
|
|
340
360
|
|
|
341
361
|
content = match[2].to_s
|
|
342
362
|
content_index = content.empty? ? text.length : (text.index(content) || text.bytesize)
|
|
343
|
-
|
|
363
|
+
# Offset of the `#` run itself. Only spaces may precede it (at most 3),
|
|
364
|
+
# so the character index doubles as a byte offset.
|
|
365
|
+
{ level: match[1].length, content: content, content_start: content_index,
|
|
366
|
+
marker_start: match.begin(1) }
|
|
344
367
|
end
|
|
345
368
|
|
|
346
369
|
def store_reference(reference, source_span)
|
data/lib/red_quilt/blockquote.rb
CHANGED
|
@@ -17,6 +17,14 @@ module RedQuilt
|
|
|
17
17
|
text.match?(BLOCKQUOTE_PREFIX_RE)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
# Byte offset of the `>` within the line, or nil when there is no prefix.
|
|
21
|
+
# Only spaces may precede it (at most 3), so the character index returned
|
|
22
|
+
# by the match doubles as a byte offset.
|
|
23
|
+
def marker_offset(text)
|
|
24
|
+
match = BLOCKQUOTE_PREFIX_RE.match(text)
|
|
25
|
+
match && (match.end(0) - 1)
|
|
26
|
+
end
|
|
27
|
+
|
|
20
28
|
# Strip the leading `>` (and at most one column of whitespace after
|
|
21
29
|
# it) from a blockquote line. Returns a new Line whose
|
|
22
30
|
# content is the inner text. If the line has no `>` prefix, the
|
|
@@ -75,6 +83,11 @@ module RedQuilt
|
|
|
75
83
|
end
|
|
76
84
|
|
|
77
85
|
def parse(parent_id, lines, index)
|
|
86
|
+
# Captured before the loop: block_lines holds prefix-stripped lines,
|
|
87
|
+
# whose start_byte sits after the `>`. The span must start at the
|
|
88
|
+
# marker itself (excluding indent), as cmark and mdast both report.
|
|
89
|
+
first_line = lines[index]
|
|
90
|
+
source_start = first_line.start_byte + (Blockquote.marker_offset(first_line.content) || 0)
|
|
78
91
|
block_lines = []
|
|
79
92
|
paragraph_open = false
|
|
80
93
|
|
|
@@ -113,8 +126,8 @@ module RedQuilt
|
|
|
113
126
|
end
|
|
114
127
|
|
|
115
128
|
block_id = @arena.add_node(NodeType::BLOCKQUOTE,
|
|
116
|
-
source_start:
|
|
117
|
-
source_len: block_lines.last.end_byte -
|
|
129
|
+
source_start: source_start,
|
|
130
|
+
source_len: block_lines.last.end_byte - source_start)
|
|
118
131
|
@arena.append_child(parent_id, block_id)
|
|
119
132
|
@block_parser.parse_lines(block_id, block_lines, transformed: true)
|
|
120
133
|
index
|
data/lib/red_quilt/code_block.rb
CHANGED
|
@@ -63,8 +63,14 @@ module RedQuilt
|
|
|
63
63
|
indent_n = fence[:indent] || 0
|
|
64
64
|
code = content_lines.map { |l| Indentation.strip_leading_spaces(l.content, indent_n) }.join("\n")
|
|
65
65
|
code << "\n" unless content_lines.empty?
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
# The span covers the fences too, as cmark and mdast both report — a
|
|
67
|
+
# span of the content alone points a line below the block. Leading
|
|
68
|
+
# indent is excluded, so ` ```rb` starts at the backticks. `index`
|
|
69
|
+
# now sits past the closing fence, or past the last line when the
|
|
70
|
+
# fence was never closed, so lines[index - 1] is the block's last line
|
|
71
|
+
# either way.
|
|
72
|
+
source_start = start_line.start_byte + indent_n
|
|
73
|
+
source_end = lines[index - 1].end_byte
|
|
68
74
|
code_id = @arena.add_node(NodeType::CODE_BLOCK,
|
|
69
75
|
source_start: source_start,
|
|
70
76
|
source_len: source_end - source_start,
|
|
@@ -27,10 +27,10 @@ module RedQuilt
|
|
|
27
27
|
def initialize(arena, source, references, track_source: true, diagnostics: nil, footnotes: nil)
|
|
28
28
|
@arena = arena
|
|
29
29
|
@source = source
|
|
30
|
-
# Binary view of the source for String#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
# as its own character.
|
|
30
|
+
# Binary view of the source for String#index hot paths: on a
|
|
31
|
+
# UTF-8 string, index counts characters, so the offsets would not
|
|
32
|
+
# line up with our byte positions. The binary view treats every
|
|
33
|
+
# byte as its own character, making index a byte-offset search.
|
|
34
34
|
@source_b = source.b
|
|
35
35
|
@references = references
|
|
36
36
|
@track_source = track_source
|
|
@@ -229,14 +229,14 @@ module RedQuilt
|
|
|
229
229
|
# source bytes directly. CommonMark: backslash escapes do not
|
|
230
230
|
# apply inside a code span, so once we're past the opener every
|
|
231
231
|
# backtick run is a real candidate (token-level ESCAPED_CHAR is
|
|
232
|
-
# ignored).
|
|
233
|
-
# C speed.
|
|
232
|
+
# ignored). index on the binary view jumps over non-backtick byte
|
|
233
|
+
# stretches at C speed.
|
|
234
234
|
def resolve_code_span(opener_id)
|
|
235
235
|
run_len = @tokens.int1(opener_id)
|
|
236
236
|
pos = @tokens.end_byte(opener_id)
|
|
237
237
|
bytesize = @source_b.bytesize
|
|
238
238
|
while pos < bytesize
|
|
239
|
-
run_start = @source_b.
|
|
239
|
+
run_start = @source_b.index(BACKTICK_BYTE, pos)
|
|
240
240
|
break if run_start.nil?
|
|
241
241
|
|
|
242
242
|
pos = run_start + 1
|
|
@@ -22,8 +22,8 @@ module RedQuilt
|
|
|
22
22
|
[0x2A, 0x5F, 0x60, 0x5B, 0x5D, 0x21, 0x3C, 0x26, 0x5C, 0x0A, 0x7E].each { |b| a[b] = true }
|
|
23
23
|
a.freeze
|
|
24
24
|
end
|
|
25
|
-
# Same set as SPECIAL_BYTES, for String#
|
|
26
|
-
# plain-text runs at C speed.
|
|
25
|
+
# Same set as SPECIAL_BYTES, for String#index on the binary view to
|
|
26
|
+
# jump over long plain-text runs at C speed.
|
|
27
27
|
SPECIAL_BYTE_RE = /[*_`\[\]!<&\\\n~]/
|
|
28
28
|
|
|
29
29
|
# Anchored regexes for StringScanner#scan (still used by
|
|
@@ -116,12 +116,12 @@ module RedQuilt
|
|
|
116
116
|
pos = @ss.pos
|
|
117
117
|
else
|
|
118
118
|
# Inlined scan_text. Always make progress: consume the
|
|
119
|
-
# current byte, then
|
|
119
|
+
# current byte, then index against the binary view to
|
|
120
120
|
# leap to the next special byte at C speed.
|
|
121
121
|
start = pos
|
|
122
122
|
pos += 1
|
|
123
123
|
if pos < end_pos
|
|
124
|
-
next_special = @source_b.
|
|
124
|
+
next_special = @source_b.index(SPECIAL_BYTE_RE, pos)
|
|
125
125
|
pos = next_special.nil? || next_special >= end_pos ? end_pos : next_special
|
|
126
126
|
end
|
|
127
127
|
tokens.emit(TokenKind::TEXT, start_byte: start, end_byte: pos)
|
|
@@ -35,8 +35,7 @@ module RedQuilt
|
|
|
35
35
|
diagnostics: @document.diagnostics,
|
|
36
36
|
footnotes: @document.footnotes).build(node_id, @tokens)
|
|
37
37
|
else
|
|
38
|
-
start_byte =
|
|
39
|
-
end_byte = @arena.source_end(node_id)
|
|
38
|
+
start_byte, end_byte = inline_range(node_id)
|
|
40
39
|
@lexer.lex_into(@tokens, start_byte, end_byte)
|
|
41
40
|
@builder.build(node_id, @tokens)
|
|
42
41
|
end
|
|
@@ -49,5 +48,19 @@ module RedQuilt
|
|
|
49
48
|
child_id = @arena.raw_next_sibling_id(child_id)
|
|
50
49
|
end
|
|
51
50
|
end
|
|
51
|
+
|
|
52
|
+
# The byte range to lex as inline content. For most nodes that is the
|
|
53
|
+
# source span, but an ATX heading's span covers the `#` marker and any
|
|
54
|
+
# closing hashes so that positions point at the heading as authored;
|
|
55
|
+
# lexing that range would turn the marker into text. Setext headings
|
|
56
|
+
# leave the content range zeroed and fall back to the span.
|
|
57
|
+
def inline_range(node_id)
|
|
58
|
+
if @arena.type(node_id) == NodeType::HEADING && @arena.heading_content_start(node_id).positive?
|
|
59
|
+
start_byte = @arena.heading_content_start(node_id)
|
|
60
|
+
return [start_byte, start_byte + @arena.heading_content_len(node_id)]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
[@arena.source_start(node_id), @arena.source_end(node_id)]
|
|
64
|
+
end
|
|
52
65
|
end
|
|
53
66
|
end
|
data/lib/red_quilt/list.rb
CHANGED
|
@@ -146,15 +146,19 @@ module RedQuilt
|
|
|
146
146
|
|
|
147
147
|
def parse(parent_id, lines, index)
|
|
148
148
|
first_match = List.match(lines[index].content)
|
|
149
|
+
# Leading indent is excluded, so ` - item` starts at the bullet.
|
|
150
|
+
list_start_byte = lines[index].start_byte + Indentation.leading_ws_bytes(lines[index].content)
|
|
149
151
|
list_id = @arena.add_node(NodeType::LIST,
|
|
150
|
-
source_start:
|
|
152
|
+
source_start: list_start_byte,
|
|
151
153
|
source_len: 0,
|
|
152
154
|
int1: first_match[:ordered] ? 1 : 0,
|
|
153
155
|
int2: first_match[:start_number],
|
|
154
156
|
int3: 1,
|
|
155
157
|
str1: first_match[:marker])
|
|
156
158
|
@arena.append_child(parent_id, list_id)
|
|
157
|
-
|
|
159
|
+
# update_span below rewrites the span once the list's extent is
|
|
160
|
+
# known, so it has to carry the indent-stripped start too.
|
|
161
|
+
start_byte = list_start_byte
|
|
158
162
|
end_byte = lines[index].end_byte
|
|
159
163
|
loose = false
|
|
160
164
|
|
|
@@ -167,11 +171,16 @@ module RedQuilt
|
|
|
167
171
|
break unless match
|
|
168
172
|
break unless List.same_group?(first_match, match)
|
|
169
173
|
|
|
174
|
+
# collect_item strips the bullet, so item_lines start at the item's
|
|
175
|
+
# content. The span starts at the marker instead — an item is
|
|
176
|
+
# authored as "- text", not "text".
|
|
177
|
+
marker_line = lines[index]
|
|
178
|
+
item_start = marker_line.start_byte + Indentation.leading_ws_bytes(marker_line.content)
|
|
170
179
|
item_lines, index = collect_item(lines, index, match)
|
|
171
180
|
end_byte = item_lines.last.end_byte
|
|
172
181
|
item_id = @arena.add_node(NodeType::LIST_ITEM,
|
|
173
|
-
source_start:
|
|
174
|
-
source_len: item_lines.last.end_byte -
|
|
182
|
+
source_start: item_start,
|
|
183
|
+
source_len: item_lines.last.end_byte - item_start)
|
|
175
184
|
@arena.append_child(list_id, item_id)
|
|
176
185
|
# CommonMark: an item is loose when "two block-level elements
|
|
177
186
|
# with a blank line between them" appear at its top level.
|
data/lib/red_quilt/node_ref.rb
CHANGED
|
@@ -46,13 +46,83 @@ module RedQuilt
|
|
|
46
46
|
text
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
-
#
|
|
49
|
+
# Node attributes, by NodeType.
|
|
50
|
+
#
|
|
51
|
+
# Each returns nil when the node does not carry the attribute, so callers
|
|
52
|
+
# can walk the tree branching on #type and read the type's own fields
|
|
53
|
+
# without a Hash allocation per node. The Arena's accessors are the raw
|
|
54
|
+
# layer and deliberately skip that check: several attributes share a
|
|
55
|
+
# storage column, so reading one off a mismatched node yields another
|
|
56
|
+
# field's value rather than nil (e.g. Arena#link_destination on a
|
|
57
|
+
# paragraph returns the paragraph's text). These wrappers are the safe
|
|
58
|
+
# way to read attributes.
|
|
59
|
+
|
|
60
|
+
# CODE_BLOCK: the fence info string, e.g. "ruby" or 'vtt audio="x.mp3"'.
|
|
61
|
+
# A code block written without one has an empty info string, so "" means
|
|
62
|
+
# "no info given" while nil means "not a code block".
|
|
50
63
|
def info
|
|
51
|
-
return
|
|
64
|
+
return nil unless type?(NodeType::CODE_BLOCK)
|
|
52
65
|
|
|
53
66
|
@arena.code_block_info(@node_id).to_s
|
|
54
67
|
end
|
|
55
68
|
|
|
69
|
+
# HEADING: nesting level (1..6).
|
|
70
|
+
def heading_level
|
|
71
|
+
@arena.heading_level(@node_id) if type?(NodeType::HEADING)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# LIST: ordered (`1.`) vs bullet (`-`).
|
|
75
|
+
def list_ordered?
|
|
76
|
+
@arena.list_ordered?(@node_id) if type?(NodeType::LIST)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# LIST: start number of an ordered list.
|
|
80
|
+
def list_start
|
|
81
|
+
@arena.list_start(@node_id) if type?(NodeType::LIST)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# LIST: tight (no blank lines between items) vs loose.
|
|
85
|
+
def list_tight?
|
|
86
|
+
@arena.list_tight?(@node_id) if type?(NodeType::LIST)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# LIST: the item delimiter as authored, e.g. "-" or "1.".
|
|
90
|
+
def list_delimiter
|
|
91
|
+
@arena.list_delimiter(@node_id) if type?(NodeType::LIST)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# LINK / IMAGE: the destination URL.
|
|
95
|
+
def link_destination
|
|
96
|
+
@arena.link_destination(@node_id) if link_like?
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# LINK / IMAGE: the optional title, nil when absent.
|
|
100
|
+
def link_title
|
|
101
|
+
@arena.link_title(@node_id) if link_like?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# FOOTNOTE_DEFINITION / FOOTNOTE_REFERENCE: the label as authored.
|
|
105
|
+
def footnote_label
|
|
106
|
+
@arena.footnote_label(@node_id) if footnote_like?
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# FOOTNOTE_DEFINITION / FOOTNOTE_REFERENCE: the resolved 1-based number.
|
|
110
|
+
def footnote_number
|
|
111
|
+
@arena.footnote_number(@node_id) if footnote_like?
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# TABLE_ROW / TABLE_CELL: whether this belongs to the header row.
|
|
115
|
+
#
|
|
116
|
+
# nil rather than false outside a table, matching the other attribute
|
|
117
|
+
# accessors: a paragraph is not a non-header row, it has no header-ness
|
|
118
|
+
# at all. Both answers are falsy, so `if node.header?` reads the same.
|
|
119
|
+
def header?
|
|
120
|
+
return @arena.table_row_header?(@node_id) if type?(NodeType::TABLE_ROW)
|
|
121
|
+
return @arena.table_cell_header?(@node_id) if type?(NodeType::TABLE_CELL)
|
|
122
|
+
|
|
123
|
+
nil # rubocop:disable Style/ReturnNilInPredicateMethodDefinition
|
|
124
|
+
end
|
|
125
|
+
|
|
56
126
|
def source_span
|
|
57
127
|
@arena.source_span(@node_id)
|
|
58
128
|
end
|
|
@@ -90,6 +160,22 @@ module RedQuilt
|
|
|
90
160
|
|
|
91
161
|
private
|
|
92
162
|
|
|
163
|
+
def type?(node_type)
|
|
164
|
+
@arena.type(@node_id) == node_type
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Kept allocation-free (no splat) because these accessors are meant for
|
|
168
|
+
# per-node use while walking a whole document.
|
|
169
|
+
def link_like?
|
|
170
|
+
node_type = @arena.type(@node_id)
|
|
171
|
+
node_type == NodeType::LINK || node_type == NodeType::IMAGE
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def footnote_like?
|
|
175
|
+
node_type = @arena.type(@node_id)
|
|
176
|
+
node_type == NodeType::FOOTNOTE_DEFINITION || node_type == NodeType::FOOTNOTE_REFERENCE
|
|
177
|
+
end
|
|
178
|
+
|
|
93
179
|
def ast_attributes
|
|
94
180
|
case @arena.type(@node_id)
|
|
95
181
|
when NodeType::HEADING
|
|
@@ -127,19 +127,23 @@ module RedQuilt
|
|
|
127
127
|
result
|
|
128
128
|
end
|
|
129
129
|
|
|
130
|
+
# unist Point: line and column are 1-based, offset is a 0-based
|
|
131
|
+
# *character* index. SourceSpan carries bytes, so offset is converted
|
|
132
|
+
# rather than passed through — the two diverge on multibyte sources.
|
|
130
133
|
def position(span)
|
|
131
|
-
|
|
132
|
-
|
|
134
|
+
source_map = @document.source_map
|
|
135
|
+
start_loc = source_map.line_column(span.start_byte)
|
|
136
|
+
end_loc = source_map.line_column(span.end_byte)
|
|
133
137
|
{
|
|
134
138
|
"start" => {
|
|
135
139
|
"line" => start_loc[:line],
|
|
136
140
|
"column" => start_loc[:column],
|
|
137
|
-
"offset" => span.start_byte,
|
|
141
|
+
"offset" => source_map.char_offset(span.start_byte),
|
|
138
142
|
},
|
|
139
143
|
"end" => {
|
|
140
144
|
"line" => end_loc[:line],
|
|
141
145
|
"column" => end_loc[:column],
|
|
142
|
-
"offset" => span.end_byte,
|
|
146
|
+
"offset" => source_map.char_offset(span.end_byte),
|
|
143
147
|
},
|
|
144
148
|
}
|
|
145
149
|
end
|
data/lib/red_quilt/source_map.rb
CHANGED
|
@@ -1,29 +1,69 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module RedQuilt
|
|
4
|
+
# Translates byte offsets from SourceSpan into human-facing positions.
|
|
5
|
+
#
|
|
6
|
+
# Positions follow the unist Point convention, which cmark sourcepos and
|
|
7
|
+
# mdast both use: `line` and `column` are 1-based, while character offsets
|
|
8
|
+
# are 0-based. Columns and offsets are counted in characters, not bytes, so
|
|
9
|
+
# a multibyte source reports the position a reader would count.
|
|
4
10
|
class SourceMap
|
|
5
11
|
def initialize(source)
|
|
6
12
|
@source = source
|
|
7
13
|
@line_starts = build_line_starts(source)
|
|
14
|
+
@line_char_starts = build_line_char_starts(source, @line_starts)
|
|
8
15
|
end
|
|
9
16
|
|
|
17
|
+
# Returns { line:, column: }, both 1-based.
|
|
10
18
|
def line_column(byte_offset)
|
|
11
|
-
line = (
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
19
|
+
line = line_index(byte_offset)
|
|
20
|
+
{ line: line + 1, column: chars_from_line_start(line, byte_offset) + 1 }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Returns the 0-based character offset, as unist Point#offset requires.
|
|
24
|
+
# Byte offsets are what SourceSpan carries, and the two differ as soon as
|
|
25
|
+
# the source contains a multibyte character.
|
|
26
|
+
def char_offset(byte_offset)
|
|
27
|
+
line = line_index(byte_offset)
|
|
28
|
+
@line_char_starts[line] + chars_from_line_start(line, byte_offset)
|
|
15
29
|
end
|
|
16
30
|
|
|
17
31
|
private
|
|
18
32
|
|
|
33
|
+
def line_index(byte_offset)
|
|
34
|
+
(@line_starts.bsearch_index { |s| s > byte_offset } || @line_starts.length) - 1
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def chars_from_line_start(line, byte_offset)
|
|
38
|
+
line_start = @line_starts[line]
|
|
39
|
+
@source.byteslice(line_start, byte_offset - line_start).to_s.length
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Line starts are byte offsets, matching SourceSpan#start_byte / #end_byte
|
|
43
|
+
# and the argument of #line_column. Scanning the binary view is what makes
|
|
44
|
+
# them bytes: index on a UTF-8 string counts characters, so a multibyte
|
|
45
|
+
# source would yield offsets that line_column then misreads as bytes.
|
|
19
46
|
def build_line_starts(source)
|
|
20
47
|
starts = [0]
|
|
48
|
+
source_b = source.b
|
|
21
49
|
pos = 0
|
|
22
|
-
while (idx =
|
|
50
|
+
while (idx = source_b.index("\n", pos))
|
|
23
51
|
starts << (idx + 1)
|
|
24
52
|
pos = idx + 1
|
|
25
53
|
end
|
|
26
54
|
starts
|
|
27
55
|
end
|
|
56
|
+
|
|
57
|
+
# Character offset of each line start, so char_offset stays O(1) per call
|
|
58
|
+
# instead of recounting the source prefix every time.
|
|
59
|
+
def build_line_char_starts(source, line_starts)
|
|
60
|
+
chars = 0
|
|
61
|
+
prev_byte = 0
|
|
62
|
+
line_starts.map do |byte_start|
|
|
63
|
+
chars += source.byteslice(prev_byte, byte_start - prev_byte).to_s.length
|
|
64
|
+
prev_byte = byte_start
|
|
65
|
+
chars
|
|
66
|
+
end
|
|
67
|
+
end
|
|
28
68
|
end
|
|
29
69
|
end
|
data/lib/red_quilt/version.rb
CHANGED
data/sig/red_quilt.rbs
CHANGED
|
@@ -61,11 +61,23 @@ module RedQuilt
|
|
|
61
61
|
def walk: () { (NodeRef) -> void } -> void
|
|
62
62
|
| () -> Enumerator[NodeRef, void]
|
|
63
63
|
def text: () -> String?
|
|
64
|
-
def info: () -> String
|
|
65
64
|
def source_span: () -> SourceSpan?
|
|
66
65
|
def find_all: (Symbol type) -> Array[NodeRef]
|
|
67
66
|
def source_location: () -> { start_line: Integer, start_column: Integer, end_line: Integer, end_column: Integer }?
|
|
68
67
|
def to_h: () -> Hash[Symbol, untyped]
|
|
68
|
+
|
|
69
|
+
# Node attributes: nil when the node's type does not carry the attribute.
|
|
70
|
+
def info: () -> String?
|
|
71
|
+
def heading_level: () -> Integer?
|
|
72
|
+
def list_ordered?: () -> bool?
|
|
73
|
+
def list_start: () -> Integer?
|
|
74
|
+
def list_tight?: () -> bool?
|
|
75
|
+
def list_delimiter: () -> String?
|
|
76
|
+
def link_destination: () -> String?
|
|
77
|
+
def link_title: () -> String?
|
|
78
|
+
def footnote_label: () -> String?
|
|
79
|
+
def footnote_number: () -> Integer?
|
|
80
|
+
def header?: () -> bool?
|
|
69
81
|
end
|
|
70
82
|
|
|
71
83
|
# Low-level parallel-array storage for the AST. See docs/arena-usage.ja.md.
|
|
@@ -118,6 +130,8 @@ module RedQuilt
|
|
|
118
130
|
|
|
119
131
|
# Semantic payload accessors (per-NodeType column conventions)
|
|
120
132
|
def heading_level: (Integer id) -> Integer
|
|
133
|
+
def heading_content_start: (Integer id) -> Integer
|
|
134
|
+
def heading_content_len: (Integer id) -> Integer
|
|
121
135
|
def list_ordered?: (Integer id) -> bool
|
|
122
136
|
def list_start: (Integer id) -> Integer
|
|
123
137
|
def list_tight?: (Integer id) -> bool
|
|
@@ -150,9 +164,12 @@ module RedQuilt
|
|
|
150
164
|
def to_h: () -> { start_byte: Integer, end_byte: Integer }
|
|
151
165
|
end
|
|
152
166
|
|
|
167
|
+
# Positions follow the unist Point convention: line and column are 1-based
|
|
168
|
+
# and counted in characters, while char_offset is 0-based.
|
|
153
169
|
class SourceMap
|
|
154
170
|
def initialize: (String source) -> void
|
|
155
171
|
def line_column: (Integer byte_offset) -> { line: Integer, column: Integer }
|
|
172
|
+
def char_offset: (Integer byte_offset) -> Integer
|
|
156
173
|
end
|
|
157
174
|
|
|
158
175
|
class Diagnostic
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: red_quilt
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- takahashim
|
|
@@ -94,14 +94,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
94
94
|
requirements:
|
|
95
95
|
- - ">="
|
|
96
96
|
- !ruby/object:Gem::Version
|
|
97
|
-
version: 3.
|
|
97
|
+
version: 3.1.0
|
|
98
98
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
99
99
|
requirements:
|
|
100
100
|
- - ">="
|
|
101
101
|
- !ruby/object:Gem::Version
|
|
102
102
|
version: '0'
|
|
103
103
|
requirements: []
|
|
104
|
-
rubygems_version: 4.0.
|
|
104
|
+
rubygems_version: 4.0.16
|
|
105
105
|
specification_version: 4
|
|
106
106
|
summary: CommonMark-based Markdown processor written in pure Ruby
|
|
107
107
|
test_files: []
|