markbridge 0.2.1 → 0.3.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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/lib/markbridge/ast/element.rb +28 -0
  3. data/lib/markbridge/ast/quote.rb +45 -15
  4. data/lib/markbridge/ast/text.rb +7 -4
  5. data/lib/markbridge/ast/url.rb +15 -0
  6. data/lib/markbridge/normalizer/report.rb +41 -0
  7. data/lib/markbridge/normalizer/rule_set.rb +89 -0
  8. data/lib/markbridge/normalizer/text_projection.rb +37 -0
  9. data/lib/markbridge/normalizer/walker.rb +250 -0
  10. data/lib/markbridge/normalizer.rb +180 -0
  11. data/lib/markbridge/parsers/bbcode/handler_registry.rb +27 -1
  12. data/lib/markbridge/parsers/bbcode/handlers/quote_handler.rb +32 -9
  13. data/lib/markbridge/parsers/bbcode/handlers/raw_handler.rb +8 -4
  14. data/lib/markbridge/parsers/bbcode/parser.rb +6 -3
  15. data/lib/markbridge/parsers/bbcode/scanner.rb +133 -53
  16. data/lib/markbridge/parsers/html/handler_registry.rb +27 -1
  17. data/lib/markbridge/parsers/html/parser.rb +54 -13
  18. data/lib/markbridge/parsers/media_wiki/inline_parser.rb +95 -57
  19. data/lib/markbridge/parsers/media_wiki/inline_tag_registry.rb +24 -1
  20. data/lib/markbridge/parsers/media_wiki/parser.rb +5 -2
  21. data/lib/markbridge/parsers/text_formatter/handler_registry.rb +23 -1
  22. data/lib/markbridge/parsers/text_formatter/handlers/quote_handler.rb +31 -2
  23. data/lib/markbridge/parsers/text_formatter/parser.rb +10 -3
  24. data/lib/markbridge/renderers/discourse/markdown_escaper.rb +12 -1
  25. data/lib/markbridge/renderers/discourse/render_context.rb +74 -11
  26. data/lib/markbridge/renderers/discourse/renderer.rb +63 -13
  27. data/lib/markbridge/renderers/discourse/rendering_interface.rb +27 -2
  28. data/lib/markbridge/renderers/discourse/tag_library.rb +20 -0
  29. data/lib/markbridge/renderers/discourse/tags/event_tag.rb +2 -4
  30. data/lib/markbridge/renderers/discourse/tags/poll_tag.rb +2 -4
  31. data/lib/markbridge/renderers/discourse/tags/quote_tag.rb +8 -6
  32. data/lib/markbridge/renderers/discourse/tags/url_tag.rb +36 -2
  33. data/lib/markbridge/version.rb +1 -1
  34. data/lib/markbridge.rb +61 -11
  35. metadata +6 -1
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "normalizer/rule_set"
4
+ require_relative "normalizer/report"
5
+ require_relative "normalizer/text_projection"
6
+ require_relative "normalizer/walker"
7
+
8
+ module Markbridge
9
+ # Rewrites an AST so the renderer only gets markup the target format can
10
+ # express. It runs once, between parse and render. The default rules are
11
+ # CommonMark legality: no link inside a link, no block element inside an
12
+ # inline container, and an inline-only code span. Each match resolves to a
13
+ # strategy (+:keep+, +:hoist_after+, +:unwrap+, +:textify+, +:drop+, or a
14
+ # callable) that the {Walker} applies.
15
+ #
16
+ # Discourse-specific policy (for example, moving an image out of a link) is
17
+ # not built in. A consumer adds those with {#rule}.
18
+ #
19
+ # @example The default, reused across conversions
20
+ # Markbridge::Normalizer.shared_default
21
+ #
22
+ # @example A customized normalizer
23
+ # n = Markbridge::Normalizer.default
24
+ # n.rule(parent: AST::Url, child: AST::Image, strategy: :hoist_after)
25
+ # Markbridge.convert(input, format: :bbcode, normalize: n)
26
+ #
27
+ # @example List what would change, without changing it
28
+ # Markbridge::Normalizer.default.violations(ast) # => [...]
29
+ class Normalizer
30
+ # The strategy symbols a rule may resolve to.
31
+ STRATEGIES = %i[keep hoist_after unwrap textify drop].freeze
32
+
33
+ EMPTY_STACK = [].freeze
34
+ private_constant :EMPTY_STACK
35
+
36
+ # Containers that hold inline content only: a link's text (CommonMark
37
+ # §6.3), and emphasis and heading content.
38
+ INLINE_CONTAINERS = [
39
+ AST::Url,
40
+ AST::Bold,
41
+ AST::Italic,
42
+ AST::Strikethrough,
43
+ AST::Underline,
44
+ AST::Superscript,
45
+ AST::Subscript,
46
+ AST::Heading,
47
+ ].freeze
48
+
49
+ # AST nodes the Discourse renderer prints as block-level Markdown (their
50
+ # output has blank lines around it). One inside an inline container breaks
51
+ # that container, so it is moved out. Spoiler and single-line Code stay
52
+ # inline and are not listed (Code is handled by {KEEP_INLINE_CODE}).
53
+ BLOCK_NODES = [
54
+ AST::Quote,
55
+ AST::Heading,
56
+ AST::List,
57
+ AST::ListItem,
58
+ AST::Table,
59
+ AST::TableRow,
60
+ AST::TableCell,
61
+ AST::Details,
62
+ AST::Paragraph,
63
+ AST::HorizontalRule,
64
+ AST::Align,
65
+ AST::Poll,
66
+ AST::Event,
67
+ ].freeze
68
+
69
+ # A code span may stay inside an inline container while it is on one line.
70
+ # A fenced or multi-line block is moved out. This matches
71
+ # +RenderingInterface#block_context?+: Code prints as a fenced block when a
72
+ # Text child has a newline (the language alone does not make it a block).
73
+ KEEP_INLINE_CODE =
74
+ lambda do |_boundary, node|
75
+ block = node.children.any? { |c| c.instance_of?(AST::Text) && c.text.include?("\n") }
76
+ block ? :hoist_after : :keep
77
+ end
78
+
79
+ class << self
80
+ # A fresh, customizable normalizer with the default rules. Add more with
81
+ # {#rule}.
82
+ # @return [Normalizer]
83
+ def default
84
+ new(build_rules)
85
+ end
86
+
87
+ # The default normalizer, built once and frozen, reused across
88
+ # conversions. +#normalize+ and +#violations+ keep no state on the
89
+ # instance, so one frozen instance is safe to reuse, also across threads.
90
+ # @return [Normalizer] the same frozen instance on every call
91
+ def shared_default
92
+ @shared_default ||= default.freeze
93
+ end
94
+
95
+ private
96
+
97
+ def build_rules
98
+ rules = RuleSet.new
99
+
100
+ # §6.3 A link may not contain another link, at any depth. Unwrap the
101
+ # inner link and keep its text.
102
+ rules.add(parent: AST::Url, child: AST::Url, strategy: :unwrap)
103
+
104
+ INLINE_CONTAINERS.each do |container|
105
+ rules.add(parent: container, child: AST::Code, strategy: KEEP_INLINE_CODE)
106
+
107
+ BLOCK_NODES.each do |block|
108
+ next if container == block
109
+
110
+ rules.add(parent: container, child: block, strategy: :hoist_after)
111
+ end
112
+ end
113
+
114
+ rules
115
+ end
116
+ end
117
+
118
+ # @param rule_set [RuleSet]
119
+ def initialize(rule_set)
120
+ @rules = rule_set
121
+ end
122
+
123
+ # Add or override a rule. Chainable. A rule for a +(parent, child)+ pair
124
+ # that already exists is replaced. Raises on a frozen ({shared_default})
125
+ # instance; build a fresh one with {.default}.
126
+ #
127
+ # @param parent [Class] ancestor AST class
128
+ # @param child [Class] contained AST class
129
+ # @param strategy [Symbol, #call] one of {STRATEGIES} or a callable
130
+ # @return [self]
131
+ def rule(parent:, child:, strategy:)
132
+ @rules.add(parent:, child:, strategy:)
133
+ self
134
+ end
135
+
136
+ # Rewrite +ast+ in place so it satisfies the rules.
137
+ #
138
+ # @param ast [AST::Document, AST::Element]
139
+ # @return [Array<Hash>] a report of what changed (empty when nothing did),
140
+ # one +{parent:, child:, strategy:, count:}+ row per distinct change.
141
+ def normalize(ast)
142
+ report = Report.new
143
+ Walker.new(@rules, report).call(ast)
144
+ report.to_a
145
+ end
146
+
147
+ # List the violations in +ast+ without changing it.
148
+ #
149
+ # @param ast [AST::Document, AST::Element]
150
+ # @return [Array<Hash>] +{parent:, child:, strategy:}+ per occurrence
151
+ def violations(ast)
152
+ found = []
153
+ collect_violations(ast, EMPTY_STACK, found)
154
+ found
155
+ end
156
+
157
+ def freeze
158
+ @rules.freeze
159
+ super
160
+ end
161
+
162
+ private
163
+
164
+ def collect_violations(element, ancestors, found)
165
+ stack = ancestors + [element]
166
+ element.children.each do |child|
167
+ strategy, boundary = @rules.resolve(child, stack)
168
+ strategy = strategy.call(boundary, child) if strategy.respond_to?(:call)
169
+ unless strategy.nil? || strategy == :keep
170
+ found << { parent: demodulize(boundary.class), child: demodulize(child.class), strategy: }
171
+ end
172
+ collect_violations(child, stack, found) if child.is_a?(AST::Element)
173
+ end
174
+ end
175
+
176
+ def demodulize(klass)
177
+ klass.name.split("::").last
178
+ end
179
+ end
180
+ end
@@ -62,7 +62,11 @@ module Markbridge
62
62
  # @param tag_name [String]
