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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 56e09a19cfb78fad2a7e9a377f3ec6b9033d2160d4c126719e8220245b340709
4
- data.tar.gz: 35bcb8de686345a72b9d2edf0261c7b201fb138047b571e3cc14b4f62be2671a
3
+ metadata.gz: 211bfba5adede6552fb64c3dfadf6c895325af2c58a577eadb86c8d426a3dd0b
4
+ data.tar.gz: 2e542ae344e626dc5a1bf0198b80761dfac3317b48f11011569235e5418048c9
5
5
  SHA512:
6
- metadata.gz: a5ad64e49f61ddcca8c3453b28ab06e7c04d76da574d82e6185b9a175189f313e043dcb586ff4293ad801b6bbb521ecca902f4dd9cac630186db97a2d8de5b91
7
- data.tar.gz: fc3764a74f06f1d902afd5eeba1e572ddb97f3fa1c9306f4de937e1929937d22830f856c77b03d2e1fc658be3310d7f7535cb994a7945cf7f5f18ea52e0416c4
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
- node.info # String fence info of a code block (e.g. "ruby")
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: 1-indexed, column: 0-indexed (character-based)
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]
@@ -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
- @arena.append_child(parent_id, @arena.add_node(NodeType::THEMATIC_BREAK, source_start: line.start_byte, source_len: line.span_len))
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
- source_start = line.start_byte + heading[:content_start]
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: content.bytesize,
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
- start_byte = paragraph_lines.first.start_byte
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
- { level: match[1].length, content: content, content_start: content_index }
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)
@@ -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: block_lines.first.start_byte,
117
- source_len: block_lines.last.end_byte - block_lines.first.start_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
@@ -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
- source_start = content_lines.empty? ? start_line.start_byte : content_lines.first.start_byte
67
- source_end = content_lines.empty? ? start_line.end_byte : content_lines.last.end_byte
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#byteindex hot paths:
31
- # byteindex on a UTF-8 string raises when the byte offset falls
32
- # inside a multibyte sequence; the binary view treats every byte
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). byteindex jumps over non-backtick byte stretches at
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.byteindex(BACKTICK_BYTE, pos)
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#byteindex to jump over long
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 byteindex against the binary view to
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.byteindex(SPECIAL_BYTE_RE, pos)
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 = @arena.source_start(node_id)
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
@@ -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: lines[index].start_byte,
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
- start_byte = lines[index].start_byte
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: item_lines.first.start_byte,
174
- source_len: item_lines.last.end_byte - item_lines.first.start_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.
@@ -46,13 +46,83 @@ module RedQuilt
46
46
  text
47
47
  end
48
48
 
49
- # Returns the fence info string of a CODE_BLOCK node.
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 "" unless @arena.type(@node_id) == NodeType::CODE_BLOCK
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
- start_loc = @document.source_map.line_column(span.start_byte)
132
- end_loc = @document.source_map.line_column(span.end_byte)
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
@@ -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 = (@line_starts.bsearch_index { |s| s > byte_offset } || @line_starts.length) - 1
12
- line_start = @line_starts[line]
13
- column = @source.byteslice(line_start, byte_offset - line_start).to_s.length
14
- { line: line + 1, column: column }
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 = source.index("\n", pos))
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RedQuilt
4
- VERSION = "0.8.0"
4
+ VERSION = "0.9.0"
5
5
  end
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.8.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.3.0
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.10
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: []