Skip to content

Commit eb661ae

Browse files
authored
perf(renderer): reject autolink candidates on the second byte before prefix checks (#520)
The first-byte index for the default patterns (http://, https://) keys on 'h', which is one of the most frequent letters in English prose - every "the", "here", "hush" hit ran the word-boundary check plus up to two case-insensitive multi-byte prefix comparisons. Record the lowercased second byte all patterns sharing a first byte agree on (ANY_SECOND when they disagree or the pattern is one byte) and reject non-candidates with a single comparison right after the memchr hit. Un-instrumented parse+render loop on the 1 MB JS-harness sample: p50 5.53 -> 5.43 ms (-1.8%). Text nodes with no real URL - the common case - now cost one byte compare per frequent-letter hit instead of a prefix walk. Four new unit tests pin the filter: candidate-heavy prose stays plain (including a trailing-candidate-byte text), uppercase prefixes still match, conflicting second bytes disable the filter, and one-byte custom patterns bypass it.
1 parent 251d4da commit eb661ae

2 files changed

Lines changed: 101 additions & 1 deletion

File tree

crates/ox_content_renderer/src/html/autolink.rs

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,18 @@ pub(super) struct FirstByteIndex {
1818
needles: [u8; 3],
1919
needle_len: usize,
2020
overflow: bool,
21+
/// Per-first-byte second-byte filter: `second[b]` holds the lowercased
22+
/// byte every pattern starting with `b` continues with, or `ANY_SECOND`
23+
/// when patterns disagree (or the pattern is a single byte). Prose hits
24+
/// on a frequent first byte — `h` matches "the", "here", … — are
25+
/// rejected with one comparison instead of full prefix checks.
26+
second: [u8; 256],
2127
}
2228

29+
/// Sentinel in `FirstByteIndex::second`: no single second byte filters
30+
/// candidates starting with this first byte.
31+
const ANY_SECOND: u8 = 0xFF;
32+
2333
impl FirstByteIndex {
2434
/// Builds a compact candidate-start index for the configured patterns.
2535
///
@@ -33,11 +43,22 @@ impl FirstByteIndex {
3343
let mut needles = [0u8; 3];
3444
let mut needle_len = 0usize;
3545
let mut overflow = false;
46+
let mut second = [0u8; 256];
3647
for pat in patterns {
3748
let Some(&first) = pat.as_bytes().first() else {
3849
continue;
3950
};
51+
// 0 = unset, ANY_SECOND = conflicting/absent, else the required
52+
// lowercased second byte. The default patterns (`http://`,
53+
// `https://`) agree on `t`, so `h` candidates filter on it.
54+
let pat_second = pat.as_bytes().get(1).map_or(ANY_SECOND, u8::to_ascii_lowercase);
4055
for cand in [first.to_ascii_lowercase(), first.to_ascii_uppercase()] {
56+
let entry = &mut second[cand as usize];
57+
*entry = match *entry {
58+
0 => pat_second,
59+
prev if prev == pat_second => prev,
60+
_ => ANY_SECOND,
61+
};
4162
if table[cand as usize] {
4263
continue;
4364
}
@@ -52,7 +73,22 @@ impl FirstByteIndex {
5273
if needle_len > needles.len() {
5374
overflow = true;
5475
}
55-
Self { table, needles, needle_len, overflow }
76+
Self { table, needles, needle_len, overflow, second }
77+
}
78+
79+
/// True when the byte after a first-byte hit rules the candidate out
80+
/// without running any full prefix comparison.
81+
#[inline]
82+
fn rejects_second(&self, first: u8, after: Option<&u8>) -> bool {
83+
let expected = self.second[first as usize];
84+
if expected == ANY_SECOND {
85+
return false;
86+
}
87+
match after {
88+
Some(&byte) => byte.to_ascii_lowercase() != expected,
89+
// Pattern needs a second byte but the text ends here.
90+
None => true,
91+
}
5692
}
5793

5894
/// Byte offset of the next possible pattern start within `hay`, or `None`.
@@ -97,6 +133,12 @@ pub(super) fn find_autolink_match(
97133
while base < bytes.len() {
98134
let rel = index.next(&bytes[base..])?;
99135
let i = base + rel;
136+
// One-byte look-ahead: most prose hits on a frequent candidate byte
137+
// ("the", "words"…) die here before any prefix comparison.
138+
if index.rejects_second(bytes[i], bytes.get(i + 1)) {
139+
base = i + 1;
140+
continue;
141+
}
100142
// Word boundary: the previous byte must not be ASCII alphanumeric.
101143
let is_boundary = i == 0 || !bytes[i - 1].is_ascii_alphanumeric();
102144
if is_boundary {

crates/ox_content_renderer/src/html/tests/autolink.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,3 +145,61 @@ fn test_autolink_escapes_query_string_safely() {
145145
let html = renderer.render(&doc);
146146
insta::assert_snapshot!(html);
147147
}
148+
149+
#[test]
150+
fn test_autolink_prose_full_of_candidate_first_bytes_stays_plain() {
151+
let allocator = Allocator::new();
152+
// Every `h` here hits the first-byte index; the second-byte filter
153+
// must reject them all without producing links (and without panicking
154+
// when the candidate is the last byte of the text).
155+
let doc =
156+
Parser::new(&allocator, "the theory holds hereabouts, hush; ends with h").parse().unwrap();
157+
let mut renderer = HtmlRenderer::with_options(HtmlRendererOptions {
158+
autolink_urls: true,
159+
..Default::default()
160+
});
161+
let html = renderer.render(&doc);
162+
assert!(!html.contains("<a "), "unexpected link in: {html}");
163+
}
164+
165+
#[test]
166+
fn test_autolink_uppercase_prefix_still_matches_through_filter() {
167+
let allocator = Allocator::new();
168+
let doc = Parser::new(&allocator, "loud HTTPS://CAPS.test here").parse().unwrap();
169+
let mut renderer = HtmlRenderer::with_options(HtmlRendererOptions {
170+
autolink_urls: true,
171+
..Default::default()
172+
});
173+
let html = renderer.render(&doc);
174+
assert_eq!(html.matches("<a ").count(), 1, "missing uppercase autolink in: {html}");
175+
}
176+
177+
#[test]
178+
fn test_autolink_conflicting_second_bytes_disable_the_filter() {
179+
let allocator = Allocator::new();
180+
// Two patterns share the first byte but disagree on the second; the
181+
// filter must fall back to full prefix checks so both still match.
182+
let doc = Parser::new(&allocator, "a http://one.test b hxxp://two.test c").parse().unwrap();
183+
let mut renderer = HtmlRenderer::with_options(HtmlRendererOptions {
184+
autolink_urls: true,
185+
autolink_patterns: vec!["http://".to_string(), "hxxp://".to_string()],
186+
..Default::default()
187+
});
188+
let html = renderer.render(&doc);
189+
assert_eq!(html.matches("<a ").count(), 2, "expected both schemes in: {html}");
190+
}
191+
192+
#[test]
193+
fn test_autolink_single_byte_pattern_bypasses_the_filter() {
194+
let allocator = Allocator::new();
195+
// A one-byte pattern has no second byte to filter on; candidates must
196+
// go straight to the prefix check.
197+
let doc = Parser::new(&allocator, "go htail now").parse().unwrap();
198+
let mut renderer = HtmlRenderer::with_options(HtmlRendererOptions {
199+
autolink_urls: true,
200+
autolink_patterns: vec!["h".to_string()],
201+
..Default::default()
202+
});
203+
let html = renderer.render(&doc);
204+
assert_eq!(html.matches("<a ").count(), 1, "single-byte pattern should link in: {html}");
205+
}

0 commit comments

Comments
 (0)