63
63
  # @return [BaseHandler, nil]
64
64
  def [](tag_name)
65
- @handlers[tag_name.to_s.downcase]
65
+ # Keys are always downcased strings and the scanner emits
66
+ # downcased tags, so the fetch hits directly on the hot path;
67
+ # the block normalizes only unusual lookups (mixed case,
68
+ # symbols) instead of allocating a downcased copy per token.
69
+ @handlers.fetch(tag_name) { @handlers[tag_name.to_s.downcase] }
66
70
  end
67
71
 
68
72
  # Get handler for an element instance
@@ -176,6 +180,28 @@ module Markbridge
176
180
  registry
177
181
  end
178
182
 
183
+ # Shared, deep-frozen default registry for the no-customization
184
+ # fast path. Built once per process; {Parser} falls back to it
185
+ # when no +handlers:+ are given, skipping the full registry
186
+ # construction on every parse. Handlers and closing strategies
187
+ # are stateless after construction, so sharing is safe across
188
+ # parsers and threads.
189
+ #
190
+ # @return [HandlerRegistry] the same frozen instance on every call
191
+ def self.shared_default
192
+ @shared_default ||= default.freeze
193
+ end
194
+
195
+ # Freeze the registry together with its internal collections so
196
+ # that registration on a shared instance fails loudly instead of
197
+ # silently mutating state visible to every parser.
198
+ def freeze
199
+ @handlers.freeze
200
+ @element_handlers.freeze
201
+ @auto_closeable_elements.freeze
202
+ super
203
+ end
204
+
179
205
  # Build a registry from the default configuration with optional customization
