red_quilt 0.7.2 → 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.
@@ -9,22 +9,10 @@ module RedQuilt
9
9
  # 1. linear_pass — code spans, brackets (link/image), autolinks,
10
10
  # HTML, simple inlines. Emphasis delimiter runs are added as
11
11
  # provisional TEXT nodes and pushed onto a delimiter stack.
12
- # 2. process_emphasis — CommonMark spec 6.2 algorithm pairs up
13
- # delimiter stack entries into EMPHASIS / STRONG nodes.
12
+ # 2. EmphasisResolver#resolve — CommonMark spec 6.2 algorithm pairs
13
+ # up delimiter stack entries into EMPHASIS / STRONG nodes
14
+ # (delegated to Inline::EmphasisResolver).
14
15
  class Builder
15
- SAFE_SCHEMES = %w[http https mailto ftp tel ssh].freeze
16
- # Autolinks (`<scheme:...>`) are not run through the SAFE_SCHEMES
17
- # allowlist: CommonMark permits arbitrary schemes there (e.g.
18
- # `<made-up-scheme://x>`), and an allowlist would break that
19
- # conformance. Only the schemes that execute script when the link
20
- # is navigated are denied.
21
- UNSAFE_AUTOLINK_SCHEMES = %w[javascript vbscript data].freeze
22
-
23
- # `count` is the CommonMark delimiter-run length; a Delimiter is
24
- # never enumerated, so shadowing Struct#count (from Enumerable) is
25
- # intentional rather than a footgun.
26
- Delimiter = Struct.new(:node_id, :char, :count, :can_open, :can_close) # rubocop:disable Lint/StructNewOverride
27
-
28
16
  Bracket = Struct.new(:token_id, :node_id, :image, :active, :delim_stack_size)
29
17
 
30
18
  # track_source: when true, arena nodes carry the byte ranges supplied
@@ -39,16 +27,17 @@ module RedQuilt
39
27
  def initialize(arena, source, references, track_source: true, diagnostics: nil, footnotes: nil)
40
28
  @arena = arena
41
29
  @source = source
42
- # Binary view of the source for String#byteindex hot paths:
43
- # byteindex on a UTF-8 string raises when the byte offset falls
44
- # inside a multibyte sequence; the binary view treats every byte
45
- # 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.
46
34
  @source_b = source.b
47
35
  @references = references
48
36
  @track_source = track_source
49
37
  @diagnostics = diagnostics
50
38
  @footnotes = footnotes
51
39
  @link_scanner = LinkScanner.new(source)
40
+ @emphasis = EmphasisResolver.new(arena, track_source: track_source)
52
41
  end
53
42
 
54
43
  def build(parent_id, tokens)
@@ -58,7 +47,7 @@ module RedQuilt
58
47
  @bracket_stack = []
59
48
  @provisional_nodes = {}
60
49
  linear_pass
61
- process_emphasis(@delimiter_stack)
50
+ @emphasis.resolve(@delimiter_stack, @provisional_nodes)
62
51
  end
63
52
 
64
53
  private
@@ -228,43 +217,26 @@ module RedQuilt
228
217
  link_id = add_arena_node(
229
218
  NodeType::LINK,
230
219
  @tokens.start_byte(id), @tokens.end_byte(id),
231
- str1: block_unsafe_autolink(@link_scanner.normalize_uri(destination)),
220
+ str1: UrlSanitizer.block_unsafe_autolink(@link_scanner.normalize_uri(destination), @diagnostics),
232
221
  )
233
222
  @arena.append_child(@parent_id, link_id)
234
223
  @arena.append_child(link_id, @arena.add_node(NodeType::TEXT, str1: label))
235
224
  end
236
225
 
237
- # Returns "" (blocking the href) for autolink destinations whose
238
- # scheme executes script on navigation; otherwise the destination
239
- # is returned unchanged. Unlike sanitize_destination this is a
240
- # denylist, to stay CommonMark-conformant for benign custom schemes.
241
- def block_unsafe_autolink(destination)
242
- scheme = destination[%r{\A([a-zA-Z][a-zA-Z0-9+\-.]*):}, 1]
243
- return destination if scheme.nil?
244
- return destination unless UNSAFE_AUTOLINK_SCHEMES.include?(scheme.downcase)
245
-
246
- report_diagnostic(
247
- severity: :warning,
248
- rule: :unsafe_url,
249
- message: "Unsafe URL scheme #{scheme.downcase.inspect} blocked",
250
- )
251
- ""
252
- end
253
-
254
226
  # --------------------------- code spans -----------------------------
