Skip to content

Commit ff0eb41

Browse files
committed
PERF: Build the MediaWiki inline tag registry only once
Every Parser#parse call created an InlineParser without handlers, which built a full InlineTagRegistry.default — 10 registrations plus Entry objects, ~30 objects per parse. Same pattern and fix as the BBCode registries: a shared, deep-frozen instance built once per process, used only by the no-customization path. Entries are immutable Data objects, so sharing is safe across parsers and threads. Also mirrors two smaller BBCode-parser optimizations: registry lookups fetch directly instead of allocating a downcased key per probe, and normalize_line_endings skips the gsub copy when the input is already LF-only. Both fast paths are output-equivalent to their slow paths, so their subjects join the existing mutant ignore entries. Corpus bench: mw_parse 35 → 32 µs/post ASCII, mw_fresh 55 → 50; mw_parse allocations 211 → 167 objects/post.
1 parent 60b1f22 commit ff0eb41

5 files changed

Lines changed: 71 additions & 5 deletions

File tree

lib/markbridge/parsers/media_wiki/inline_parser.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ class InlineParser
4343
attr_reader :unknown_tags
4444

4545
def initialize(handlers: nil, depth: 0, unknown_tags: nil)
46-
@registry = handlers || InlineTagRegistry.default
46+
@registry = handlers || InlineTagRegistry.shared_default
4747
@depth = depth
4848
@unknown_tags = unknown_tags || Hash.new(0)
4949
end

lib/markbridge/parsers/media_wiki/inline_tag_registry.rb

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,11 @@ def register(tag_name, type, element_class)
4444
# @param tag_name [String]
4545
# @return [Entry, nil]
4646
def [](tag_name)
47-
@entries[tag_name.downcase]
47+
# Keys are always downcased and the inline parser downcases tag
48+
# names before lookup, so the fetch hits directly on the hot
49+
# path; the block normalizes only mixed-case lookups instead of
50+
# allocating a downcased copy per probe.
51+
@entries.fetch(tag_name) { @entries[tag_name.downcase] }
4852
end
4953

5054
# Check if a tag name is registered.
@@ -88,6 +92,25 @@ def self.build_from_default
8892
registry
8993
end
9094

95+
# Shared, deep-frozen default registry for the no-customization
96+
# fast path. Built once per process; {InlineParser} falls back to
97+
# it when no +handlers:+ are given, skipping the full registry
98+
# construction on every parse. Entries are immutable Data objects,
99+
# so sharing is safe across parsers and threads.
100+
#
101+
# @return [InlineTagRegistry] the same frozen instance on every call
102+
def self.shared_default
103+
@shared_default ||= default.freeze
104+
end
105+
106+
# Freeze the registry together with its internal Hash so that
107+
# registration on a shared instance fails loudly instead of
108+
# silently mutating state visible to every parser.
109+
def freeze
110+
@entries.freeze
111+
super
112+
end
113+
91114
private
92115

93116
VALID_TYPES = %i[raw formatting self_closing].freeze

lib/markbridge/parsers/media_wiki/parser.rb

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,15 @@ def parse(input)
5656

5757
private
5858

59+
LINE_ENDING_RE = /\r\n?|[\u2028\u2029]+/
60+
private_constant :LINE_ENDING_RE
61+
5962
# Normalize line endings (CR, CRLF, and Unicode separators).
6063
#
6164
# @param input [String]
62-
# @return [String]
65+
# @return [String] the input itself when already normalized (LF-only)
6366
def normalize_line_endings(input)
64-
input.gsub(/\r\n?|[\u2028\u2029]+/, "\n")
67+
input.match?(LINE_ENDING_RE) ? input.gsub(LINE_ENDING_RE, "\n") : input
6568
end
6669

6770
# Process all lines of input.

mutant.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,11 @@ matcher:
4848
# differ in per-token allocations. The fetch exists purely to skip
4949
# the `to_s.downcase` copy on the hot path; no test can observe
5050
# the difference through the public API. Bucket A.
51+
# MediaWiki::InlineTagRegistry#[] has the identical fetch-with-
52+
# downcasing-fallback shape (the inline parser downcases tag names
53+
# before lookup) and the same equivalence.
5154
- Markbridge::Parsers::BBCode::HandlerRegistry#[]
55+
- Markbridge::Parsers::MediaWiki::InlineTagRegistry#[]
5256

5357
# HandlerRegistry#freeze deep-freezes three internal collections.
5458
# `register` writes @handlers first and raises FrozenError there,
@@ -66,8 +70,10 @@ matcher:
6670
# `if LINE_ENDING_RE`, conditional drop) return an equal string
6771
# that differs only in object identity, which parse() never
6872
# exposes. Bucket A — the guard exists purely to avoid a
69-
# full-document copy per post.
73+
# full-document copy per post. Same guard, same equivalence in the
74+
# MediaWiki parser.
7075
- Markbridge::Parsers::BBCode::Parser#normalize_line_endings
76+
- Markbridge::Parsers::MediaWiki::Parser#normalize_line_endings
7177

7278
# RenderingInterface#apply_markers' EDGE_WHITESPACE fast path.
7379
# The capture-group sub handles flush content identically (its

spec/unit/markbridge/parsers/media_wiki/inline_tag_registry_spec.rb

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,4 +179,38 @@
179179
expect(registry["code"].element_class).to eq(Markbridge::AST::Bold)
180180
end
181181
end
182+
183+
describe ".shared_default" do
184+
it "returns the same instance on every call" do
185+
expect(described_class.shared_default).to be(described_class.shared_default)
186+
end
187+
188+
it "is frozen" do
189+
expect(described_class.shared_default).to be_frozen
190+
end
191+
192+
it "resolves the default tags" do
193+
expect(described_class.shared_default["code"].element_class).to eq(Markbridge::AST::Code)
194+
end
195+
196+
it "is a different instance from .default" do
197+
expect(described_class.shared_default).not_to be(described_class.default)
198+
end
199+
end
200+
201+
describe "#freeze" do
202+
it "makes register raise instead of silently mutating shared state" do
203+
frozen = described_class.new.freeze
204+
205+
expect { frozen.register("mark", :formatting, Markbridge::AST::Bold) }.to raise_error(
206+
FrozenError,
207+
)
208+
end
209+
210+
it "returns self" do
211+
registry = described_class.new
212+
213+
expect(registry.freeze).to be(registry)
214+
end
215+
end
182216
end

0 commit comments

Comments
 (0)