180
206
  # @yield [HandlerRegistry] the registry to customize
181
207
  # @return [HandlerRegistry]
@@ -10,7 +10,14 @@ module Markbridge
10
10
  # - [quote=author]text[/quote]
11
11
  # - [quote="author"]text[/quote]
12
12
  # - [quote author=username]text[/quote]
13
- # - [quote="username, post:123, topic:456"]text[/quote] (Discourse format)
13
+ # - [quote="username, post:2, topic:456"]text[/quote] (Discourse format)
14
+ #
15
+ # Attribution semantics are Discourse's: `post:` is the post's
16
+ # number within its topic and `topic:` is the topic id. Beware
17
+ # when feeding other dialects through this handler — XenForo
18
+ # attributions ("name, post: 12345, member: 678") also match the
19
+ # `post:` pattern, but there the value is a database post id, not
20
+ # a post number.
14
21
  class QuoteHandler < BaseHandler
15
22
  def initialize
16
23
  @element_class = AST::Quote
@@ -27,13 +34,13 @@ module Markbridge
27
34
  private
28
35
 
29
36
  def extract_quote_attrs(token)
30
- author, post, topic, username = extract_from_option(token)
37
+ author, post_number, topic_id, username = extract_from_option(token)
31
38
  author ||= token.attrs[:author]