255
227
 
256
228
  # Find the closing backtick run for a code span by scanning the
257
229
  # source bytes directly. CommonMark: backslash escapes do not
258
230
  # apply inside a code span, so once we're past the opener every
259
231
  # backtick run is a real candidate (token-level ESCAPED_CHAR is
260
- # ignored). byteindex jumps over non-backtick byte stretches at
261
- # C speed.
232
+ # ignored). index on the binary view jumps over non-backtick byte
233
+ # stretches at C speed.
262
234
  def resolve_code_span(opener_id)
263
235
  run_len = @tokens.int1(opener_id)
264
236
  pos = @tokens.end_byte(opener_id)
265
237
  bytesize = @source_b.bytesize
266
238
  while pos < bytesize
267
- run_start = @source_b.byteindex(BACKTICK_BYTE, pos)
239
+ run_start = @source_b.index(BACKTICK_BYTE, pos)
268
240
  break if run_start.nil?
269
241
 
270
242
  pos = run_start + 1
@@ -400,7 +372,7 @@ module RedQuilt
400
372
  link_kind = opener.image ? NodeType::IMAGE : NodeType::LINK
401
373
  link_id = add_arena_node(
402
374
  link_kind, opener_start, match[:end_byte],
403
- str1: sanitize_destination(match[:destination]),
375
+ str1: UrlSanitizer.sanitize_destination(match[:destination], @diagnostics),
404
376
  str2: match[:title],
405
377
  )
406
378
 
@@ -416,7 +388,7 @@ module RedQuilt
416
388
  @arena.detach(opener.node_id)
417
389
 
418
390
  inner_delims = @delimiter_stack.slice!(opener.delim_stack_size..) || []
419
- process_emphasis(inner_delims)
391
+ @emphasis.resolve(inner_delims, @provisional_nodes)
420
392
 
421
393
  @bracket_stack.delete_at(opener_index)
422
394
 
@@ -489,22 +461,6 @@ module RedQuilt
489
461
  last
490
462
  end
491
463
 
492
- def sanitize_destination(destination)
493
- return "" if destination.nil?
494
- return destination if destination.start_with?("/", "#")
495
-
496
- scheme = destination[%r{\A([a-zA-Z][a-zA-Z0-9+\-.]*):}, 1]
497
- return destination if scheme.nil?
498
- return destination if SAFE_SCHEMES.include?(scheme.downcase)
499
-
500
- report_diagnostic(
501
- severity: :warning,
502
- rule: :unsafe_url,
503
- message: "Unsafe URL scheme #{scheme.downcase.inspect} blocked",
504
- )
505
- ""
506
- end
507
-
508
464
  def report_diagnostic(severity:, rule:, message:, source_span: nil)
509
465
  return unless @diagnostics
510
466
 
@@ -530,145 +486,12 @@ module RedQuilt
530
486
  @arena.append_child(@parent_id, node_id)
531
487
  @provisional_nodes[node_id] = true
532
488
 
