From 5eaafe2df52f94c58c8f82a0f8e0906b512c676c Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Thu, 9 Jul 2026 00:47:08 +0200 Subject: [PATCH 1/3] DEV: Add TextFormatter corpus and nokogiri-share bench variants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The s9e/TextFormatter corpus mirrors the other formats (ASCII + multibyte, mixed constructs, / markup preservation). New html_walk/html_nokogiri and tf_walk/tf_nokogiri variants separate nokogiri's C parse from the Ruby tree walk by pre-parsing the corpus and feeding the parsers Nokogiri nodes. Baseline these produced: the HTML walk costs 36.7 µs/post against 21.7 µs for the nokogiri parse itself — the Ruby side is the bigger half. The allocation profile blames node.text.gsub copies per text node, a node.ancestors walk per text node, and rstrip+pop+re-add churn per element. TextFormatter is much leaner (7.6 µs walk); its only notable waste is per-call registry construction and two array literals allocated per element. --- bench/alloc_profile.rb | 28 ++++++++++++++++++++++++++ bench/corpus.rb | 40 +++++++++++++++++++++++++++++++++++++ bench/corpus_bench.rb | 45 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 112 insertions(+), 1 deletion(-) diff --git a/bench/alloc_profile.rb b/bench/alloc_profile.rb index 82b7e1f3..ae63cd92 100644 --- a/bench/alloc_profile.rb +++ b/bench/alloc_profile.rb @@ -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 @@ -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" diff --git a/bench/corpus.rb b/bench/corpus.rb index ce4a6e48..7cd86f04 100644 --- a/bench/corpus.rb +++ b/bench/corpus.rb @@ -169,6 +169,38 @@ def html_construct(rng, words) end end + # An s9e/TextFormatter XML post (the phpBB 3.2+ storage format): a + # single root, inline tags with / markup-preservation + # children,
line breaks. + def text_formatter_post(rng, words) + parts = [] + parts << sentence(rng, words, 10) + parts << "[b]#{sentence(rng, words, 4)}[/b] and " \ + "[i]#{sentence(rng, words, 3)}[/i]" + parts << sentence(rng, words, 12) + parts << text_formatter_construct(rng, words) + parts << sentence(rng, words, 10) + "#{parts.join("