32
39
 
33
40
  {
34
41
  author:,
35
- post: token.attrs[:post] || post,
36
- topic: token.attrs[:topic] || topic,
42
+ post_number: integer_or_nil(token.attrs[:post]) || post_number,
43
+ topic_id: integer_or_nil(token.attrs[:topic]) || topic_id,
37
44
  username: token.attrs[:username] || username,
38
45
  }
39
46
  end
@@ -42,15 +49,31 @@ module Markbridge
42
49
  option = token.attrs[:option]
43
50
  return nil, nil, nil, nil unless option
44
51
 
45
- post = option[/,\s*post:(\d+)/, 1]
46
- return option, nil, nil, nil unless post
52
+ post_number = option[/,\s*post:(\d+)/, 1]
53
+ return option, nil, nil, nil unless post_number
47
54
 
48
- # Discourse format: "username, post:123, topic:456" (topic optional,
55
+ # Discourse format: "username, post:2, topic:456" (topic optional,
49
56
  # order irrelevant between post: and topic:).
50
57
  username = option.split(",").first.strip
51
- topic = option[/,\s*topic:(\d+)/, 1]
58
+ topic_id = option[/,\s*topic:(\d+)/, 1]
52
59
 
53
- [username, post, topic, username]
60
+ # The regex captures are guaranteed digit runs, so strict
61
+ # Integer() applies. Base 10 is explicit: without it a
62
+ # leading zero switches Integer() to octal and "099" raises.
63
+ [username, Integer(post_number, 10), topic_id && Integer(topic_id, 10), username]
64
+ end
65
+
66
+ # Coerce an attribute value to Integer; nil and non-numeric
67
+ # values (which would make a useless attribution anyway)
68
+ # become nil. Base 10 is explicit so leading zeros ("099")
69
+ # don't trip Integer()'s octal mode and prefix forms ("0x1A")
70
+ # don't smuggle in surprise values. The result is an unbounded
71
+ # Ruby Integer — a runaway digit run like
72
+ # "post:77777777777777777789999" parses to a bignum, so
73
+ # consumers binding these into fixed-width storage (int64
74
+ # columns) need their own bounds check.
75
+ def integer_or_nil(value)
76
+ Integer(value, 10, exception: false)
54
77
  end
55
78
  end
56
79
  end
@@ -11,6 +11,13 @@ module Markbridge
11
11
  def initialize(element_class, collector: RawContentCollector.new)
12
12
  @element_class = element_class
13
13
  @collector = collector
14
+ # Computed once here: handlers are long-lived, elements are
15
+ # per-tag, and the reflection is too costly to repeat per element.
16
+ @accepts_language =
17
+ element_class
18
+ .instance_method(:initialize)
19
+ .parameters
20
+ .any? { |_kind, name| name == :language }
14
21
  end