533
- @delimiter_stack << Delimiter.new(
489
+ @delimiter_stack << EmphasisResolver::Delimiter.new(
534
490
  node_id, char, count,
535
491
  (flags & 0b10) != 0,
536
492
  (flags & 0b01) != 0,
537
493
  )
538
494
  end
539
-
540
- def process_emphasis(stack)
541
- # NB: the CommonMark spec describes an `openers_bottom`
542
- # optimization keyed by closer character / length / flanking
543
- # flags. Implementing that correctly is subtle (a single
544
- # per-character bottom blocks valid matches like
545
- # `*foo**bar**baz*`), so the implementation here just walks
546
- # back to the start of the stack for every closer. This is
547
- # O(stack^2) in the worst case but stacks are tiny in practice.
548
- closer_idx = 0
549
-
550
- while closer_idx < stack.length
551
- closer = stack[closer_idx]
552
- unless closer.can_close
553
- closer_idx += 1
554
- next
555
- end
556
-
557
- opener_idx = closer_idx - 1
558
- found = false
559
- while opener_idx >= 0
560
- opener = stack[opener_idx]
561
- if opener.can_open && opener.char == closer.char
562
- skip = false
563
- if (opener.can_close || closer.can_open) &&
564
- ((opener.count + closer.count) % 3).zero? &&
565
- !((opener.count % 3).zero? && (closer.count % 3).zero?)
566
- skip = true
567
- end
568
- unless skip
569
- found = true
570
- break
571
- end
572
- end
573
- opener_idx -= 1
574
- end
575
-
576
- unless found
577
- unless closer.can_open
578
- @provisional_nodes.delete(closer.node_id)
579
- stack.delete_at(closer_idx)
580
- end
581
- closer_idx += 1
582
- next
583
- end
584
-
585
- opener = stack[opener_idx]
586
- strength = [opener.count, closer.count].min >= 2 ? 2 : 1
587
- if closer.char == "~"
588
- # GFM strikethrough only forms on `~~` runs. A single `~`
589
- # leaves the delimiter as text; advance the cursor so future
590
- # `~~` pairs can still match.
591
- if strength < 2
592
- closer_idx += 1
593
- next
594
- end
595
- kind = NodeType::STRIKETHROUGH
596
- else
597
- kind = strength == 2 ? NodeType::STRONG : NodeType::EMPHASIS
598
- end
599
-
600
- # CommonMark spec: any delimiters strictly between this opener and
601
- # closer can't open or close anything in this scope, so drop them
602
- # from the stack before we rebuild the tree. Their arena nodes
603
- # stay where they are (they'll be reparented into the new emphasis
604
- # alongside the surrounding content), but they must no longer be
605
- # candidates for future iterations. Without this, the next
606
- # iteration would try to pair stranded delimiters that have
607
- # already been moved into a different parent, which corrupts the
608
- # sibling chain (Arena#reparent walks into @parent[-1]).
609
- if closer_idx > opener_idx + 1
610
- removed = stack.slice!((opener_idx + 1)...closer_idx)
611
- removed.each { |e| @provisional_nodes.delete(e.node_id) }
612
- closer_idx = opener_idx + 1
613
- closer = stack[closer_idx]
614
- end
615
-
616
- opener_node = opener.node_id
617
- closer_node = closer.node_id
618
-
619
- if @track_source
620
- opener_match_start = @arena.source_end(opener_node) - strength
621
- closer_match_end = @arena.source_start(closer_node) + strength
622
- else
623
- opener_match_start = -1
624
- closer_match_end = 0
625
- end
626
- emphasis_id = add_arena_node(kind, opener_match_start, closer_match_end)
627
-
628
- first_inside = @arena.raw_next_sibling_id(opener_node)
629
- last_inside = @arena.raw_prev_sibling_id(closer_node)
630
- if first_inside != -1 && last_inside != -1 &&
631
- first_inside != closer_node && last_inside != opener_node
632
- @arena.reparent(emphasis_id, first_inside, last_inside)
633
- end
634
-
635
- parent_id = @arena.raw_parent_id(opener_node)
636
- @arena.insert_before(parent_id, closer_node, emphasis_id)
637
-
638
- if opener.count == strength
639
- @provisional_nodes.delete(opener_node)
640
- @arena.detach(opener_node)
641
- stack.delete_at(opener_idx)
642
- closer_idx -= 1
643
- else
644
- opener.count -= strength
645
- str = @arena.str1(opener_node)
646
- @arena.update_str1(opener_node, str[0...-strength])
647
- if @track_source
648
- new_end = @arena.source_end(opener_node) - strength
649
- @arena.update_span(opener_node, @arena.source_start(opener_node), new_end)
650
- end
651
- end
652
-
653
- if closer.count == strength
654
- @provisional_nodes.delete(closer_node)
655
- @arena.detach(closer_node)
656
- stack.delete_at(closer_idx)
657
- else
658
- closer.count -= strength
659
- str = @arena.str1(closer_node)
660
- @arena.update_str1(closer_node, str[strength..])
661
- if @track_source
662
- new_start = @arena.source_start(closer_node) + strength
663
- new_end = @arena.source_end(closer_node)
664
- @arena.update_span(closer_node, new_start, new_end)
665
- end
666
- end
667
- end
668
-
669
- stack.each { |e| @provisional_nodes.delete(e.node_id) }
670
- stack.clear
671
- end
672
495
  end
673
496
  end
674
497
  end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RedQuilt
4
+ module Inline
5
+ # CommonMark emphasis algorithm (spec 6.2). Phase 2 of inline parsing:
6
+ # given the delimiter stack the linear pass collected (provisional TEXT
7
+ # nodes for each `*` / `_` / `~` run), it pairs openers with closers and
8
+ # rebuilds the arena subtree into EMPHASIS / STRONG / STRIKETHROUGH
9
+ # nodes.
10
+ #
11
+ # Kept separate from Builder because it is a closed algorithm with a
12
+ # narrow interface: it only needs the arena, the set of still-provisional
13
+ # nodes (so consumed delimiters can be unmarked), and whether source
14
+ # spans are tracked. Builder owns the linear pass and bracket handling;
15
+ # it hands this resolver a delimiter stack to collapse.
16
+ class EmphasisResolver
17
+ # `count` is the CommonMark delimiter-run length; a Delimiter is
18
+ # never enumerated, so shadowing Struct#count (from Enumerable) is
19
+ # intentional rather than a footgun.
20
+ Delimiter = Struct.new(:node_id, :char, :count, :can_open, :can_close) # rubocop:disable Lint/StructNewOverride
21
+
22
+ def initialize(arena, track_source:)
23
+ @arena = arena
24
+ @track_source = track_source
25
+ end
26
+
27
+ # Collapses `stack` (an Array of Delimiter) in place, removing
28
+ # consumed entries from `provisional_nodes`. Used both for the
29
+ # document-level stack and for the inner delimiters of a resolved
30
+ # link/image (see Builder#finalize_link).
31
+ def resolve(stack, provisional_nodes)
32
+ # NB: the CommonMark spec describes an `openers_bottom`
33
+ # optimization keyed by closer character / length / flanking
34
+ # flags. Implementing that correctly is subtle (a single
35
+ # per-character bottom blocks valid matches like
36
+ # `*foo**bar**baz*`), so the implementation here just walks
37
+ # back to the start of the stack for every closer. This is
38
+ # O(stack^2) in the worst case but stacks are tiny in practice.
39
+ closer_idx = 0
40
+
41
+ while closer_idx < stack.length
42
+ closer = stack[closer_idx]
43
+ unless closer.can_close
44
+ closer_idx += 1
45
+ next
46
+ end
47
+
48
+ opener_idx = closer_idx - 1
49
+ found = false
50
+ while opener_idx >= 0
51
+ opener = stack[opener_idx]
52
+ if opener.can_open && opener.char == closer.char
53
+ skip = false
54
+ if (opener.can_close || closer.can_open) &&
55
+ ((opener.count + closer.count) % 3).zero? &&
56
+ !((opener.count % 3).zero? && (closer.count % 3).zero?)
57
+ skip = true
58
+ end
59
+ unless skip
60
+ found = true
61
+ break
62
+ end
63
+ end
64
+ opener_idx -= 1
65
+ end
66
+
67
+ unless found
68
+ unless closer.can_open
69
+ provisional_nodes.delete(closer.node_id)
70
+ stack.delete_at(closer_idx)
71
+ end
72
+ closer_idx += 1
73
+ next
74
+ end
75
+
76
+ opener = stack[opener_idx]
77
+ strength = [opener.count, closer.count].min >= 2 ? 2 : 1
78
+ if closer.char == "~"
79
+ # GFM strikethrough only forms on `~~` runs. A single `~`
80
+ # leaves the delimiter as text; advance the cursor so future
81
+ # `~~` pairs can still match.
82
+ if strength < 2
83
+ closer_idx += 1
84
+ next
85
+ end
86
+ kind = NodeType::STRIKETHROUGH
87
+ else
88
+ kind = strength == 2 ? NodeType::STRONG : NodeType::EMPHASIS
89
+ end
90
+
91
+ # CommonMark spec: any delimiters strictly between this opener and
92
+ # closer can't open or close anything in this scope, so drop them
93
+ # from the stack before we rebuild the tree. Their arena nodes
94
+ # stay where they are (they'll be reparented into the new emphasis
95
+ # alongside the surrounding content), but they must no longer be
96
+ # candidates for future iterations. Without this, the next
97
+ # iteration would try to pair stranded delimiters that have
98
+ # already been moved into a different parent, which corrupts the
99
+ # sibling chain (Arena#reparent walks into @parent[-1]).
100
+ if closer_idx > opener_idx + 1
101
+ removed = stack.slice!((opener_idx + 1)...closer_idx)
102
+ removed.each { |e| provisional_nodes.delete(e.node_id) }
103
+ closer_idx = opener_idx + 1
104
+ closer = stack[closer_idx]
105
+ end
106
+
107
+ opener_node = opener.node_id
108
+ closer_node = closer.node_id
109
+
110
+ if @track_source
111
+ opener_match_start = @arena.source_end(opener_node) - strength
112
+ closer_match_end = @arena.source_start(closer_node) + strength
113
+ else
114
+ opener_match_start = -1
115
+ closer_match_end = 0
116
+ end
117
+ emphasis_id = add_node(kind, opener_match_start, closer_match_end)
118
+
119
+ first_inside = @arena.raw_next_sibling_id(opener_node)
120
+ last_inside = @arena.raw_prev_sibling_id(closer_node)
121
+ if first_inside != -1 && last_inside != -1 &&
122
+ first_inside != closer_node && last_inside != opener_node
123
+ @arena.reparent(emphasis_id, first_inside, last_inside)
124
+ end
125
+
126
+ parent_id = @arena.raw_parent_id(opener_node)
127
+ @arena.insert_before(parent_id, closer_node, emphasis_id)
128
+
129
+ # Consume `strength` characters from the inner end of each
130
+ # delimiter. The opener is trimmed on its right (trailing) end,
131
+ # the closer on its left (leading) end; removing the opener from
132
+ # the stack shifts the closer one slot left.
133
+ closer_idx -= 1 if consume_delimiter(opener, opener_idx, stack, strength, provisional_nodes, from_start: false)
134
+ consume_delimiter(closer, closer_idx, stack, strength, provisional_nodes, from_start: true)
135
+ end
136
+
137
+ stack.each { |e| provisional_nodes.delete(e.node_id) }
138
+ stack.clear
139
+ end
140
+
141
+ private
142
+
143
+ # Mirrors Builder#add_arena_node for the nodes this resolver creates
144
+ # (emphasis wrappers only ever take a type and a span).
145
+ def add_node(type, start_byte, end_byte)
146
+ if @track_source
147
+ @arena.add_node(type, source_start: start_byte, source_len: end_byte - start_byte)
148
+ else
149
+ @arena.add_node(type, source_start: -1, source_len: 0)
150
+ end
151
+ end
152
+
153
+ # Removes `strength` characters from one end of a delimiter run. When
154
+ # the whole run is consumed the node is detached and dropped from the
155
+ # stack (returns true); otherwise its count, str1, and — in
156
+ # source-tracking mode — its span are trimmed on the requested side
157
+ # (`from_start` trims the leading end, used for closers; trailing for
158
+ # openers) and it stays on the stack (returns false).
159
+ def consume_delimiter(entry, index, stack, strength, provisional_nodes, from_start:)
160
+ node = entry.node_id
161
+ if entry.count == strength
162
+ provisional_nodes.delete(node)
163
+ @arena.detach(node)
164
+ stack.delete_at(index)
165
+ return true
166
+ end
167
+
168
+ entry.count -= strength
169
+ str = @arena.str1(node)
170
+ @arena.update_str1(node, from_start ? str[strength..] : str[0...-strength])
171
+ if @track_source
172
+ start_byte = @arena.source_start(node)
173
+ end_byte = @arena.source_end(node)
174
+ if from_start
175
+ @arena.update_span(node, start_byte + strength, end_byte)
176
+ else
177
+ @arena.update_span(node, start_byte, end_byte - strength)
178
+ end
179
+ end
180
+ false
181
+ end
182
+ end
183
+ end
184
+ end
@@ -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)
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RedQuilt
4
+ module Inline
5
+ # URL-scheme security policy for inline link / image / autolink
6
+ # destinations. Kept separate from Builder so the "which schemes are
7
+ # safe and how blocking is reported" concern has a single home and can
8
+ # change without touching the inline construction logic.
9
+ #
10
+ # Stateless (module_function); diagnostics are appended to the caller's
11
+ # array (or skipped when it is nil), so there is no per-call allocation.
12
+ module UrlSanitizer
13
+ module_function
14
+
15
+ SAFE_SCHEMES = %w[http https mailto ftp tel ssh].freeze
16
+
17
+ # Autolinks (`<scheme:...>`) are not run through the SAFE_SCHEMES
18
+ # allowlist: CommonMark permits arbitrary schemes there (e.g.
19
+ # `<made-up-scheme://x>`), and an allowlist would break that
20
+ # conformance. Only the schemes that execute script when the link
21
+ # is navigated are denied.
22
+ UNSAFE_AUTOLINK_SCHEMES = %w[javascript vbscript data].freeze
23
+
24
+ SCHEME_RE = /\A([a-zA-Z][a-zA-Z0-9+\-.]*):/
25
+
26
+ # Link / image destinations: allowlist. Relative URLs (starting `/`
27
+ # or `#`) and scheme-less URLs pass; an unknown scheme is blocked
28
+ # (href emptied) and a diagnostic is recorded.
29
+ def sanitize_destination(destination, diagnostics)
30
+ return "" if destination.nil?
31
+ return destination if destination.start_with?("/", "#")
32
+
33
+ scheme = destination[SCHEME_RE, 1]
34
+ return destination if scheme.nil?
35
+ return destination if SAFE_SCHEMES.include?(scheme.downcase)
36
+
37
+ report_blocked(diagnostics, scheme)
38
+ ""
39
+ end
40
+
41
+ # Autolink destinations: denylist. The destination is returned
42
+ # unchanged unless its scheme executes script on navigation, in which
43
+ # case the href is emptied and a diagnostic is recorded.
44
+ def block_unsafe_autolink(destination, diagnostics)
45
+ scheme = destination[SCHEME_RE, 1]
46
+ return destination if scheme.nil?
47
+ return destination unless UNSAFE_AUTOLINK_SCHEMES.include?(scheme.downcase)
48
+
49
+ report_blocked(diagnostics, scheme)
50
+ ""
51
+ end
52
+
53
+ def report_blocked(diagnostics, scheme)
54
+ return unless diagnostics
55
+
56
+ diagnostics << Diagnostic.new(
57
+ severity: :warning,
58
+ rule: :unsafe_url,
59
+ message: "Unsafe URL scheme #{scheme.downcase.inspect} blocked",
60
+ )
61
+ end
62
+ end
63
+ end
64
+ end
@@ -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
@@ -10,5 +10,10 @@ module RedQuilt
10
10
  # Positional (not keyword_init): one Line is built per source line, so
