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.
- checksums.yaml +4 -4
- data/lib/markbridge/ast/element.rb +28 -0
- data/lib/markbridge/ast/quote.rb +45 -15
- data/lib/markbridge/ast/text.rb +7 -4
- data/lib/markbridge/ast/url.rb +15 -0
- data/lib/markbridge/normalizer/report.rb +41 -0
- data/lib/markbridge/normalizer/rule_set.rb +89 -0
- data/lib/markbridge/normalizer/text_projection.rb +37 -0
- data/lib/markbridge/normalizer/walker.rb +250 -0
- data/lib/markbridge/normalizer.rb +180 -0
- data/lib/markbridge/parsers/bbcode/handler_registry.rb +27 -1
- data/lib/markbridge/parsers/bbcode/handlers/quote_handler.rb +32 -9
- data/lib/markbridge/parsers/bbcode/handlers/raw_handler.rb +8 -4
- data/lib/markbridge/parsers/bbcode/parser.rb +6 -3
- data/lib/markbridge/parsers/bbcode/scanner.rb +133 -53
- data/lib/markbridge/parsers/html/handler_registry.rb +27 -1
- data/lib/markbridge/parsers/html/parser.rb +54 -13
- data/lib/markbridge/parsers/media_wiki/inline_parser.rb +95 -57
- data/lib/markbridge/parsers/media_wiki/inline_tag_registry.rb +24 -1
- data/lib/markbridge/parsers/media_wiki/parser.rb +5 -2
- data/lib/markbridge/parsers/text_formatter/handler_registry.rb +23 -1
- data/lib/markbridge/parsers/text_formatter/handlers/quote_handler.rb +31 -2
- data/lib/markbridge/parsers/text_formatter/parser.rb +10 -3
- data/lib/markbridge/renderers/discourse/markdown_escaper.rb +12 -1
- data/lib/markbridge/renderers/discourse/render_context.rb +74 -11
- data/lib/markbridge/renderers/discourse/renderer.rb +63 -13
- data/lib/markbridge/renderers/discourse/rendering_interface.rb +27 -2
- data/lib/markbridge/renderers/discourse/tag_library.rb +20 -0
- data/lib/markbridge/renderers/discourse/tags/event_tag.rb +2 -4
- data/lib/markbridge/renderers/discourse/tags/poll_tag.rb +2 -4
- data/lib/markbridge/renderers/discourse/tags/quote_tag.rb +8 -6
- data/lib/markbridge/renderers/discourse/tags/url_tag.rb +36 -2
- data/lib/markbridge/version.rb +1 -1
- data/lib/markbridge.rb +61 -11
- 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
|
-
|
|
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:
|
|
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,
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
46
|
-
return option, nil, nil, nil unless
|
|
52
|
+
post_number = option[/,\s*post:(\d+)/, 1]
|
|
53
|
+
return option, nil, nil, nil unless post_number
|
|
47
54
|
|
|
48
|
-
# Discourse format: "username, post:
|
|
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
|
-
|
|
58
|
+
topic_id = option[/,\s*topic:(\d+)/, 1]
|
|
52
59
|
|
|
53
|
-
|
|
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
|
-
@
|
|
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.
|
|
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(
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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 :
|
|
46
|
-
:
|
|
47
|
-
:
|
|
48
|
-
:
|
|
49
|
-
:
|
|
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
|
|
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
|
|
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
|
-
#
|
|
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.
|
|
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
|
|
135
|
+
return nil unless tag_initial_byte?(current_byte)
|
|
90
136
|
@current_pos += 1
|
|
91
137
|
|
|
92
|
-
@current_pos += 1 while
|
|
138
|
+
@current_pos += 1 while tag_name_byte?(current_byte)
|
|
93
139
|
|
|
94
|
-
if
|
|
140
|
+
if current_byte == COLON
|
|
95
141
|
@current_pos += 1
|
|
96
|
-
@current_pos += 1 while
|
|
142
|
+
@current_pos += 1 while uid_hex_byte?(current_byte)
|
|
97
143
|
end
|
|
98
144
|
|
|
99
|
-
@input
|
|
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
|
|
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 =
|
|
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(
|
|
133
|
-
return false if
|
|
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
|
-
|
|
141
|
-
if
|
|
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
|
-
|
|
210
|
+
quote = current_byte == DOUBLE_QUOTE ? "\"" : "'"
|
|
165
211
|
start = (@current_pos += 1) # skip opening quote
|
|
166
|
-
closing_index = @input.
|
|
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
|
|
216
|
+
@input.byteslice(start, closing_index - start)
|
|
171
217
|
end
|
|
172
218
|
|
|
173
219
|
def scan_unquoted_value
|
|
174
|
-
|
|
220
|
+
consume_range(@input.byteindex(UNQUOTED_VALUE_STOP, @current_pos) || @length)
|
|
175
221
|
end
|
|
176
222
|
|
|
177
|
-
# Consumes characters
|
|
178
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
199
|
-
|
|
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
|
|
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 (
|
|
209
|
-
# bounds on @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
|