15
22
 
16
23
  def on_open(token:, context:, registry:, tokens:)
@@ -43,10 +50,7 @@ module Markbridge
43
50
  end
44
51
 
45
52
  def accepts_language?
46
- @element_class
47
- .instance_method(:initialize)
48
- .parameters
49
- .any? { |_kind, name| name == :language }
53
+ @accepts_language
50
54
  end
51
55
  end
52
56
  end
@@ -26,7 +26,7 @@ module Markbridge
26
26
  if block_given?
27
27
  HandlerRegistry.build_from_default(&block)
28
28
  else
29
- handlers || HandlerRegistry.default
29
+ handlers || HandlerRegistry.shared_default
30
30
  end
31
31
  @unknown_tags = Hash.new(0)
32
32
  end
@@ -53,11 +53,14 @@ module Markbridge
53
53
 
54
54
  private
55
55
 
56
+ LINE_ENDING_RE = /\r\n?|[\u2028\u2029]+/
57
+ private_constant :LINE_ENDING_RE
58
+
56
59
  # Normalize line endings (CR, CRLF, and Unicode separators)
57
60
  # @param input [String]
58
- # @return [String]
61
+ # @return [String] the input itself when already normalized (LF-only)
59
62
  def normalize_line_endings(input)
60
- input.gsub(/\r\n?|[\u2028\u2029]+/, "\n")
63
+ input.match?(LINE_ENDING_RE) ? input.gsub(LINE_ENDING_RE, "\n") : input
61
64
  end
62
65
 
63
66
  # Parse tokens using scanner
@@ -3,26 +3,39 @@
3
3
  module Markbridge
4
4
  module Parsers
5
5
  module BBCode
6
- # High-performance character-by-character BBCode scanner
6
+ # High-performance BBCode scanner
7
7
  # Tokenizes BBCode in O(n) time with minimal allocations and bounded backtracking
8
+ #
9
+ # The scanner works entirely in *byte* offsets (byteindex/byteslice/
10
+ # getbyte). CRuby has no character-index cache, so character-index
11
+ # operations like `str[pos]` walk the string from the start on any
12
+ # input containing a multibyte character — O(pos) per call and
13
+ # superlinear per document. Byte offsets keep multibyte input at
14
+ # near-ASCII cost. Invariant: `@current_pos` always sits on a
15
+ # character boundary — jumps land on matches of ASCII-only patterns
16
+ # and advances step over ASCII bytes or whole matches. Token
17
+ # positions are therefore byte offsets into the input.
8
18
  class Scanner
9
19
  def initialize(input)
10
20
  @input = input
11
- @length = input.length
21
+ @length = input.bytesize
12
22
  @current_pos = 0
13
23
  end
14
24
 
15
25
  def next_token
16
26
  return nil if end_of_input?
17
27
  start_pos = @current_pos
18
- bracket_index = @input.index("[", @current_pos)
28
+ # `byteindex` returns 0 for a match at the start — nil-check, never
29
+ # truthiness-check. Can't be 0 here though: callers guarantee
30
+ # @current_pos <= bracket_index.
31
+ bracket_index = @input.byteindex("[", @current_pos)
19
32
 
20
33
  if bracket_index.nil?
21
- text = @input[@current_pos..]
34
+ text = @input.byteslice(@current_pos, @length - @current_pos)
22
35
  @current_pos = @length
23
36
  TextToken.new(text:, pos: start_pos)
24
37
  elsif bracket_index > @current_pos
25
- text = @input[@current_pos...bracket_index]
38
+ text = @input.byteslice(@current_pos, bracket_index - @current_pos)
26
39
  @current_pos = bracket_index
27
40
  TextToken.new(text:, pos: start_pos)
28
41
  elsif (tag_token = parse_tag_at_cursor)
