Skip to content

Commit a5b2815

Browse files
committed
Fix apiQuery splice: use match index instead of String.replace()
String.replace(raw, term) replaces the first *substring* occurrence of raw, which can target the wrong position when the same text appears earlier in the query as a non-token prefix (e.g. '/useState/iSomething /useState/i' — the string '/useState/i' is a substring of the first token but the actual matched token is the second one). Fix: - Add `index: number` to RegexToken, storing the exact start position of the token within the original query (computed from m.index + leading-space offset). - Use slice-based splice in buildApiQuery(): q.slice(0, index) + term + q.slice(index + raw.length) This replaces only the boundary-validated token at its exact location, regardless of whether the same raw text appears elsewhere. Regression test added: '/useState/iSomething /useState/i' → '/useState/iSomething useState'
1 parent 0cde999 commit a5b2815

File tree

2 files changed

+24
-6
lines changed

2 files changed

+24
-6
lines changed

src/regex.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,16 @@ describe("buildApiQuery — qualifier preservation", () => {
153153
expect(r.regexFilter).not.toBeNull();
154154
expect(r.warn).toBeUndefined();
155155
});
156+
157+
it("replaces the matched token when the same raw text appears earlier as a prefix substring", () => {
158+
// Regression: '/useState/i' is a substring of '/useState/iSomething' (not a
159+
// valid token — fails boundary check). q.replace(raw, term) would wrongly
160+
// replace the first occurrence inside the non-token prefix. The splice must
161+
// target only the index-validated token.
162+
const r = buildApiQuery("/useState/iSomething /useState/i");
163+
expect(r.apiQuery).toBe("/useState/iSomething useState");
164+
expect(r.regexFilter).not.toBeNull();
165+
});
156166
});
157167

158168
describe("buildApiQuery — flags", () => {

src/regex.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export function buildApiQuery(q: string): {
4141
// y (sticky) — makes RegExp.test() stateful via lastIndex, causing false
4242
// negatives when the same instance is reused across fragments.
4343
// Both are intentionally removed; all other flags (i, m, s, d, v, …) are kept.
44-
const { pattern, flags, raw } = token;
44+
const { pattern, flags, raw, index } = token;
4545
const safeFlags = flags.replace(/[gy]/g, "");
4646
let regexFilter: RegExp | null = null;
4747
try {
@@ -60,10 +60,12 @@ export function buildApiQuery(q: string): {
6060
// Derive the API search term from the regex pattern.
6161
const { term, warn } = extractApiTerm(pattern);
6262

63-
// Rebuild the API query by replacing the regex token in-place, preserving
64-
// all other characters byte-for-byte — including quoted phrases (e.g.
65-
// '"exact match" /pattern/') whose internal whitespace must not be split.
66-
const apiQuery = q.replace(raw, term).trim();
63+
// Rebuild the API query by splicing the derived term at the exact byte
64+
// position of the matched token. Using q.replace(raw, term) would replace the
65+
// first *substring* occurrence of `raw`, which may appear earlier in the query
66+
// as a non-token prefix (e.g. inside a longer word). Using the stored index
67+
// guarantees we replace only the boundary-validated token that was matched.
68+
const apiQuery = (q.slice(0, index) + term + q.slice(index + raw.length)).trim();
6769

6870
return { apiQuery, regexFilter, warn };
6971
}
@@ -77,6 +79,8 @@ interface RegexToken {
7779
pattern: string;
7880
/** Flags string (may be empty) */
7981
flags: string;
82+
/** Start index of `raw` within the original query string. */
83+
index: number;
8084
}
8185

8286
/**
@@ -97,10 +101,14 @@ function extractRegexToken(q: string): RegexToken | null {
97101
const m = q.match(/(?:^|\s)(\/(?:[^/\\\r\n]|\\.)+\/[gimsuydv]*)(?=$|\s)/);
98102
if (!m || !m[1]) return null;
99103
const raw = m[1].trim();
104+
// Compute the exact start position of the token within the original string.
105+
// m.index is where the full match starts; the token (group 1) may be preceded
106+
// by one whitespace character captured by (?:^|\s), hence the offset.
107+
const tokenStart = m.index! + m[0].length - m[1].length;
100108
const lastSlash = raw.lastIndexOf("/");
101109
const pattern = raw.slice(1, lastSlash);
102110
const flags = raw.slice(lastSlash + 1);
103-
return { raw, pattern, flags };
111+
return { raw, pattern, flags, index: tokenStart };
104112
}
105113

106114
/**

0 commit comments

Comments
 (0)