")}
" + end + + def text_formatter_construct(rng, words) + case rng.rand(5) + when 0 + "[quote=alice]#{sentence(rng, words, 8)}[/quote]" + when 1 + items = Array.new(3 + rng.rand(3)) { "
  • [*]#{sentence(rng, words, 5)}
  • " }.join + "[list]#{items}[/list]" + when 2 + "[code]x = #{rng.rand(100)}[/code]" + when 3 + url = "https://example.com/#{rng.rand(1000)}" + "[url=#{url}]#{sentence(rng, words, 3)}[/url]" + when 4 + "[s]#{sentence(rng, words, 3)}[/s] und " \ + "[u]#{sentence(rng, words, 2)}[/u]" + end + end + def build(words, count: 200, seed: 42, kind: :post) rng = Random.new(seed) Array.new(count) { public_send(kind, rng, words) } @@ -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 diff --git a/bench/corpus_bench.rb b/bench/corpus_bench.rb index 0aeddaae..b1b440db 100644 --- a/bench/corpus_bench.rb +++ b/bench/corpus_bench.rb @@ -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 ...] # @@ -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 @@ -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 From e7df37d4ed868c2ae7f1ac84dffe9b1cadbd68e0 Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Thu, 9 Jul 2026 00:47:09 +0200 Subject: [PATCH 2/3] PERF: Cut the HTML tree walk's per-node overhead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The walk cost more than nokogiri's actual parse (36.7 vs 21.7 µs/post on the corpus). Four changes, profiler-ranked: - Whitespace preservation is now tracked with a counter during descent instead of walking node.ancestors for every text node. The counter is seeded once per parse from the parse root's real ancestry, so pre-parsed subtrees inside a
     keep their exact semantics (new
      specs pin that, plus custom-preserving-tag nesting — the built-in
      preserving tags use RawHandler and never walk children).
    - The default HandlerRegistry is built once per process (shared,
      deep-frozen — same pattern as the other parsers). All three internal
      collections are publicly reachable, so every freeze line is pinned
      by a raising spec.
    - process_text_node only pays the whitespace-collapsing gsub when the
      text contains something collapsible; single-space prose passes
      through without the copy.
    - trim_trailing_whitespace exits early when the last text node has no
      strippable tail, instead of rstrip+pop+re-add on every element.
    
    The two gsub/rstrip gates are output-equivalent fast paths and join
    the mutant ignore list with the registry fetch lookups; everything
    else is at 100% with killing specs.
    
    Corpus bench: html_walk 36.7 → 16.9 µs/post (allocations 285 → 143),
    full HTML conversion 87.9 → 60.8 ASCII and 117.3 → 81.3 multibyte.
    Output verified unchanged against the old implementation over all 400
    corpus posts plus Unicode edge cases (zero diff, unknown_tags
    included).
    ---
     .../parsers/html/handler_registry.rb          | 28 ++++++-
     lib/markbridge/parsers/html/parser.rb         | 67 +++++++++++++----
     mutant.yml                                    | 20 ++++-
     .../parsers/html/handler_registry_spec.rb     | 46 ++++++++++++
     .../markbridge/parsers/html/parser_spec.rb    | 73 ++++++++++++++++++-
     5 files changed, 214 insertions(+), 20 deletions(-)
    
    diff --git a/lib/markbridge/parsers/html/handler_registry.rb b/lib/markbridge/parsers/html/handler_registry.rb
    index 3b29f129..dbcc26a1 100644
    --- a/lib/markbridge/parsers/html/handler_registry.rb
    +++ b/lib/markbridge/parsers/html/handler_registry.rb
    @@ -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
    @@ -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
    diff --git a/lib/markbridge/parsers/html/parser.rb b/lib/markbridge/parsers/html/parser.rb
    index 47de03a5..ad92a339 100644
    --- a/lib/markbridge/parsers/html/parser.rb
    +++ b/lib/markbridge/parsers/html/parser.rb
    @@ -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
    @@ -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 
     keep their semantics.
    +          @preserve_depth = initial_preserve_depth(doc)
    +
               # Process all nodes
               children.each { |node| process_node(node, document) }
               trim_trailing_whitespace(document)
    @@ -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.
    @@ -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
     
    @@ -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
    @@ -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
     
    @@ -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?
    diff --git a/mutant.yml b/mutant.yml
    index 9810e517..4152b431 100644
    --- a/mutant.yml
    +++ b/mutant.yml
    @@ -48,11 +48,14 @@ matcher:
         # differ in per-token allocations. The fetch exists purely to skip
         # the `to_s.downcase` copy on the hot path; no test can observe
         # the difference through the public API. Bucket A.
    -    # MediaWiki::InlineTagRegistry#[] has the identical fetch-with-
    -    # downcasing-fallback shape (the inline parser downcases tag names
    -    # before lookup) and the same equivalence.
    +    # MediaWiki::InlineTagRegistry#[], HTML::HandlerRegistry#[], and
    +    # TextFormatter::HandlerRegistry#[] have the identical fetch-with-
    +    # normalizing-fallback shape (their tag sources already produce
    +    # keys in the stored case) and the same equivalence.
         - Markbridge::Parsers::BBCode::HandlerRegistry#[]
         - Markbridge::Parsers::MediaWiki::InlineTagRegistry#[]
    +    - Markbridge::Parsers::HTML::HandlerRegistry#[]
    +    - Markbridge::Parsers::TextFormatter::HandlerRegistry#[]
     
         # HandlerRegistry#freeze deep-freezes three internal collections.
         # `register` writes @handlers first and raises FrozenError there,
    @@ -75,6 +78,17 @@ matcher:
         - Markbridge::Parsers::BBCode::Parser#normalize_line_endings
         - Markbridge::Parsers::MediaWiki::Parser#normalize_line_endings
     
    +    # HTML::Parser fast-path guards with output-equivalent slow paths:
    +    # process_text_node's COLLAPSIBLE_WHITESPACE gate (forcing the gsub
    +    # on single-space prose rebuilds an equal string) and
    +    # trim_trailing_whitespace's TRAILING_STRIPPABLE gate (forcing the
    +    # rstrip+pop+re-add on an untrimmed text produces a value-equal
    +    # AST). Both guards exist purely to avoid per-node copies; the
    +    # behavioral branches around them are pinned by the whitespace-
    +    # handling specs. Bucket A.
    +    - Markbridge::Parsers::HTML::Parser#process_text_node
    +    - Markbridge::Parsers::HTML::Parser#trim_trailing_whitespace
    +
         # RenderingInterface#apply_markers' EDGE_WHITESPACE fast path.
         # The capture-group sub handles flush content identically (its
         # edge captures match empty strings), so mutations that disable
    diff --git a/spec/unit/markbridge/parsers/html/handler_registry_spec.rb b/spec/unit/markbridge/parsers/html/handler_registry_spec.rb
    index d1a86466..8ed558e4 100644
    --- a/spec/unit/markbridge/parsers/html/handler_registry_spec.rb
    +++ b/spec/unit/markbridge/parsers/html/handler_registry_spec.rb
    @@ -259,4 +259,50 @@
           expect(registry.overlay("a") { |p| p }).to be(registry)
         end
       end
    +
    +  describe ".shared_default" do
    +    it "returns the same instance on every call" do
    +      expect(described_class.shared_default).to be(described_class.shared_default)
    +    end
    +
    +    it "is frozen" do
    +      expect(described_class.shared_default).to be_frozen
    +    end
    +
    +    it "resolves the default tags" do
    +      expect(described_class.shared_default["b"]).to be_a(
    +        Markbridge::Parsers::HTML::Handlers::SimpleHandler,
    +      )
    +    end
    +
    +    it "is a different instance from .default" do
    +      expect(described_class.shared_default).not_to be(described_class.default)
    +    end
    +  end
    +
    +  describe "#freeze" do
    +    it "makes register raise instead of silently mutating shared state" do
    +      frozen = described_class.new.freeze
    +
    +      expect { frozen.register("b", handler) }.to raise_error(FrozenError)
    +    end
    +
    +    it "freezes the block-level tag set" do
    +      frozen = described_class.new.freeze
    +
    +      expect { frozen.block_level_tags << "my-block" }.to raise_error(FrozenError)
    +    end
    +
    +    it "freezes the whitespace-preserving tag set" do
    +      frozen = described_class.new.freeze
    +
    +      expect { frozen.whitespace_preserving_tags << "my-pre" }.to raise_error(FrozenError)
    +    end
    +
    +    it "returns self" do
    +      registry = described_class.new
    +
    +      expect(registry.freeze).to be(registry)
    +    end
    +  end
     end
    diff --git a/spec/unit/markbridge/parsers/html/parser_spec.rb b/spec/unit/markbridge/parsers/html/parser_spec.rb
    index a862da9f..1685b1b5 100644
    --- a/spec/unit/markbridge/parsers/html/parser_spec.rb
    +++ b/spec/unit/markbridge/parsers/html/parser_spec.rb
    @@ -363,10 +363,10 @@ def to_s
           expect(doc.children[0].children[0].text).to eq("a   b")
         end
     
    -    it "preserves whitespace inside c   d")
    +
    +      # Both texts land in the same parent and merge into one node:
    +      # "a   b" kept verbatim, "c   d" collapsed.
    +      expect(doc.children[0].text).to eq("a   bc d")
    +    end
    +
    +    it "preserves whitespace when the parse root itself is a 
     element" do
    +      pre = Nokogiri::HTML.fragment("
    a   b
    ").at_css("pre") + doc = parser.parse(pre) + + expect(doc.children[0].text).to eq("a b") + end + + it "preserves whitespace when the parse root sits inside a
     ancestor" do
    +      span = Nokogiri::HTML.fragment("
    a   b
    ").at_css("span") + doc = parser.parse(span) + + expect(doc.children[0].text).to eq("a b") + end + + it "does not preserve whitespace for a parse root under non-preserving ancestors" do + # Guards the seeded counter: only whitespace-preserving ancestors + # may count, not just any ancestors. + span = Nokogiri::HTML.fragment("
    a b
    ").at_css("span") + doc = parser.parse(span) + + expect(doc.children[0].text).to eq("a b") + end + + it "keeps preserving whitespace after a non-preserving child element closes" do + # A custom preserving tag without a handler is the only way to get + # the parser to walk children in preserving mode (pre/code/tt use + # RawHandler, which flattens content without walking). + registry = Markbridge::Parsers::HTML::HandlerRegistry.default + registry.whitespace_preserving_tags << "poem" + custom_parser = described_class.new(handlers: registry) + + doc = custom_parser.parse("xa b") + + poem_children = doc.children + expect(poem_children[0]).to be_a(Markbridge::AST::Bold) + expect(poem_children[1].text).to eq("a b") + end + + it "keeps preserving whitespace after a nested preserving element closes" do + registry = Markbridge::Parsers::HTML::HandlerRegistry.default + registry.whitespace_preserving_tags << "poem" + custom_parser = described_class.new(handlers: registry) + + doc = custom_parser.parse("a bc d") + + expect(doc.children[0].text).to eq("a bc d") + end + it "preserves whitespace inside " do doc = parser.parse("a b") From 7eac804167aab27664f7a2dc016168aa7514daba Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Thu, 9 Jul 2026 00:47:21 +0200 Subject: [PATCH 3/3] PERF: Trim TextFormatter per-parse and per-element overhead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Small wins — the TextFormatter walk was already lean: - The default HandlerRegistry is built once per process (shared, deep-frozen), instead of a full construction per parse call. - The %w[s e] / %w[t r] literals allocated two Arrays per element node; they're frozen constants now. - Registry lookups fetch directly (s9e stores tag names uppercase, so the fetch hits without the upcase copy); the fallback keeps case-insensitive lookups working. Corpus bench: tf_fresh 35.4 → 29.8 µs/post ASCII, tf_parse allocations drop by the per-parse registry share. Output verified unchanged (zero diff over the corpus + Unicode edge cases). --- .../text_formatter/handler_registry.rb | 24 ++++++++++++- .../parsers/text_formatter/parser.rb | 13 +++++-- .../text_formatter/handler_registry_spec.rb | 36 +++++++++++++++++++ 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/lib/markbridge/parsers/text_formatter/handler_registry.rb b/lib/markbridge/parsers/text_formatter/handler_registry.rb index 8f60dac2..3073b7f8 100644 --- a/lib/markbridge/parsers/text_formatter/handler_registry.rb +++ b/lib/markbridge/parsers/text_formatter/handler_registry.rb @@ -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 @@ -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 diff --git a/lib/markbridge/parsers/text_formatter/parser.rb b/lib/markbridge/parsers/text_formatter/parser.rb index 67104e03..566a4afc 100644 --- a/lib/markbridge/parsers/text_formatter/parser.rb +++ b/lib/markbridge/parsers/text_formatter/parser.rb @@ -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 @@ -75,6 +75,13 @@ def parse(input) AST::Document.new << AST::Text.new(input) end + # / hold the original BBCode markup for unparsing; / + # 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] @@ -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" diff --git a/spec/unit/markbridge/parsers/text_formatter/handler_registry_spec.rb b/spec/unit/markbridge/parsers/text_formatter/handler_registry_spec.rb index a29d15a2..7ad47000 100644 --- a/spec/unit/markbridge/parsers/text_formatter/handler_registry_spec.rb +++ b/spec/unit/markbridge/parsers/text_formatter/handler_registry_spec.rb @@ -367,4 +367,40 @@ def handler_for(name) expect(registry.overlay("URL") { |p| p }).to be(registry) end end + + describe ".shared_default" do + it "returns the same instance on every call" do + expect(described_class.shared_default).to be(described_class.shared_default) + end + + it "is frozen" do + expect(described_class.shared_default).to be_frozen + end + + it "resolves the default tags" do + expect(described_class.shared_default["B"]).to be_a( + Markbridge::Parsers::TextFormatter::Handlers::SimpleHandler, + ) + end + + it "is a different instance from .default" do + expect(described_class.shared_default).not_to be(described_class.default) + end + end + + describe "#freeze" do + it "makes register raise instead of silently mutating shared state" do + handler = + Markbridge::Parsers::TextFormatter::Handlers::SimpleHandler.new(Markbridge::AST::Bold) + frozen = described_class.new.freeze + + expect { frozen.register("MARK", handler) }.to raise_error(FrozenError) + end + + it "returns self" do + registry = described_class.new + + expect(registry.freeze).to be(registry) + end + end end