返回 DeepSeek-Reasonix
codeSearch.ts
根目录 / desktop / frontend / src / components / editors / codeSearch.ts
1 const WORD_CHARACTER_RE = /[\p{L}\p{N}_]/u;
2
3 export const MAX_SEARCH_MATCHES = 10_000;
4 export const MAX_REGEX_PATTERN_LENGTH = 256;
5 export const MAX_REGEX_SOURCE_LENGTH = 2 * 1024 * 1024;
6 export const REGEX_SEARCH_TIMEOUT_MS = 200;
7
8 export interface CodeSearchMatch {
9 lineIndex: number;
10 start: number;
11 end: number;
12 absoluteStart: number;
13 absoluteEnd: number;
14 }
15
16 export interface CodeSearchResult {
17 matches: CodeSearchMatch[];
18 truncated: boolean;
19 }
20
21 export type RegexSearchErrorCode =
22 | "invalid_pattern"
23 | "pattern_too_long"
24 | "source_too_large"
25 | "zero_length_unsupported"
26 | "multiline_unsupported"
27 | "timeout"
28 | "unavailable";
29
30 export interface RegexSearchRequest {
31 requestId: number;
32 source: string;
33 pattern: string;
34 caseSensitive: boolean;
35 wholeWord: boolean;
36 maxMatches: number;
37 }
38
39 export type RegexSearchResponse =
40 | {
41 requestId: number;
42 ok: true;
43 result: CodeSearchResult;
44 }
45 | {
46 requestId: number;
47 ok: false;
48 error: RegexSearchErrorCode;
49 detail?: string;
50 };
51
52 export function findCodeMatches(
53 source: string | readonly string[],
54 query: string,
55 caseSensitive = false,
56 wholeWord = false,
57 maxMatches = MAX_SEARCH_MATCHES,
58 ): CodeSearchResult {
59 if (!query) return emptySearchResult();
60
61 return collectMatches(
62 source,
63 new RegExp(escapeRegex(query), caseSensitive ? "gu" : "giu"),
64 wholeWord,
65 maxMatches,
66 );
67 }
68
69 export function findRegexCodeMatches(request: RegexSearchRequest): RegexSearchResponse {
70 if (request.pattern.length > MAX_REGEX_PATTERN_LENGTH) {
71 return {
72 requestId: request.requestId,
73 ok: false,
74 error: "pattern_too_long",
75 };
76 }
77 if (request.source.length > MAX_REGEX_SOURCE_LENGTH) {
78 return {
79 requestId: request.requestId,
80 ok: false,
81 error: "source_too_large",
82 };
83 }
84 if (!request.pattern) {
85 return {
86 requestId: request.requestId,
87 ok: true,
88 result: emptySearchResult(),
89 };
90 }
91
92 let pattern: RegExp;
93 try {
94 // Search anchors should continue to address individual source lines, while
95 // the full source is still scanned so newline-spanning matches can be
96 // rejected explicitly instead of disappearing silently.
97 pattern = new RegExp(request.pattern, request.caseSensitive ? "gmu" : "gimu");
98 } catch (error) {
99 return {
100 requestId: request.requestId,
101 ok: false,
102 error: "invalid_pattern",
103 detail: error instanceof Error ? error.message : String(error),
104 };
105 }
106
107 const result = collectRegexMatches(
108 request.source,
109 pattern,
110 request.wholeWord,
111 Math.max(0, Math.min(request.maxMatches, MAX_SEARCH_MATCHES)),
112 );
113 if ("error" in result) {
114 return {
115 requestId: request.requestId,
116 ok: false,
117 error: result.error,
118 };
119 }
120 return {
121 requestId: request.requestId,
122 ok: true,
123 result,
124 };
125 }
126
127 function collectMatches(
128 source: string | readonly string[],
129 pattern: RegExp,
130 wholeWord: boolean,
131 maxMatches: number,
132 rejectZeroLength?: false,
133 ): CodeSearchResult;
134 function collectMatches(
135 source: string | readonly string[],
136 pattern: RegExp,
137 wholeWord: boolean,
138 maxMatches: number,
139 rejectZeroLength: true,
140 ): CodeSearchResult | { error: "zero_length_unsupported" };
141 function collectMatches(
142 source: string | readonly string[],
143 pattern: RegExp,
144 wholeWord: boolean,
145 maxMatches: number,
146 rejectZeroLength = false,
147 ): CodeSearchResult | { error: "zero_length_unsupported" } {
148 const matches: CodeSearchMatch[] = [];
149 const lines = typeof source === "string" ? source.split("\n") : source;
150 let absoluteOffset = 0;
151 let sawZeroLengthMatch = false;
152 let sawNonEmptyMatch = false;
153
154 for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
155 const line = lines[lineIndex];
156 pattern.lastIndex = 0;
157 let match: RegExpExecArray | null;
158 while ((match = pattern.exec(line)) !== null) {
159 if (match[0].length === 0) {
160 sawZeroLengthMatch = true;
161 const nextCodePoint = line.codePointAt(pattern.lastIndex);
162 pattern.lastIndex += nextCodePoint != null && nextCodePoint > 0xffff ? 2 : 1;
163 continue;
164 }
165 sawNonEmptyMatch = true;
166
167 const start = match.index;
168 const end = start + match[0].length;
169 const startsInsideWord = start > 0 && isWordCharacter(codePointBefore(line, start));
170 const endsInsideWord = end < line.length && isWordCharacter(codePointAt(line, end));
171 if (!wholeWord || (!startsInsideWord && !endsInsideWord)) {
172 if (matches.length >= maxMatches) {
173 return { matches, truncated: true };
174 }
175 matches.push({
176 lineIndex,
177 start,
178 end,
179 absoluteStart: absoluteOffset + start,
180 absoluteEnd: absoluteOffset + end,
181 });
182 }
183 }
184 absoluteOffset += line.length + 1;
185 }
186
187 if (rejectZeroLength && sawZeroLengthMatch && !sawNonEmptyMatch) {
188 return { error: "zero_length_unsupported" };
189 }
190 return { matches, truncated: false };
191 }
192
193 function collectRegexMatches(
194 source: string,
195 pattern: RegExp,
196 wholeWord: boolean,
197 maxMatches: number,
198 ): CodeSearchResult | { error: "zero_length_unsupported" | "multiline_unsupported" } {
199 const matches: CodeSearchMatch[] = [];
200 const lineStarts = [0];
201 for (let index = 0; index < source.length; index += 1) {
202 if (source[index] === "\n") lineStarts.push(index + 1);
203 }
204
205 pattern.lastIndex = 0;
206 let sawZeroLengthMatch = false;
207 let sawNonEmptyMatch = false;
208 let match: RegExpExecArray | null;
209 while ((match = pattern.exec(source)) !== null) {
210 if (match[0].length === 0) {
211 sawZeroLengthMatch = true;
212 const nextCodePoint = source.codePointAt(pattern.lastIndex);
213 pattern.lastIndex += nextCodePoint != null && nextCodePoint > 0xffff ? 2 : 1;
214 continue;
215 }
216 sawNonEmptyMatch = true;
217
218 const start = match.index;
219 const end = start + match[0].length;
220 if (source.slice(start, end).includes("\n")) {
221 return { error: "multiline_unsupported" };
222 }
223
224 const lineIndex = findLineIndex(lineStarts, start);
225 const lineStart = lineStarts[lineIndex] ?? 0;
226 const startsInsideWord = start > 0 && isWordCharacter(codePointBefore(source, start));
227 const endsInsideWord = end < source.length && isWordCharacter(codePointAt(source, end));
228 if (!wholeWord || (!startsInsideWord && !endsInsideWord)) {
229 if (matches.length >= maxMatches) {
230 return { matches, truncated: true };
231 }
232 matches.push({
233 lineIndex,
234 start: start - lineStart,
235 end: end - lineStart,
236 absoluteStart: start,
237 absoluteEnd: end,
238 });
239 }
240 }
241
242 if (sawZeroLengthMatch && !sawNonEmptyMatch) {
243 return { error: "zero_length_unsupported" };
244 }
245 return { matches, truncated: false };
246 }
247
248 function findLineIndex(lineStarts: readonly number[], offset: number): number {
249 let low = 0;
250 let high = lineStarts.length - 1;
251 while (low <= high) {
252 const middle = Math.floor((low + high) / 2);
253 if ((lineStarts[middle] ?? 0) <= offset) low = middle + 1;
254 else high = middle - 1;
255 }
256 return Math.max(0, high);
257 }
258
259 function emptySearchResult(): CodeSearchResult {
260 return { matches: [], truncated: false };
261 }
262
263 function escapeRegex(value: string): string {
264 return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
265 }
266
267 function isWordCharacter(value: string): boolean {
268 return value !== "" && WORD_CHARACTER_RE.test(value);
269 }
270
271 function codePointBefore(value: string, offset: number): string {
272 const codePoints = Array.from(value.slice(0, offset));
273 return codePoints[codePoints.length - 1] ?? "";
274 }
275
276 function codePointAt(value: string, offset: number): string {
277 return Array.from(value.slice(offset))[0] ?? "";
278 }
279
279 lines TYPESCRIPT