Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions bench/alloc_profile.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
bbcode: -> { [Corpus.ascii, Corpus.multibyte] },
mediawiki: -> { [Corpus.mediawiki, Corpus.mediawiki_multibyte] },
html: -> { [Corpus.html, Corpus.html_multibyte] },
text_formatter: -> { [Corpus.text_formatter, Corpus.text_formatter_multibyte] },
}.freeze

# Each variant names the corpus pair it runs on and a builder that
Expand Down Expand Up @@ -72,6 +73,33 @@
->(i) { parser.parse(corpus[i]) }
end,
],
"html_walk" => [
:html,
lambda do |corpus|
parser = Markbridge::Parsers::HTML::Parser.new
fragments = corpus.map { |post| Nokogiri::HTML.fragment(post) }
->(i) { parser.parse(fragments[i]) }
end,
],
"tf_fresh" => [
:text_formatter,
->(corpus) { ->(i) { Markbridge.text_formatter_xml_to_markdown(corpus[i]) } },
],
"tf_parse" => [
:text_formatter,
lambda do |corpus|
parser = Markbridge::Parsers::TextFormatter::Parser.new
->(i) { parser.parse(corpus[i]) }
end,
],
"tf_walk" => [
:text_formatter,
lambda do |corpus|
parser = Markbridge::Parsers::TextFormatter::Parser.new
docs = corpus.map { |post| Nokogiri.XML(post) }
->(i) { parser.parse(docs[i]) }
end,
],
}.freeze

variant = ARGV[0] || "fresh"
Expand Down
40 changes: 40 additions & 0 deletions bench/corpus.rb
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,38 @@ def html_construct(rng, words)
end
end

# An s9e/TextFormatter XML post (the phpBB 3.2+ storage format): a
# single <r> root, inline tags with <s>/<e> markup-preservation
# children, <br/> line breaks.
def text_formatter_post(rng, words)
parts = []
parts << sentence(rng, words, 10)
parts << "<B><s>[b]</s>#{sentence(rng, words, 4)}<e>[/b]</e></B> and " \
"<I><s>[i]</s>#{sentence(rng, words, 3)}<e>[/i]</e></I>"
parts << sentence(rng, words, 12)
parts << text_formatter_construct(rng, words)
parts << sentence(rng, words, 10)
"<r>#{parts.join("<br/><br/>")}</r>"
end

def text_formatter_construct(rng, words)
case rng.rand(5)
when 0
"<QUOTE author=\"alice\"><s>[quote=alice]</s>#{sentence(rng, words, 8)}<e>[/quote]</e></QUOTE>"
when 1
items = Array.new(3 + rng.rand(3)) { "<LI><s>[*]</s>#{sentence(rng, words, 5)}</LI>" }.join
"<LIST><s>[list]</s>#{items}<e>[/list]</e></LIST>"
when 2
"<CODE><s>[code]</s>x = #{rng.rand(100)}<e>[/code]</e></CODE>"
when 3
url = "https://example.com/#{rng.rand(1000)}"
"<URL url=\"#{url}\"><s>[url=#{url}]</s>#{sentence(rng, words, 3)}<e>[/url]</e></URL>"
when 4
"<S><s>[s]</s>#{sentence(rng, words, 3)}<e>[/s]</e></S> und " \
"<U><s>[u]</s>#{sentence(rng, words, 2)}<e>[/u]</e></U>"
end
end

def build(words, count: 200, seed: 42, kind: :post)
rng = Random.new(seed)
Array.new(count) { public_send(kind, rng, words) }
Expand Down Expand Up @@ -197,4 +229,12 @@ def html
def html_multibyte
@html_multibyte ||= build(WORDS_MULTI, kind: :html_post)
end

def text_formatter
@text_formatter ||= build(WORDS_ASCII, kind: :text_formatter_post)
end

