red_quilt 0.6.1

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.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +109 -0
  4. data/.rubocop_todo.yml +7 -0
  5. data/CHANGELOG.md +57 -0
  6. data/README.md +284 -0
  7. data/Rakefile +8 -0
  8. data/ast-spec.md +1227 -0
  9. data/docs/architecture.md +81 -0
  10. data/docs/arena-usage.md +363 -0
  11. data/docs/commonmark-conformance.md +241 -0
  12. data/exe/redquilt +7 -0
  13. data/lib/red_quilt/arena.rb +366 -0
  14. data/lib/red_quilt/block_parser.rb +724 -0
  15. data/lib/red_quilt/blockquote.rb +151 -0
  16. data/lib/red_quilt/cli.rb +182 -0
  17. data/lib/red_quilt/diagnostic.rb +47 -0
  18. data/lib/red_quilt/document.rb +126 -0
  19. data/lib/red_quilt/extended_autolink_pass.rb +185 -0
  20. data/lib/red_quilt/footnote_definition.rb +147 -0
  21. data/lib/red_quilt/footnote_pass.rb +39 -0
  22. data/lib/red_quilt/footnote_registry.rb +68 -0
  23. data/lib/red_quilt/indentation.rb +73 -0
  24. data/lib/red_quilt/inline/builder.rb +674 -0
  25. data/lib/red_quilt/inline/flanking.rb +120 -0
  26. data/lib/red_quilt/inline/html_entities.rb +2180 -0
  27. data/lib/red_quilt/inline/lexer.rb +280 -0
  28. data/lib/red_quilt/inline/link_scanner.rb +315 -0
  29. data/lib/red_quilt/inline/token_kind.rb +39 -0
  30. data/lib/red_quilt/inline/tokens.rb +73 -0
  31. data/lib/red_quilt/inline.rb +34 -0
  32. data/lib/red_quilt/inline_pass.rb +53 -0
  33. data/lib/red_quilt/line.rb +14 -0
  34. data/lib/red_quilt/lint_pass.rb +71 -0
  35. data/lib/red_quilt/list.rb +317 -0
  36. data/lib/red_quilt/node_ref.rb +114 -0
  37. data/lib/red_quilt/node_type.rb +66 -0
  38. data/lib/red_quilt/plain_text.rb +46 -0
  39. data/lib/red_quilt/reference_definition.rb +309 -0
  40. data/lib/red_quilt/renderer/html.rb +279 -0
  41. data/lib/red_quilt/renderer/mdast.rb +152 -0
  42. data/lib/red_quilt/source_map.rb +29 -0
  43. data/lib/red_quilt/source_span.rb +26 -0
  44. data/lib/red_quilt/theme.rb +28 -0
  45. data/lib/red_quilt/themes/default.css +87 -0
  46. data/lib/red_quilt/version.rb +5 -0
  47. data/lib/red_quilt.rb +86 -0
  48. data/mise.toml +2 -0
  49. data/sig/red_quilt.rbs +45 -0
  50. metadata +91 -0
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RedQuilt
4
+ # GitHub-style footnote definitions: `[^label]: content`. `match` handles
5
+ # the opener line; `Parser` (a cached BlockParser collaborator, like
6
+ # List::Parser) collects the optionally-indented, multi-paragraph
7
+ # continuation and parses it into a FOOTNOTE_DEFINITION node. Label
8
+ # normalization is shared with link reference definitions.
9
+ #
10
+ # NOTE: ReferenceDefinition's REF_DEF_RE also matches `[^label]:` (treating
11
+ # `^label` as an ordinary label), so the block dispatch must try this
12
+ # matcher BEFORE the reference-definition branch when footnotes are on.
13
+ module FootnoteDefinition
14
+ # Up to 3 spaces of indent, then `[^label]:`. The label is non-empty and
15
+ # contains no whitespace or `]` (GFM rule).
16
+ RE = /\A {0,3}\[\^([^\]\s]+)\]:(.*)\z/m
17
+
18
+ module_function
19
+
20
+ # Returns { label:, content_start:, content: } for a footnote-definition
21
+ # opener, or nil. `content_start` is the byte offset (within `text`)
22
+ # where the content begins, after `]:` and an optional single separating
23
+ # space/tab; `content` is that text (possibly empty).
24
+ def match(text)
25
+ m = RE.match(text)
26
+ return nil unless m
27
+
28
+ rest = m[2]
29
+ lead = rest.match?(/\A[ \t]/) ? 1 : 0
30
+ {
31
+ label: m[1],
32
+ content_start: text.bytesize - rest.bytesize + lead,
33
+ content: lead.zero? ? rest : rest[lead..],
34
+ }
35
+ end
36
+
37
+ # Cached collaborator for BlockParser (created once per document and
38
+ # reused for every definition). Footnote definitions are document-global:
39
+ # the parser lazily creates a single FOOTNOTES_SECTION under the root and
40
+ # memoizes it; per-call state otherwise lives in locals so the recursive
41
+ # parse_lines call is safe.
42
+ class Parser
43
+ # GFM footnote continuation indent (columns). Lines indented at least
44
+ # this much (plus blank lines between them) belong to the definition.
45
+ CONTENT_INDENT = 4
46
+
47
+ def initialize(block_parser)
48
+ @block_parser = block_parser
49
+ @arena = block_parser.arena
50
+ @section_id = nil
51
+ end
52
+
53
+ # Consumes the definition opening at `lines[index]` (its `match`
54
+ # already parsed), registers it in `registry`, and returns the next
55
+ # unconsumed line index.
56
+ def parse(lines, index, match, registry, root_id)
57
+ first = lines[index]
58
+ content_lines = [content_line(match[:content], first.start_byte + match[:content_start], first.end_byte)]
59
+ consumed_index = collect_continuation(lines, index + 1, content_lines)
60
+
61
+ label = ReferenceDefinition.normalize_label(match[:label])
62
+ span = SourceSpan.new(first.start_byte, lines[consumed_index - 1].end_byte)
63
+ if registry.defined?(label)
64
+ @block_parser.diagnostics << Diagnostic.new(
65
+ severity: :warning, rule: :duplicate_footnote,
66
+ message: "Duplicate footnote definition #{label.inspect} — keeping the first",
67
+ source_span: span,
68
+ )
69
+ return consumed_index
70
+ end
71
+
72
+ def_id = @arena.add_node(NodeType::FOOTNOTE_DEFINITION,
73
+ source_start: span.start_byte, source_len: span.length,
74
+ str1: label)
75
+ @arena.append_child(section_id(root_id), def_id)
76
+ registry.define(label, def_id)
77
+ @block_parser.parse_lines(def_id, content_lines, transformed: true)
78
+ consumed_index
79
+ end
80
+
81
+ # Make the footnotes section root's last child so it renders last and
82
+ # the inline pass numbers body references before any nested references
83
+ # inside the definitions. No-op when no definition was found.
84
+ def move_section_to_end(root_id)
85
+ return if @section_id.nil?
86
+
87
+ @arena.detach(@section_id)
88
+ @arena.append_child(root_id, @section_id)
89
+ end
90
+
91
+ private
92
+
93
+ def content_line(content, start_byte, end_byte)
94
+ Line.new(content, start_byte, end_byte, !content.match?(/[^ \t]/))
95
+ end
96
+
97
+ # Appends continuation lines to `content_lines` and returns the first
98
+ # line index NOT consumed (trailing blank lines are left for the
99
+ # surrounding flow). A line continues the definition when it is blank,
100
+ # indented >= CONTENT_INDENT columns (a fresh/continued paragraph), or
101
+ # — GFM treats footnote definitions like list items — a lazy
102
+ # continuation: an unindented non-blank line directly following open
103
+ # paragraph content that doesn't itself start a block or a new footnote
104
+ # definition.
105
+ def collect_continuation(lines, index, content_lines)
106
+ pending_blanks = []
107
+ while index < lines.length
108
+ current = lines[index]
109
+ if current.blank
110
+ pending_blanks << current
111
+ index += 1
112
+ next
113
+ end
114
+
115
+ if Indentation.leading_columns(current.content) >= CONTENT_INDENT
116
+ pending_blanks.each { |b| content_lines << Line.new("", b.start_byte, b.end_byte, true) }
117
+ pending_blanks = []
118
+ stripped = Indentation.strip_columns(current.content, CONTENT_INDENT)
119
+ advance = [Indentation.leading_ws_bytes(current.content), current.content.bytesize - stripped.bytesize].min
120
+ advance = 0 if advance.negative?
121
+ content_lines << Line.new(stripped, current.start_byte + advance, current.end_byte, false)
122
+ index += 1
123
+ next
124
+ end
125
+
126
+ break unless pending_blanks.empty?
127
+ break if content_lines.last.nil? || content_lines.last.blank
128
+ break if @block_parser.lazy_break?(lines, index) || FootnoteDefinition.match(current.content)
129
+
130
+ stripped = current.content.sub(/\A[ \t]+/, "")
131
+ strip_len = current.content.length - stripped.length
132
+ content_lines << Line.new(stripped, current.start_byte + strip_len, current.end_byte, false, true)
133
+ index += 1
134
+ end
135
+ index - pending_blanks.length
136
+ end
137
+
138
+ def section_id(root_id)
139
+ @section_id ||= begin
140
+ id = @arena.add_node(NodeType::FOOTNOTES_SECTION, source_start: -1, source_len: 0)
141
+ @arena.append_child(root_id, id)
142
+ id
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RedQuilt
4
+ # Post-inline pass for the footnotes extension. After the inline pass has
5
+ # resolved `[^label]` references (assigning numbers in first-reference
6
+ # order on the shared FootnoteRegistry), this reorders the definition
7
+ # nodes under the document-level footnotes section into that order, drops
8
+ # definitions that were never referenced, and removes the whole section
9
+ # when nothing referenced it.
10
+ class FootnotePass
11
+ def initialize(document)
12
+ @document = document
13
+ @arena = document.arena
14
+ @registry = document.footnotes
15
+ end
16
+
17
+ def apply
18
+ return if @registry.nil?
19
+
20
+ # BlockParser moves the footnotes section to be root's last child, so
21
+ # that's where it is (if any definitions were collected at all).
22
+ section_id = @arena.raw_last_child_id(@document.root_id)
23
+ return if section_id == -1 || @arena.type(section_id) != NodeType::FOOTNOTES_SECTION
24
+
25
+ unless @registry.any_referenced?
26
+ @arena.detach(section_id)
27
+ return
28
+ end
29
+
30
+ # Re-append referenced definitions in first-reference order; detaching
31
+ # all current children first means unreferenced definitions are left
32
+ # orphaned (and so never rendered).
33
+ @arena.child_ids(section_id).to_a.each { |child| @arena.detach(child) }
34
+ @registry.referenced_labels.each do |label|
35
+ @arena.append_child(section_id, @registry.definition_node(label))
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RedQuilt
4
+ # Shared state for the footnotes extension, created once per parse when
5
+ # `footnotes: true` and threaded (by reference) through the block parser,
6
+ # the inline builders, the FootnotePass, and the renderer. A single shared
7
+ # object is required because the inline pass builds a fresh Builder per
8
+ # materialized target, so the first-reference numbering counter cannot live
9
+ # on a Builder instance.
10
+ #
11
+ # `nil` is used in place of a registry when footnotes are disabled, so the
12
+ # collectors/resolvers can cheaply opt out.
13
+ class FootnoteRegistry
14
+ def initialize
15
+ @definitions = {} # normalized label => FOOTNOTE_DEFINITION node id
16
+ @numbers = {} # normalized label => footnote number
17
+ @occurrences = Hash.new(0) # normalized label => reference count
18
+ @order = [] # normalized labels in first-reference order
19
+ end
20
+
21
+ # Records a definition node for a label during block parsing. Returns
22
+ # false when the label is already defined (duplicate), true otherwise.
23
+ def define(label, node_id)
24
+ return false if @definitions.key?(label)
25
+
26
+ @definitions[label] = node_id
27
+ true
28
+ end
29
+
30
+ def defined?(label)
31
+ @definitions.key?(label)
32
+ end
33
+
34
+ def definition_node(label)
35
+ @definitions[label]
36
+ end
37
+
38
+ # Records an inline reference to a label. Returns [number, occurrence]
39
+ # (assigning the number on first reference, in encounter order), or nil
40
+ # when the label has no definition.
41
+ def reference(label)
42
+ return nil unless @definitions.key?(label)
43
+
44
+ unless @numbers.key?(label)
45
+ @order << label
46
+ @numbers[label] = @order.length
47
+ end
48
+ [@numbers[label], @occurrences[label] += 1]
49
+ end
50
+
51
+ def number(label)
52
+ @numbers[label]
53
+ end
54
+
55
+ def occurrences(label)
56
+ @occurrences[label]
57
+ end
58
+
59
+ # Referenced labels in first-reference order (the render order).
60
+ def referenced_labels
61
+ @order
62
+ end
63
+
64
+ def any_referenced?
65
+ !@order.empty?
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RedQuilt
4
+ # Stateless CommonMark indentation / column arithmetic, shared by the
5
+ # block parser and its collaborator parsers (List, Blockquote, Footnote).
6
+ # Tabs expand to the next multiple of 4 columns.
7
+ module Indentation
8
+ module_function
9
+
10
+ # Leading-whitespace width of `text` in columns (tabs expanded to the
11
+ # next tab stop of 4).
12
+ def leading_columns(text)
13
+ col = 0
14
+ i = 0
15
+ bytes = text.bytesize
16
+ while i < bytes
17
+ b = text.getbyte(i)
18
+ if b == 0x20
19
+ col += 1
20
+ elsif b == 0x09
21
+ col = ((col / 4) + 1) * 4
22
+ else
23
+ break
24
+ end
25
+ i += 1
26
+ end
27
+ col
28
+ end
29
+
30
+ # Strips up to `n` columns of leading whitespace from `text` and
31
+ # returns the rest. Leading whitespace is normalised to spaces in the
32
+ # returned string so subsequent strips compose correctly regardless of
33
+ # where they land relative to the original tab stops.
34
+ def strip_columns(text, n)
35
+ return text if n <= 0
36
+
37
+ col = 0
38
+ i = 0
39
+ bytes = text.bytesize
40
+ while i < bytes
41
+ b = text.getbyte(i)
42
+ if b == 0x20
43
+ col += 1
44
+ elsif b == 0x09
45
+ col = ((col / 4) + 1) * 4
46
+ else
47
+ break
48
+ end
49
+ i += 1
50
+ end
51
+ # text[0...i] is all leading whitespace representing `col` cols.
52
+ if n >= col
53
+ i.zero? ? text : text.byteslice(i..)
54
+ else
55
+ # Keep the unstripped portion as a run of spaces.
56
+ (" " * (col - n)) + text.byteslice(i..)
57
+ end
58
+ end
59
+
60
+ # Bytes of literal leading 0x20 / 0x09 in `text`.
61
+ def leading_ws_bytes(text)
62
+ i = 0
63
+ bytes = text.bytesize
64
+ while i < bytes
65
+ b = text.getbyte(i)
66
+ break unless b == 0x20 || b == 0x09
67
+
68
+ i += 1
69
+ end
70
+ i
71
+ end
72
+ end
73
+ end