@@ -35,31 +48,65 @@ module Markbridge
35
48
 
36
49
  private
37
50
 
38
- TAG_INITIAL_CHAR = /[a-z*]/i
39
- TAG_NAME_CHAR = /[a-z0-9]/i
40
- UID_HEX_CHAR = /[0-9a-f]/i
41
- ATTR_NAME_CHAR = /\w/
42
- WHITESPACE_CHAR = /\s/
51
+ # Byte constants for tag scanning. A byte >= 0x80 (multibyte
52
+ # lead/continuation) never satisfies any of the ASCII predicates
53
+ # below, so no encoding-awareness is needed at probe sites.
54
+ TAB = 9 # \t
55
+ CR = 13 # \r (\s == [ \t\n\v\f\r] == 0x20, 0x09..0x0D)
56
+ SPACE = 32
57
+ DOUBLE_QUOTE = 34 # "
58
+ SINGLE_QUOTE = 39 # '
59
+ STAR = 42 # *
60
+ SLASH = 47 # /
61
+ DIGIT_0 = 48
62
+ DIGIT_9 = 57
63
+ COLON = 58 # :
64
+ EQUALS = 61 # =
65
+ UPPER_A = 65
66
+ UPPER_F = 70
67
+ UPPER_Z = 90
68
+ BRACKET_CLOSE = 93 # ]
69
+ UNDERSCORE = 95 # _
70
+ LOWER_A = 97
71
+ LOWER_F = 102
72
+ LOWER_Z = 122
73
+
74
+ # Characters an unquoted attribute value stops at. ASCII-only, so a
75
+ # byteindex match always lands on a character boundary.
43
76
  UNQUOTED_VALUE_STOP = /[\[\]\s]/
44
77
 
45
- private_constant :TAG_INITIAL_CHAR,
46
- :TAG_NAME_CHAR,
47
- :UID_HEX_CHAR,
48
- :ATTR_NAME_CHAR,
49
- :WHITESPACE_CHAR,
78
+ private_constant :TAB,
79
+ :CR,
80
+ :SPACE,
81
+ :DOUBLE_QUOTE,
82
+ :SINGLE_QUOTE,
83
+ :STAR,
84
+ :SLASH,
85
+ :DIGIT_0,
86
+ :DIGIT_9,
87
+ :COLON,
88
+ :EQUALS,
89
+ :UPPER_A,
90
+ :UPPER_F,
91
+ :UPPER_Z,
92
+ :BRACKET_CLOSE,
93
+ :UNDERSCORE,
94
+ :LOWER_A,
95
+ :LOWER_F,
96
+ :LOWER_Z,
50
97
  :UNQUOTED_VALUE_STOP
51
98
 
52
99
  # @return [Token, nil] tag token or nil if not a valid tag (caller rolls back)
53
- # Precondition: caller has verified current_char == "[".
100
+ # Precondition: caller has verified the byte at the cursor is "[".
54
101
  def parse_tag_at_cursor
55
102
  tag_start_pos = @current_pos
56
103
  @current_pos += 1 # skip '['
57
- closing = consume("/")
104
+ closing = consume(SLASH)
58
105
  tag_name = scan_tag_name
59
106
  attrs = (closing || tag_name.nil?) ? {} : scan_attributes
60
- return rollback(tag_start_pos) unless tag_name && consume("]")
107
+ return rollback(tag_start_pos) unless tag_name && consume(BRACKET_CLOSE)
61
108
 
62
- source = @input[tag_start_pos...@current_pos]
109
+ source = @input.byteslice(tag_start_pos, @current_pos - tag_start_pos)
63
110
  build_token(closing:, tag: tag_name.downcase, attrs:, pos: tag_start_pos, source:)
64
111
  end
65
112
 
@@ -76,27 +123,26 @@ module Markbridge
76
123
  nil
77
124
  end
78
125
 