def text_formatter_multibyte
@text_formatter_multibyte ||= build(WORDS_MULTI, kind: :text_formatter_post)
end
end
45 changes: 44 additions & 1 deletion bench/corpus_bench.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
# that file measures micro-inputs per feature; this one measures
# realistic ~1 KB forum posts and — more importantly — *isolates* cost
# centers by differencing variants (e.g. fresh minus shared = per-call
# setup cost; parse_only minus scan_only = handler/AST cost).
# setup cost; parse_only minus scan_only = handler/AST cost; *_parse
# minus *_walk = nokogiri's share).
#
# bundle exec ruby --yjit bench/corpus_bench.rb [variant ...]
#
Expand Down Expand Up @@ -47,6 +48,7 @@ def report(name, corpus, &work)
bbcode: -> { [Corpus.ascii, Corpus.multibyte] },
mediawiki: -> { [Corpus.mediawiki, Corpus.mediawiki_multibyte] },
html: -> { [Corpus.html, Corpus.html_multibyte] },
text_formatter: -> { [Corpus.text_formatter, Corpus.text_formatter_multibyte] },
}.freeze

# Each variant names the corpus pair it runs on and a runner that
Expand Down Expand Up @@ -122,6 +124,47 @@ def report(name, corpus, &work)
report("html_parse/#{tag}", corpus) { |post| parser.parse(post) }
end,
],
# The Ruby tree walk alone (pre-parsed input); html_parse minus
# html_walk = nokogiri's share.
"html_walk" => [
:html,
lambda do |corpus, tag|
parser = Markbridge::Parsers::HTML::Parser.new
fragments = corpus.map { |post| Nokogiri::HTML.fragment(post) }
report("html_walk/#{tag}", fragments) { |fragment| parser.parse(fragment) }
end,
],
"html_nokogiri" => [
:html,
lambda do |corpus, tag|
report("html_nokogiri/#{tag}", corpus) { |post| Nokogiri::HTML.fragment(post) }
end,
],
"tf_fresh" => [
:text_formatter,
lambda do |corpus, tag|
report("tf_fresh/#{tag}", corpus) { |post| Markbridge.text_formatter_xml_to_markdown(post) }
end,
],
"tf_parse" => [
:text_formatter,
lambda do |corpus, tag|
parser = Markbridge::Parsers::TextFormatter::Parser.new
report("tf_parse/#{tag}", corpus) { |post| parser.parse(post) }
end,
],
"tf_walk" => [
:text_formatter,
lambda do |corpus, tag|
parser = Markbridge::Parsers::TextFormatter::Parser.new
docs = corpus.map { |post| Nokogiri.XML(post) }
report("tf_walk/#{tag}", docs) { |doc| parser.parse(doc) }
end,
],
"tf_nokogiri" => [
:text_formatter,
lambda { |corpus, tag| report("tf_nokogiri/#{tag}", corpus) { |post| Nokogiri.XML(post) } },
],
}.freeze

requested = ARGV.empty? ? VARIANTS.keys : ARGV
Expand Down
28 changes: 27 additions & 1 deletion lib/markbridge/parsers/html/handler_registry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,10 @@ def overlay(tag_names)
# @param tag_name [String]
# @return [BaseHandler, nil]
def [](tag_name)
@handlers[tag_name.to_s.downcase]
# Keys are always downcased strings and Nokogiri's HTML parser
# yields downcased element names, so the fetch hits directly on
# the hot path; the block normalizes only unusual lookups.
@handlers.fetch(tag_name) { @handlers[tag_name.to_s.downcase] }
end

# Create the default handler registry with common HTML tags
Expand Down Expand Up @@ -149,6 +152,29 @@ def self.build_from_default
yield(registry) if block_given?
registry
end

# Shared, deep-frozen default registry for the no-customization
# fast path. Built once per process; {Parser} falls back to it
# when no +handlers:+ are given, skipping the full registry
# construction on every parse. Handlers are stateless after
# construction, so sharing is safe across parsers and threads.
# Callers who want to mutate the tag sets must build their own
# {.default}.
#
# @return [HandlerRegistry] the same frozen instance on every call
def self.shared_default
@shared_default ||= default.freeze
end