11
11
  # the ~2.5x faster positional constructor matters on large documents.
12
12
  # Argument order: content, start_byte, end_byte, blank, lazy_continuation.
13
- Line = Struct.new(:content, :start_byte, :end_byte, :blank, :lazy_continuation)
13
+ Line = Struct.new(:content, :start_byte, :end_byte, :blank, :lazy_continuation) do
14
+ # Byte length of the line's span in the original source.
15
+ def span_len
16
+ end_byte - start_byte
17
+ end
18
+ end
14
19
  end
@@ -22,7 +22,7 @@ module RedQuilt
22
22
  walk(@document.root_id) do |id|
23
23
  case @arena.type(id)
24
24
  when NodeType::HEADING
25
- level = @arena.int1(id)
25
+ level = @arena.heading_level(id)
26
26
  if last_heading_level.positive? && level > last_heading_level + 1
27
27
  push(:info, :heading_level_skip,
28
28
  "Heading jumps from h#{last_heading_level} to h#{level}",
@@ -45,7 +45,7 @@ module RedQuilt
45
45
  end
46
46
 
47
47
  def check_empty_link(node_id)
48
- return unless @arena.str1(node_id).to_s.empty?
48
+ return unless @arena.link_destination(node_id).to_s.empty?
49
49
 
50
50
  push(:warning, :empty_link,
51
51
  "Link has no destination",
@@ -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.