79
- # Scan a tag name: [a-z*][a-z0-9]*(:hex*)?
126
+ # Scan a tag name: [a-z*][a-z0-9]*(:hex*)? (case-insensitive)
80
127
  #
81
- # Char-by-char rather than a single regex over `@input[pos..]`
128
+ # Byte-predicate loop rather than a regex over `@input[pos..]`
82
129
  # because the regex form allocates a substring for every tag,
83
- # which is a dominant cost on tag-heavy input. The char-based
84
- # loop is ~3x faster under YJIT.
130
+ # which is a dominant cost on tag-heavy input.
85
131
  # @return [String, nil]
86
132
  def scan_tag_name
87
133
  start = @current_pos
88
134
 
89
- return nil unless current_char&.match?(TAG_INITIAL_CHAR)
135
+ return nil unless tag_initial_byte?(current_byte)
90
136
  @current_pos += 1
91
137
 
92
- @current_pos += 1 while current_char&.match?(TAG_NAME_CHAR)
138
+ @current_pos += 1 while tag_name_byte?(current_byte)
93
139
 
94
- if current_char == ":"
140
+ if current_byte == COLON
95
141
  @current_pos += 1
96
- @current_pos += 1 while current_char&.match?(UID_HEX_CHAR)
142
+ @current_pos += 1 while uid_hex_byte?(current_byte)
97
143
  end
98
144
 
99
- @input[start...@current_pos]
145
+ @input.byteslice(start, @current_pos - start)
100
146
  end
101
147
 
102
148
  # Scan tag attributes
@@ -107,7 +153,7 @@ module Markbridge
107
153
  attrs = {}
108
154
  skip_whitespace
109
155
 
110
- if current_char == "="
156
+ if current_byte == EQUALS
111
157
  @current_pos += 1
112
158
  skip_whitespace
113
159
  if (val = scan_attribute_value)
@@ -116,9 +162,9 @@ module Markbridge
116
162
  skip_whitespace
117
163
  end
118
164
 
119
- while (name = scan_while(ATTR_NAME_CHAR))
165
+ while (name = scan_attr_name)
120
166
  skip_whitespace
121
- break unless consume("=")
167
+ break unless consume(EQUALS)
122
168
 
123
169
  skip_whitespace
124
170
  value = scan_attribute_value
@@ -129,16 +175,16 @@ module Markbridge
129
175
  attrs
130
176
  end
131
177
 
132
- def consume(char)
133
- return false if current_char != char
178
+ def consume(byte)
179
+ return false if current_byte != byte
134
180
 
135
181
  @current_pos += 1
136
182
  true
137
183
  end
138
184
 
139
185
  def scan_attribute_value
140
- char = current_char
141
- if char == '"' || char == "'"
186
+ byte = current_byte
187
+ if byte == DOUBLE_QUOTE || byte == SINGLE_QUOTE
142
188
  scan_quoted_string
143
189
  else
144
190
  scan_unquoted_value
@@ -161,52 +207,86 @@ module Markbridge
161
207
  #
162
208
  # @return [String, nil] the unescaped attribute value, or nil if unterminated
163
209
  def scan_quoted_string
164
- quote_char = current_char
210
+ quote = current_byte == DOUBLE_QUOTE ? "\"" : "'"
165
211
  start = (@current_pos += 1) # skip opening quote
166
- closing_index = @input.index(quote_char, start)
212
+ closing_index = @input.byteindex(quote, start)
167
213
  return nil unless closing_index
168
214
 
169
215
  @current_pos = closing_index + 1
170
- @input[start...closing_index]
216
+ @input.byteslice(start, closing_index - start)
171
217
  end
172
218
 
173
219
  def scan_unquoted_value
174
- scan_until(UNQUOTED_VALUE_STOP)
220
+ consume_range(@input.byteindex(UNQUOTED_VALUE_STOP, @current_pos) || @length)
175
221
  end
176
222
 