# Freeze the registry together with its internal collections so
# that mutation of a shared instance fails loudly instead of
# silently changing state visible to every parser.
def freeze
@handlers.freeze
@block_level_tags.freeze
@whitespace_preserving_tags.freeze
super
end
end
end
end
Expand Down
67 changes: 54 additions & 13 deletions lib/markbridge/parsers/html/parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ def initialize(handlers: nil, &block)
if block_given?
HandlerRegistry.build_from_default(&block)
else
handlers || HandlerRegistry.default
handlers || HandlerRegistry.shared_default
end
@unknown_tags = Hash.new(0)
end
Expand Down Expand Up @@ -64,6 +64,12 @@ def parse(input)
# Create root AST document
document = AST::Document.new

# Whitespace preservation is tracked during descent instead of
# walking node.ancestors per text node; seed the counter from
# the parse root's real ancestry so pre-parsed subtrees inside
# a <pre> keep their semantics.
@preserve_depth = initial_preserve_depth(doc)

# Process all nodes
children.each { |node| process_node(node, document) }
trim_trailing_whitespace(document)
Expand Down Expand Up @@ -92,16 +98,29 @@ def process_node(node, parent)
end
end

# Anything WHITESPACE_RUN would actually change: a non-space
# whitespace character, or two-plus consecutive spaces. Prose with
# only single spaces passes through without the gsub copy.
COLLAPSIBLE_WHITESPACE = /[\t\r\n\f]| {2}/
private_constant :COLLAPSIBLE_WHITESPACE

# Anything String#rstrip would remove from the end (ASCII
# whitespace or NUL), or the empty string.
TRAILING_STRIPPABLE = /[\0\t\n\v\f\r ]\z|\A\z/
private_constant :TRAILING_STRIPPABLE

# Process a text node
# @param node [Nokogiri::XML::Text]
# @param parent [AST::Element]
def process_text_node(node, parent)
if preserves_whitespace?(node)
parent << AST::Text.new(node.text)
text = node.text

if @preserve_depth.positive?
parent << AST::Text.new(text)
return
end

text = node.text.gsub(WHITESPACE_RUN, " ")
text = text.gsub(WHITESPACE_RUN, " ") if text.match?(COLLAPSIBLE_WHITESPACE)
# Drop leading whitespace at the start of an element's content,
# matching the browser rule that whitespace at the beginning of a
# block (or before any inline content) is collapsed away.
Expand All @@ -125,6 +144,21 @@ def process_element_node(node, parent)
# still collapse the whitespace before them.
trim_trailing_whitespace(parent) if @handlers.block_level_tags.include?(tag_name)

preserving = @handlers.whitespace_preserving_tags.include?(tag_name)
@preserve_depth += 1 if preserving

dispatch_element(node, tag_name, parent, preserving)

@preserve_depth -= 1 if preserving
end

# Dispatch an element to its handler (or the unknown-tag path) and
# process its children.
# @param node [Nokogiri::XML::Element]
# @param tag_name [String]
# @param parent [AST::Element]
# @param preserving [Boolean] whether this tag preserves whitespace
def dispatch_element(node, tag_name, parent, preserving)
handler = @handlers[tag_name]
return handle_unknown_tag(node, parent) unless handler

Expand All @@ -134,9 +168,7 @@ def process_element_node(node, parent)
return unless ast_element

process_children(node, ast_element)
unless @handlers.whitespace_preserving_tags.include?(tag_name)
trim_trailing_whitespace(ast_element)
end
trim_trailing_whitespace(ast_element) unless preserving
end

# Handle unknown tag by tracking it and ignoring the wrapper
Expand All @@ -148,12 +180,16 @@ def handle_unknown_tag(node, parent)
process_children(node, parent)
end

# Whether `node` is inside a tag that preserves source whitespace.
# @param node [Nokogiri::XML::Node]
# @return [Boolean]
def preserves_whitespace?(node)
node.ancestors.any? do |ancestor|
@handlers.whitespace_preserving_tags.include?(ancestor.name)
# Number of whitespace-preserving elements enclosing the parse
# root, the root itself included. Computed once per parse; from
# there the counter is maintained during descent. Documents and
# fragments count themselves harmlessly — their #name
# ("document", "#document-fragment") never matches a tag set.
# @param root [Nokogiri::XML::Node]
# @return [Integer]
def initial_preserve_depth(root)
[root, *root.ancestors].count do |node|
@handlers.whitespace_preserving_tags.include?(node.name)
end
end

Expand All @@ -174,6 +210,11 @@ def trim_trailing_whitespace(element)
last = element.children.last
return unless last.instance_of?(AST::Text)

# Fast exit when there is nothing to strip: no trailing byte
# rstrip would remove (ASCII whitespace or NUL), and not the
# empty string (which the pop below drops entirely).
return unless last.text.match?(TRAILING_STRIPPABLE)

trimmed = last.text.rstrip
element.children.pop
element << AST::Text.new(trimmed) unless trimmed.empty?
Expand Down
24 changes: 23 additions & 1 deletion lib/markbridge/parsers/text_formatter/handler_registry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,25 @@ def self.build_from_default
default.tap { |registry| yield registry if block_given? }
end

# Shared, deep-frozen default registry for the no-customization
# fast path. Built once per process; {Parser} falls back to it
# when no +handlers:+ are given, skipping the full registry
# construction on every parse. Handlers are stateless after
# construction, so sharing is safe across parsers and threads.
#
# @return [HandlerRegistry] the same frozen instance on every call
def self.shared_default
@shared_default ||= default.freeze
end

# Freeze the registry together with its internal Hash so that
# registration on a shared instance fails loudly instead of
# silently mutating state visible to every parser.
def freeze
@mappings.freeze
super
end

def initialize
@mappings = {}
end
Expand All @@ -47,7 +66,10 @@ def register(element_name, handler)
# @param element_name [String]
# @return [#process, nil]
def [](element_name)
@mappings[element_name.upcase]
# Keys are always upcased and s9e/TextFormatter stores its tag
# names in uppercase, so the fetch hits directly on the hot
# path; the block normalizes only unusual lookups.
@mappings.fetch(element_name) { @mappings[element_name.upcase] }
end

# Replace the handler bound to one or more element names by
Expand Down
13 changes: 10 additions & 3 deletions lib/markbridge/parsers/text_formatter/parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def initialize(handlers: nil, &block)
if block_given?
HandlerRegistry.build_from_default(&block)
else
handlers || HandlerRegistry.default
handlers || HandlerRegistry.shared_default
end
@unknown_tags = Hash.new(0)
end
Expand Down Expand Up @@ -75,6 +75,13 @@ def parse(input)
AST::Document.new << AST::Text.new(input)
end

# <s>/<e> hold the original BBCode markup for unparsing; <t>/<r>
# are the plain-text/rich-text roots. Frozen constants — the
# membership checks run once per element node.
MARKUP_PRESERVATION_TAGS = %w[s e].freeze
ROOT_TAGS = %w[t r].freeze
private_constant :MARKUP_PRESERVATION_TAGS, :ROOT_TAGS

# Process children of an XML element (public for handler access)
# @param element [Nokogiri::XML::Element]
# @param ast_parent [AST::Element]
Expand Down Expand Up @@ -102,10 +109,10 @@ def process_element(element, ast_parent)
tag_name = element.name

# Skip markup preservation elements and their content (used for unparsing)
return if %w[s e].include?(tag_name)
return if MARKUP_PRESERVATION_TAGS.include?(tag_name)

# Handle root nodes
return process_children(element, ast_parent) if %w[t r].include?(tag_name)
return process_children(element, ast_parent) if ROOT_TAGS.include?(tag_name)

# Handle line breaks
if tag_name == "br"
Expand Down
Loading