177
- # Consumes characters matching +pattern+; returns substring or nil if empty
178
- def scan_while(pattern)
223
+ # Consumes attribute-name characters (\w == [A-Za-z0-9_]); returns
224
+ # substring or nil if empty
225
+ def scan_attr_name
179
226
  stop_index = @current_pos
180
- stop_index += 1 while stop_index < @length && @input[stop_index].match?(pattern)
227
+ stop_index += 1 while stop_index < @length && attr_name_byte?(@input.getbyte(stop_index))
181
228
  consume_range(stop_index)
182
229
  end
183
230
 
184
- # Consumes characters until +pattern+ matches (or end of input); returns substring or nil if empty
185
- def scan_until(pattern)
186
- consume_range(@input.index(pattern, @current_pos) || @length)
187
- end
188
-
189
231
  # Slice [@current_pos, stop_index), advance the cursor, or return nil for empty.
190
232
  def consume_range(stop_index)
191
233
  return nil if stop_index == @current_pos
192
234
 
193
- value = @input[@current_pos...stop_index]
235
+ value = @input.byteslice(@current_pos, stop_index - @current_pos)
194
236
  @current_pos = stop_index
195
237
  value
196
238
  end
197
239
 
198
- def current_char
199
- @input[@current_pos]
240
+ # @return [Integer, nil] byte at the cursor, nil at end of input
241
+ def current_byte
242
+ @input.getbyte(@current_pos)
200
243
  end
201
244
 
202
245
  def skip_whitespace
203
246
  @current_pos += 1 while @current_pos < @length &&
204
- @input[@current_pos].match?(WHITESPACE_CHAR)
247
+ whitespace_byte?(@input.getbyte(@current_pos))
248
+ end
249
+
250
+ # [a-z*]/i — first character of a tag name; nil (end of input) is
251
+ # not a tag byte
252
+ def tag_initial_byte?(byte)
253
+ byte &&
254
+ (
255
+ (byte >= LOWER_A && byte <= LOWER_Z) || (byte >= UPPER_A && byte <= UPPER_Z) ||
256
+ byte == STAR
257
+ )
258
+ end
259
+
260
+ # [a-z0-9]/i — rest of a tag name
261
+ def tag_name_byte?(byte)
262
+ return false if byte.nil?
263
+
264
+ (byte >= LOWER_A && byte <= LOWER_Z) || (byte >= UPPER_A && byte <= UPPER_Z) ||
265
+ (byte >= DIGIT_0 && byte <= DIGIT_9)
266
+ end
267
+
268
+ # [0-9a-f]/i — uid suffix after ':'
269
+ def uid_hex_byte?(byte)
270
+ return false if byte.nil?
271
+
272
+ (byte >= DIGIT_0 && byte <= DIGIT_9) || (byte >= LOWER_A && byte <= LOWER_F) ||
273
+ (byte >= UPPER_A && byte <= UPPER_F)
274
+ end
275
+
276
+ # \w — attribute name character
277
+ def attr_name_byte?(byte)
278
+ (byte >= LOWER_A && byte <= LOWER_Z) || (byte >= UPPER_A && byte <= UPPER_Z) ||
279
+ (byte >= DIGIT_0 && byte <= DIGIT_9) || byte == UNDERSCORE
280
+ end
281
+
282
+ # \s — exactly [ \t\n\v\f\r]
283
+ def whitespace_byte?(byte)
284
+ byte == SPACE || (byte >= TAB && byte <= CR)
205
285
  end
206
286
 
207
287
  def end_of_input?
208
- # All callers maintain @current_pos <= @length (scan_while
209
- # bounds on @length; scan_until uses `index || @length`;
288
+ # All callers maintain @current_pos <= @length (scan_attr_name
289
+ # bounds on @length; scan_unquoted_value uses `byteindex || @length`;
210
290
  # consume is a no-op at EOF); `==` and `>=` are observably
211
291
  # identical here.
212
292
  @current_pos == @length