@aaroncql/pim-agent 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +212 -0
- package/bin/pim.ts +109 -0
- package/package.json +49 -0
- package/src/extensions/_init/index.ts +109 -0
- package/src/extensions/bash/capture.test.ts +126 -0
- package/src/extensions/bash/capture.ts +80 -0
- package/src/extensions/bash/format.test.ts +240 -0
- package/src/extensions/bash/format.ts +76 -0
- package/src/extensions/bash/index.ts +86 -0
- package/src/extensions/bash/run.test.ts +262 -0
- package/src/extensions/bash/run.ts +207 -0
- package/src/extensions/bash/schema.ts +54 -0
- package/src/extensions/command-picker/index.ts +52 -0
- package/src/extensions/command-picker/ranker.test.ts +46 -0
- package/src/extensions/command-picker/ranker.ts +17 -0
- package/src/extensions/edit/edit.test.ts +285 -0
- package/src/extensions/edit/edit.ts +382 -0
- package/src/extensions/edit/index.ts +54 -0
- package/src/extensions/edit/schema.ts +37 -0
- package/src/extensions/file-picker/catalog.test.ts +263 -0
- package/src/extensions/file-picker/catalog.ts +219 -0
- package/src/extensions/file-picker/index.test.ts +168 -0
- package/src/extensions/file-picker/index.ts +119 -0
- package/src/extensions/file-picker/ranker.test.ts +94 -0
- package/src/extensions/file-picker/ranker.ts +76 -0
- package/src/extensions/footer/git.test.ts +76 -0
- package/src/extensions/footer/git.ts +87 -0
- package/src/extensions/footer/index.test.ts +161 -0
- package/src/extensions/footer/index.ts +148 -0
- package/src/extensions/footer/powerline.ts +87 -0
- package/src/extensions/footer/segments.test.ts +164 -0
- package/src/extensions/footer/segments.ts +234 -0
- package/src/extensions/glob/glob.test.ts +171 -0
- package/src/extensions/glob/glob.ts +34 -0
- package/src/extensions/glob/index.test.ts +68 -0
- package/src/extensions/glob/index.ts +136 -0
- package/src/extensions/glob/render.test.ts +126 -0
- package/src/extensions/glob/render.ts +74 -0
- package/src/extensions/glob/schema.ts +52 -0
- package/src/extensions/grep/grep.test.ts +387 -0
- package/src/extensions/grep/grep.ts +215 -0
- package/src/extensions/grep/index.test.ts +68 -0
- package/src/extensions/grep/index.ts +158 -0
- package/src/extensions/grep/render.test.ts +269 -0
- package/src/extensions/grep/render.ts +243 -0
- package/src/extensions/grep/schema.ts +92 -0
- package/src/extensions/read/index.ts +84 -0
- package/src/extensions/read/read.test.ts +177 -0
- package/src/extensions/read/read.ts +206 -0
- package/src/extensions/read/render.test.ts +61 -0
- package/src/extensions/read/render.ts +33 -0
- package/src/extensions/read/schema.ts +27 -0
- package/src/extensions/subagent/index.test.ts +44 -0
- package/src/extensions/subagent/index.ts +30 -0
- package/src/extensions/subagent/render.test.ts +292 -0
- package/src/extensions/subagent/render.ts +359 -0
- package/src/extensions/subagent/schema.ts +9 -0
- package/src/extensions/subagent/subagent.test.ts +315 -0
- package/src/extensions/subagent/subagent.ts +418 -0
- package/src/extensions/system-prompt/index.ts +28 -0
- package/src/extensions/system-prompt/prompt.test.ts +64 -0
- package/src/extensions/system-prompt/prompt.ts +213 -0
- package/src/extensions/todo/index.test.ts +244 -0
- package/src/extensions/todo/index.ts +122 -0
- package/src/extensions/todo/render.test.ts +180 -0
- package/src/extensions/todo/render.ts +172 -0
- package/src/extensions/todo/schema.ts +24 -0
- package/src/extensions/todo/todo.test.ts +222 -0
- package/src/extensions/todo/todo.ts +188 -0
- package/src/extensions/tps/index.test.ts +254 -0
- package/src/extensions/tps/index.ts +136 -0
- package/src/extensions/web-fetch/JinaReaderClient.ts +230 -0
- package/src/extensions/web-fetch/WebViewFetchClient.ts +186 -0
- package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +119 -0
- package/src/extensions/web-fetch/WebViewMarkdownSnapshot.ts +511 -0
- package/src/extensions/web-fetch/fetch.test.ts +244 -0
- package/src/extensions/web-fetch/fetch.ts +249 -0
- package/src/extensions/web-fetch/index.ts +107 -0
- package/src/extensions/web-fetch/render.test.ts +56 -0
- package/src/extensions/web-fetch/render.ts +39 -0
- package/src/extensions/web-fetch/schema.ts +23 -0
- package/src/extensions/web-search/ExaMcpClient.test.ts +143 -0
- package/src/extensions/web-search/ExaMcpClient.ts +258 -0
- package/src/extensions/web-search/index.ts +118 -0
- package/src/extensions/web-search/render.test.ts +21 -0
- package/src/extensions/web-search/render.ts +9 -0
- package/src/extensions/web-search/schema.ts +21 -0
- package/src/extensions/web-search/search.test.ts +53 -0
- package/src/extensions/web-search/search.ts +23 -0
- package/src/extensions/working-indicator/index.test.ts +21 -0
- package/src/extensions/working-indicator/index.ts +77 -0
- package/src/extensions/write/index.ts +76 -0
- package/src/extensions/write/render.test.ts +64 -0
- package/src/extensions/write/schema.ts +14 -0
- package/src/extensions/write/write.test.ts +108 -0
- package/src/extensions/write/write.ts +104 -0
- package/src/shared/DiffLines.test.ts +193 -0
- package/src/shared/DiffLines.ts +307 -0
- package/src/shared/DiffRenderer.test.ts +206 -0
- package/src/shared/DiffRenderer.ts +396 -0
- package/src/shared/DiffView.ts +199 -0
- package/src/shared/EditMatcher.test.ts +123 -0
- package/src/shared/EditMatcher.ts +826 -0
- package/src/shared/FileScanner.test.ts +158 -0
- package/src/shared/FileScanner.ts +41 -0
- package/src/shared/Fs.ts +46 -0
- package/src/shared/FsErrors.ts +72 -0
- package/src/shared/FuzzyMatcher.test.ts +114 -0
- package/src/shared/FuzzyMatcher.ts +73 -0
- package/src/shared/GitignoreFilter.test.ts +64 -0
- package/src/shared/GitignoreFilter.ts +142 -0
- package/src/shared/GlobExclusions.ts +23 -0
- package/src/shared/Levenshtein.ts +33 -0
- package/src/shared/Lines.test.ts +25 -0
- package/src/shared/Lines.ts +77 -0
- package/src/shared/McpClient.test.ts +235 -0
- package/src/shared/McpClient.ts +406 -0
- package/src/shared/OutputBudget.test.ts +99 -0
- package/src/shared/OutputBudget.ts +79 -0
- package/src/shared/Paths.test.ts +51 -0
- package/src/shared/Paths.ts +52 -0
- package/src/shared/PimSettings.test.ts +90 -0
- package/src/shared/PimSettings.ts +124 -0
- package/src/shared/Renderer.test.ts +190 -0
- package/src/shared/Renderer.ts +256 -0
- package/src/shared/SpillCache.test.ts +94 -0
- package/src/shared/SpillCache.ts +89 -0
- package/src/shared/Tools.test.ts +392 -0
- package/src/shared/Tools.ts +636 -0
- package/src/telegram/Bot.ts +198 -0
- package/src/telegram/Commands.ts +721 -0
- package/src/telegram/Config.test.ts +275 -0
- package/src/telegram/Config.ts +162 -0
- package/src/telegram/Markdown.test.ts +143 -0
- package/src/telegram/Markdown.ts +177 -0
- package/src/telegram/Message.ts +211 -0
- package/src/telegram/Renderer.test.ts +216 -0
- package/src/telegram/Renderer.ts +713 -0
- package/src/telegram/SendFileSchema.ts +19 -0
- package/src/telegram/SendFileTool.ts +94 -0
- package/src/telegram/Session.ts +579 -0
- package/src/telegram/SessionRegistry.test.ts +89 -0
- package/src/telegram/SessionRegistry.ts +170 -0
- package/src/telegram/Supervisor.ts +357 -0
- package/src/telegram/TaskScheduler.test.ts +278 -0
- package/src/telegram/TaskScheduler.ts +293 -0
- package/src/telegram/TaskSchema.ts +88 -0
- package/src/telegram/TaskStore.ts +73 -0
- package/src/telegram/TaskTool.test.ts +179 -0
- package/src/telegram/TaskTool.ts +159 -0
- package/src/telegram/TypingIndicator.ts +43 -0
- package/src/telegram/index.ts +32 -0
- package/src/themes/pim-dark.json +84 -0
- package/src/themes/pim-light.json +84 -0
|
@@ -0,0 +1,826 @@
|
|
|
1
|
+
import { Levenshtein } from "./Levenshtein";
|
|
2
|
+
|
|
3
|
+
export type EditMatchStrategy =
|
|
4
|
+
| "simple"
|
|
5
|
+
| "lineTrimmed"
|
|
6
|
+
| "whitespaceNormalized"
|
|
7
|
+
| "indentationFlexible"
|
|
8
|
+
| "escapeNormalized"
|
|
9
|
+
| "trimmedBoundary"
|
|
10
|
+
| "unicodeNormalized"
|
|
11
|
+
| "blockAnchor"
|
|
12
|
+
| "contextAware";
|
|
13
|
+
|
|
14
|
+
export type EditRange = readonly [start: number, end: number];
|
|
15
|
+
|
|
16
|
+
export type ClosestRegion = {
|
|
17
|
+
readonly startLine: number;
|
|
18
|
+
readonly endLine: number;
|
|
19
|
+
readonly similarity: number;
|
|
20
|
+
readonly text: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type ResolveOutcome =
|
|
24
|
+
| {
|
|
25
|
+
readonly range: EditRange;
|
|
26
|
+
readonly strategy: EditMatchStrategy;
|
|
27
|
+
readonly matchCount: number;
|
|
28
|
+
}
|
|
29
|
+
| {
|
|
30
|
+
readonly ranges: readonly EditRange[];
|
|
31
|
+
readonly strategy: EditMatchStrategy;
|
|
32
|
+
readonly matchCount: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type Candidate = {
|
|
36
|
+
readonly range: EditRange;
|
|
37
|
+
readonly text: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type Strategy = {
|
|
41
|
+
readonly name: EditMatchStrategy;
|
|
42
|
+
readonly find: (content: string, oldString: string) => readonly Candidate[];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export class EditMatcher {
|
|
46
|
+
public static resolve(
|
|
47
|
+
content: string,
|
|
48
|
+
oldString: string,
|
|
49
|
+
replaceAll = false
|
|
50
|
+
): ResolveOutcome {
|
|
51
|
+
if (oldString.length === 0) {
|
|
52
|
+
throw new EditMatcher.NotFoundError(
|
|
53
|
+
oldString,
|
|
54
|
+
EditMatcher.findClosestRegions(content, oldString)
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let foundAmbiguous = false;
|
|
59
|
+
|
|
60
|
+
for (const strategy of EditMatcher.strategies) {
|
|
61
|
+
const candidates = EditMatcher.dedupeCandidates(
|
|
62
|
+
strategy.find(content, oldString)
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
if (candidates.length === 0) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (replaceAll) {
|
|
70
|
+
return {
|
|
71
|
+
ranges: EditMatcher.sortRanges(
|
|
72
|
+
candidates.map((candidate) => candidate.range)
|
|
73
|
+
),
|
|
74
|
+
strategy: strategy.name,
|
|
75
|
+
matchCount: candidates.length,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const unique = candidates.filter((candidate) => {
|
|
80
|
+
const first = content.indexOf(candidate.text);
|
|
81
|
+
const last = content.lastIndexOf(candidate.text);
|
|
82
|
+
return first !== -1 && first === last;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (unique.length === 1) {
|
|
86
|
+
return {
|
|
87
|
+
range: unique[0]!.range,
|
|
88
|
+
strategy: strategy.name,
|
|
89
|
+
matchCount: candidates.length,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
foundAmbiguous = true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (foundAmbiguous) {
|
|
97
|
+
throw new EditMatcher.MultipleMatchesError(oldString);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
throw new EditMatcher.NotFoundError(
|
|
101
|
+
oldString,
|
|
102
|
+
EditMatcher.findClosestRegions(content, oldString)
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
public static applyAll(
|
|
107
|
+
content: string,
|
|
108
|
+
mutations: readonly {
|
|
109
|
+
readonly range: EditRange;
|
|
110
|
+
readonly newString: string;
|
|
111
|
+
}[]
|
|
112
|
+
): string {
|
|
113
|
+
let result = content;
|
|
114
|
+
|
|
115
|
+
for (const mutation of [...mutations].sort(
|
|
116
|
+
(left, right) => right.range[0] - left.range[0]
|
|
117
|
+
)) {
|
|
118
|
+
result =
|
|
119
|
+
result.slice(0, mutation.range[0]) +
|
|
120
|
+
mutation.newString +
|
|
121
|
+
result.slice(mutation.range[1]);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
public static assertNoEscapeDrift(
|
|
128
|
+
strategy: EditMatchStrategy,
|
|
129
|
+
newString: string,
|
|
130
|
+
matchedRegion: string
|
|
131
|
+
): void {
|
|
132
|
+
if (strategy === "simple") {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const introduced = EditMatcher.escapeSequences(newString).filter(
|
|
137
|
+
(sequence) => !matchedRegion.includes(sequence)
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
if (introduced.length === 0) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
throw new Error(
|
|
145
|
+
`Edit failed: newString contains literal escape text not present in the matched file text: ${[...new Set(introduced)].join(", ")}. Re-read the file and retry using exact file text in oldString and the intended replacement text in newString.`
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
public static findClosestRegions(
|
|
150
|
+
content: string,
|
|
151
|
+
oldString: string,
|
|
152
|
+
max = 3,
|
|
153
|
+
threshold = 0.5
|
|
154
|
+
): readonly ClosestRegion[] {
|
|
155
|
+
const contentLines = EditMatcher.logicalLines(content);
|
|
156
|
+
const searchLines = EditMatcher.logicalLines(oldString);
|
|
157
|
+
const windowSize = Math.max(1, searchLines.length);
|
|
158
|
+
const regions: ClosestRegion[] = [];
|
|
159
|
+
|
|
160
|
+
if (contentLines.length === 0 || searchLines.length === 0) {
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
for (let index = 0; index <= contentLines.length - windowSize; index += 1) {
|
|
165
|
+
const window = contentLines.slice(index, index + windowSize);
|
|
166
|
+
let total = 0;
|
|
167
|
+
|
|
168
|
+
for (let offset = 0; offset < windowSize; offset += 1) {
|
|
169
|
+
total += EditMatcher.lineSimilarity(
|
|
170
|
+
searchLines[offset] ?? "",
|
|
171
|
+
window[offset] ?? ""
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const similarity = total / windowSize;
|
|
176
|
+
|
|
177
|
+
if (similarity >= threshold) {
|
|
178
|
+
regions.push({
|
|
179
|
+
startLine: index + 1,
|
|
180
|
+
endLine: index + windowSize,
|
|
181
|
+
similarity,
|
|
182
|
+
text: window.join("\n"),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return regions
|
|
188
|
+
.sort((left, right) => right.similarity - left.similarity)
|
|
189
|
+
.slice(0, max);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
public static renderNotFound(
|
|
193
|
+
error: InstanceType<typeof EditMatcher.NotFoundError>
|
|
194
|
+
): string {
|
|
195
|
+
if (error.closest.length === 0) {
|
|
196
|
+
return "oldString was not found in the file.";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return [
|
|
200
|
+
"oldString was not found in the file. Closest candidate regions:",
|
|
201
|
+
"",
|
|
202
|
+
...error.closest.flatMap((region, index) => [
|
|
203
|
+
`${index + 1}. lines ${EditMatcher.formatLineRange(region.startLine, region.endLine)} (${Math.round(region.similarity * 100)}% similar)`,
|
|
204
|
+
region.text,
|
|
205
|
+
]),
|
|
206
|
+
].join("\n");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
public static lineRangeFor(content: string, range: EditRange): string {
|
|
210
|
+
const start = EditMatcher.lineNumberAt(content, range[0]);
|
|
211
|
+
const end = EditMatcher.lineNumberAt(
|
|
212
|
+
content,
|
|
213
|
+
Math.max(range[0], range[1] - 1)
|
|
214
|
+
);
|
|
215
|
+
return EditMatcher.formatLineRange(start, end);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
public static readonly NotFoundError = class NotFoundError extends Error {
|
|
219
|
+
public constructor(
|
|
220
|
+
public readonly oldString: string,
|
|
221
|
+
public readonly closest: readonly ClosestRegion[]
|
|
222
|
+
) {
|
|
223
|
+
super("oldString was not found in the file.");
|
|
224
|
+
this.name = "NotFoundError";
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
public static readonly MultipleMatchesError = class MultipleMatchesError extends Error {
|
|
229
|
+
public constructor(public readonly oldString: string) {
|
|
230
|
+
super(
|
|
231
|
+
"oldString matched multiple regions. Use enough surrounding context to make oldString unique, or set replaceAll=true to replace all matching oldString."
|
|
232
|
+
);
|
|
233
|
+
this.name = "MultipleMatchesError";
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
private static readonly strategies: readonly Strategy[] = [
|
|
238
|
+
{
|
|
239
|
+
name: "simple",
|
|
240
|
+
find: (content, oldString) => EditMatcher.simple(content, oldString),
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
name: "lineTrimmed",
|
|
244
|
+
find: (content, oldString) => EditMatcher.lineTrimmed(content, oldString),
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
name: "whitespaceNormalized",
|
|
248
|
+
find: (content, oldString) =>
|
|
249
|
+
EditMatcher.whitespaceNormalized(content, oldString),
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
name: "indentationFlexible",
|
|
253
|
+
find: (content, oldString) =>
|
|
254
|
+
EditMatcher.indentationFlexible(content, oldString),
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
name: "escapeNormalized",
|
|
258
|
+
find: (content, oldString) =>
|
|
259
|
+
EditMatcher.escapeNormalized(content, oldString),
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
name: "trimmedBoundary",
|
|
263
|
+
find: (content, oldString) =>
|
|
264
|
+
EditMatcher.trimmedBoundary(content, oldString),
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
name: "unicodeNormalized",
|
|
268
|
+
find: (content, oldString) =>
|
|
269
|
+
EditMatcher.unicodeNormalized(content, oldString),
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
name: "blockAnchor",
|
|
273
|
+
find: (content, oldString) => EditMatcher.blockAnchor(content, oldString),
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
name: "contextAware",
|
|
277
|
+
find: (content, oldString) =>
|
|
278
|
+
EditMatcher.contextAware(content, oldString),
|
|
279
|
+
},
|
|
280
|
+
];
|
|
281
|
+
|
|
282
|
+
private static simple(
|
|
283
|
+
content: string,
|
|
284
|
+
oldString: string
|
|
285
|
+
): readonly Candidate[] {
|
|
286
|
+
return EditMatcher.findAll(content, oldString);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private static lineTrimmed(
|
|
290
|
+
content: string,
|
|
291
|
+
oldString: string
|
|
292
|
+
): readonly Candidate[] {
|
|
293
|
+
const contentLines = EditMatcher.offsetLines(content);
|
|
294
|
+
const searchLines = EditMatcher.logicalLines(oldString);
|
|
295
|
+
const candidates: Candidate[] = [];
|
|
296
|
+
|
|
297
|
+
for (
|
|
298
|
+
let index = 0;
|
|
299
|
+
index <= contentLines.length - searchLines.length;
|
|
300
|
+
index += 1
|
|
301
|
+
) {
|
|
302
|
+
const block = contentLines.slice(index, index + searchLines.length);
|
|
303
|
+
|
|
304
|
+
if (
|
|
305
|
+
block.every(
|
|
306
|
+
(line, offset) =>
|
|
307
|
+
line.text.trim() === (searchLines[offset] ?? "").trim()
|
|
308
|
+
)
|
|
309
|
+
) {
|
|
310
|
+
candidates.push(EditMatcher.candidateFromLines(content, block));
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return candidates;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private static whitespaceNormalized(
|
|
318
|
+
content: string,
|
|
319
|
+
oldString: string
|
|
320
|
+
): readonly Candidate[] {
|
|
321
|
+
const normalize = (text: string) => text.replace(/\s+/gu, " ").trim();
|
|
322
|
+
const normalizedFind = normalize(oldString);
|
|
323
|
+
const lines = EditMatcher.offsetLines(content);
|
|
324
|
+
const searchLines = EditMatcher.logicalLines(oldString);
|
|
325
|
+
const candidates: Candidate[] = [];
|
|
326
|
+
const flexiblePattern = oldString
|
|
327
|
+
.trim()
|
|
328
|
+
.split(/\s+/u)
|
|
329
|
+
.map((word) => EditMatcher.escapeRegex(word))
|
|
330
|
+
.join("\\s+");
|
|
331
|
+
|
|
332
|
+
for (const line of lines) {
|
|
333
|
+
if (normalize(line.text) === normalizedFind) {
|
|
334
|
+
candidates.push({
|
|
335
|
+
range: [line.start, line.end],
|
|
336
|
+
text: content.slice(line.start, line.end),
|
|
337
|
+
});
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (normalize(line.text).includes(normalizedFind)) {
|
|
342
|
+
const regex = new RegExp(flexiblePattern, "gu");
|
|
343
|
+
|
|
344
|
+
for (const match of line.text.matchAll(regex)) {
|
|
345
|
+
if (match.index === undefined) {
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
const start = line.start + match.index;
|
|
349
|
+
candidates.push({
|
|
350
|
+
range: [start, start + match[0].length],
|
|
351
|
+
text: match[0],
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (searchLines.length > 1) {
|
|
358
|
+
for (
|
|
359
|
+
let index = 0;
|
|
360
|
+
index <= lines.length - searchLines.length;
|
|
361
|
+
index += 1
|
|
362
|
+
) {
|
|
363
|
+
const block = lines.slice(index, index + searchLines.length);
|
|
364
|
+
const text = block.map((line) => line.text).join("\n");
|
|
365
|
+
|
|
366
|
+
if (normalize(text) === normalizedFind) {
|
|
367
|
+
candidates.push(EditMatcher.candidateFromLines(content, block));
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return candidates;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
private static indentationFlexible(
|
|
376
|
+
content: string,
|
|
377
|
+
oldString: string
|
|
378
|
+
): readonly Candidate[] {
|
|
379
|
+
const removeIndentation = (text: string): string => {
|
|
380
|
+
const lines = text.split("\n");
|
|
381
|
+
const nonEmpty = lines.filter((line) => line.trim().length > 0);
|
|
382
|
+
|
|
383
|
+
if (nonEmpty.length === 0) {
|
|
384
|
+
return text;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const minIndent = Math.min(
|
|
388
|
+
...nonEmpty.map((line) => line.match(/^(\s*)/u)?.[1]?.length ?? 0)
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
return lines
|
|
392
|
+
.map((line) =>
|
|
393
|
+
line.trim().length === 0 ? line : line.slice(minIndent)
|
|
394
|
+
)
|
|
395
|
+
.join("\n");
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
const normalizedFind = removeIndentation(oldString);
|
|
399
|
+
const lines = EditMatcher.offsetLines(content);
|
|
400
|
+
const searchLines = EditMatcher.logicalLines(oldString);
|
|
401
|
+
const candidates: Candidate[] = [];
|
|
402
|
+
|
|
403
|
+
for (
|
|
404
|
+
let index = 0;
|
|
405
|
+
index <= lines.length - searchLines.length;
|
|
406
|
+
index += 1
|
|
407
|
+
) {
|
|
408
|
+
const block = lines.slice(index, index + searchLines.length);
|
|
409
|
+
const text = block.map((line) => line.text).join("\n");
|
|
410
|
+
|
|
411
|
+
if (removeIndentation(text) === normalizedFind) {
|
|
412
|
+
candidates.push(EditMatcher.candidateFromLines(content, block));
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return candidates;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
private static escapeNormalized(
|
|
420
|
+
content: string,
|
|
421
|
+
oldString: string
|
|
422
|
+
): readonly Candidate[] {
|
|
423
|
+
const unescaped = EditMatcher.unescapeString(oldString);
|
|
424
|
+
const candidates: Candidate[] = [];
|
|
425
|
+
candidates.push(...EditMatcher.findAll(content, unescaped));
|
|
426
|
+
|
|
427
|
+
const lines = EditMatcher.offsetLines(content);
|
|
428
|
+
const searchLines = EditMatcher.logicalLines(unescaped);
|
|
429
|
+
|
|
430
|
+
for (
|
|
431
|
+
let index = 0;
|
|
432
|
+
index <= lines.length - searchLines.length;
|
|
433
|
+
index += 1
|
|
434
|
+
) {
|
|
435
|
+
const block = lines.slice(index, index + searchLines.length);
|
|
436
|
+
const text = block.map((line) => line.text).join("\n");
|
|
437
|
+
|
|
438
|
+
if (EditMatcher.unescapeString(text) === unescaped) {
|
|
439
|
+
candidates.push(EditMatcher.candidateFromLines(content, block));
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return candidates;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private static trimmedBoundary(
|
|
447
|
+
content: string,
|
|
448
|
+
oldString: string
|
|
449
|
+
): readonly Candidate[] {
|
|
450
|
+
const trimmed = oldString.trim();
|
|
451
|
+
|
|
452
|
+
if (trimmed === oldString || trimmed.length === 0) {
|
|
453
|
+
return [];
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const candidates = [...EditMatcher.findAll(content, trimmed)];
|
|
457
|
+
const lines = EditMatcher.offsetLines(content);
|
|
458
|
+
const searchLines = EditMatcher.logicalLines(oldString);
|
|
459
|
+
|
|
460
|
+
for (
|
|
461
|
+
let index = 0;
|
|
462
|
+
index <= lines.length - searchLines.length;
|
|
463
|
+
index += 1
|
|
464
|
+
) {
|
|
465
|
+
const block = lines.slice(index, index + searchLines.length);
|
|
466
|
+
const text = block.map((line) => line.text).join("\n");
|
|
467
|
+
|
|
468
|
+
if (text.trim() === trimmed) {
|
|
469
|
+
candidates.push(EditMatcher.candidateFromLines(content, block));
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return candidates;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
private static unicodeNormalized(
|
|
477
|
+
content: string,
|
|
478
|
+
oldString: string
|
|
479
|
+
): readonly Candidate[] {
|
|
480
|
+
// Substitutions in normalizeUnicode must be 1:1 by UTF-16 code unit so offsets in
|
|
481
|
+
// normalizedContent index into the original content. Adding multi-codepoint mappings
|
|
482
|
+
// (e.g. `…` → `...`) here would silently corrupt range math.
|
|
483
|
+
const normalizedContent = EditMatcher.normalizeUnicode(content);
|
|
484
|
+
const normalizedOld = EditMatcher.normalizeUnicode(oldString);
|
|
485
|
+
const candidates: Candidate[] = [];
|
|
486
|
+
let index = 0;
|
|
487
|
+
|
|
488
|
+
while (true) {
|
|
489
|
+
const start = normalizedContent.indexOf(normalizedOld, index);
|
|
490
|
+
|
|
491
|
+
if (start === -1) {
|
|
492
|
+
break;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const end = start + oldString.length;
|
|
496
|
+
candidates.push({ range: [start, end], text: content.slice(start, end) });
|
|
497
|
+
index = Math.max(start + 1, end);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
return candidates;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
private static blockAnchor(
|
|
504
|
+
content: string,
|
|
505
|
+
oldString: string
|
|
506
|
+
): readonly Candidate[] {
|
|
507
|
+
const lines = EditMatcher.offsetLines(content);
|
|
508
|
+
const searchLines = EditMatcher.logicalLines(oldString);
|
|
509
|
+
|
|
510
|
+
if (searchLines.length < 3) {
|
|
511
|
+
return [];
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const first = searchLines[0]?.trim() ?? "";
|
|
515
|
+
const last = searchLines.at(-1)?.trim() ?? "";
|
|
516
|
+
const candidates: Candidate[] = [];
|
|
517
|
+
|
|
518
|
+
for (
|
|
519
|
+
let index = 0;
|
|
520
|
+
index <= lines.length - searchLines.length;
|
|
521
|
+
index += 1
|
|
522
|
+
) {
|
|
523
|
+
const endIndex = index + searchLines.length - 1;
|
|
524
|
+
|
|
525
|
+
if (
|
|
526
|
+
lines[index]?.text.trim() !== first ||
|
|
527
|
+
lines[endIndex]?.text.trim() !== last
|
|
528
|
+
) {
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const block = lines.slice(index, endIndex + 1);
|
|
533
|
+
const middleCount = Math.max(1, searchLines.length - 2);
|
|
534
|
+
let similarity = 0;
|
|
535
|
+
|
|
536
|
+
for (let offset = 1; offset < searchLines.length - 1; offset += 1) {
|
|
537
|
+
similarity +=
|
|
538
|
+
EditMatcher.lineSimilarity(
|
|
539
|
+
searchLines[offset] ?? "",
|
|
540
|
+
block[offset]?.text ?? ""
|
|
541
|
+
) / middleCount;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// 0.3 floor filters anchor coincidence on unrelated blocks that happen to share first/last line text.
|
|
545
|
+
if (similarity >= 0.3) {
|
|
546
|
+
candidates.push(EditMatcher.candidateFromLines(content, block));
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
return candidates;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
private static contextAware(
|
|
554
|
+
content: string,
|
|
555
|
+
oldString: string
|
|
556
|
+
): readonly Candidate[] {
|
|
557
|
+
const lines = EditMatcher.offsetLines(content);
|
|
558
|
+
const searchLines = EditMatcher.logicalLines(oldString);
|
|
559
|
+
|
|
560
|
+
if (searchLines.length < 3) {
|
|
561
|
+
return [];
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const first = searchLines[0]?.trim() ?? "";
|
|
565
|
+
const last = searchLines.at(-1)?.trim() ?? "";
|
|
566
|
+
const candidates: Candidate[] = [];
|
|
567
|
+
|
|
568
|
+
for (
|
|
569
|
+
let index = 0;
|
|
570
|
+
index <= lines.length - searchLines.length;
|
|
571
|
+
index += 1
|
|
572
|
+
) {
|
|
573
|
+
const endIndex = index + searchLines.length - 1;
|
|
574
|
+
|
|
575
|
+
if (
|
|
576
|
+
lines[index]?.text.trim() !== first ||
|
|
577
|
+
lines[endIndex]?.text.trim() !== last
|
|
578
|
+
) {
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const block = lines.slice(index, endIndex + 1);
|
|
583
|
+
let matching = 0;
|
|
584
|
+
let total = 0;
|
|
585
|
+
|
|
586
|
+
for (let offset = 1; offset < searchLines.length - 1; offset += 1) {
|
|
587
|
+
const actual = block[offset]?.text.trim() ?? "";
|
|
588
|
+
const expected = searchLines[offset]?.trim() ?? "";
|
|
589
|
+
|
|
590
|
+
if (actual.length > 0 || expected.length > 0) {
|
|
591
|
+
total += 1;
|
|
592
|
+
if (actual === expected) {
|
|
593
|
+
matching += 1;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (total === 0 || matching / total >= 0.5) {
|
|
599
|
+
candidates.push(EditMatcher.candidateFromLines(content, block));
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return candidates;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
private static findAll(
|
|
607
|
+
content: string,
|
|
608
|
+
search: string
|
|
609
|
+
): readonly Candidate[] {
|
|
610
|
+
const candidates: Candidate[] = [];
|
|
611
|
+
|
|
612
|
+
if (search.length === 0) {
|
|
613
|
+
return candidates;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
let index = 0;
|
|
617
|
+
|
|
618
|
+
while (true) {
|
|
619
|
+
const start = content.indexOf(search, index);
|
|
620
|
+
|
|
621
|
+
if (start === -1) {
|
|
622
|
+
return candidates;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
candidates.push({
|
|
626
|
+
range: [start, start + search.length],
|
|
627
|
+
text: content.slice(start, start + search.length),
|
|
628
|
+
});
|
|
629
|
+
index = Math.max(start + 1, start + search.length);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
private static candidateFromLines(
|
|
634
|
+
content: string,
|
|
635
|
+
lines: readonly OffsetLine[]
|
|
636
|
+
): Candidate {
|
|
637
|
+
const first = lines[0];
|
|
638
|
+
const last = lines.at(-1);
|
|
639
|
+
|
|
640
|
+
if (first === undefined || last === undefined) {
|
|
641
|
+
return { range: [0, 0], text: "" };
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
return {
|
|
645
|
+
range: [first.start, last.end],
|
|
646
|
+
text: content.slice(first.start, last.end),
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
private static dedupeCandidates(
|
|
651
|
+
candidates: readonly Candidate[]
|
|
652
|
+
): readonly Candidate[] {
|
|
653
|
+
const seen = new Set<string>();
|
|
654
|
+
const deduped: Candidate[] = [];
|
|
655
|
+
|
|
656
|
+
for (const candidate of candidates) {
|
|
657
|
+
const key = `${candidate.range[0]}:${candidate.range[1]}`;
|
|
658
|
+
|
|
659
|
+
if (!seen.has(key)) {
|
|
660
|
+
seen.add(key);
|
|
661
|
+
deduped.push(candidate);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
return deduped;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
private static sortRanges(
|
|
669
|
+
ranges: readonly EditRange[]
|
|
670
|
+
): readonly EditRange[] {
|
|
671
|
+
return [...ranges].sort((left, right) => left[0] - right[0]);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
private static logicalLines(content: string): readonly string[] {
|
|
675
|
+
if (content.length === 0) {
|
|
676
|
+
return [];
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const lines = content.split(/\r?\n/u);
|
|
680
|
+
|
|
681
|
+
if (lines.at(-1) === "") {
|
|
682
|
+
lines.pop();
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
return lines;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
private static offsetLines(content: string): readonly OffsetLine[] {
|
|
689
|
+
if (content.length === 0) {
|
|
690
|
+
return [];
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const lines: OffsetLine[] = [];
|
|
694
|
+
let start = 0;
|
|
695
|
+
|
|
696
|
+
while (start <= content.length) {
|
|
697
|
+
const newline = content.indexOf("\n", start);
|
|
698
|
+
const end = newline === -1 ? content.length : newline;
|
|
699
|
+
const textEnd = end > start && content[end - 1] === "\r" ? end - 1 : end;
|
|
700
|
+
|
|
701
|
+
if (start === content.length && newline === -1) {
|
|
702
|
+
break;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
lines.push({
|
|
706
|
+
start,
|
|
707
|
+
end,
|
|
708
|
+
text: content.slice(start, textEnd),
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
if (newline === -1) {
|
|
712
|
+
break;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
start = newline + 1;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (lines.at(-1)?.text === "" && content.endsWith("\n")) {
|
|
719
|
+
lines.pop();
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
return lines;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
private static unescapeString(text: string): string {
|
|
726
|
+
return text.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/gu, (match, value) => {
|
|
727
|
+
switch (value) {
|
|
728
|
+
case "n":
|
|
729
|
+
return "\n";
|
|
730
|
+
case "t":
|
|
731
|
+
return "\t";
|
|
732
|
+
case "r":
|
|
733
|
+
return "\r";
|
|
734
|
+
case "'":
|
|
735
|
+
return "'";
|
|
736
|
+
case '"':
|
|
737
|
+
return '"';
|
|
738
|
+
case "`":
|
|
739
|
+
return "`";
|
|
740
|
+
case "\\":
|
|
741
|
+
return "\\";
|
|
742
|
+
case "\n":
|
|
743
|
+
return "\n";
|
|
744
|
+
case "$":
|
|
745
|
+
return "$";
|
|
746
|
+
default:
|
|
747
|
+
return match;
|
|
748
|
+
}
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
private static escapeSequences(text: string): readonly string[] {
|
|
753
|
+
return text.match(/\\(?:n|t|r|'|"|`|\$|\\)/gu) ?? [];
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
private static normalizeUnicode(text: string): string {
|
|
757
|
+
return text.replace(/[‘’‚‛“”„‟‐‑‒–—―− - ]/gu, (value) => {
|
|
758
|
+
switch (value) {
|
|
759
|
+
case "‘":
|
|
760
|
+
case "’":
|
|
761
|
+
case "‚":
|
|
762
|
+
case "‛":
|
|
763
|
+
return "'";
|
|
764
|
+
case "“":
|
|
765
|
+
case "”":
|
|
766
|
+
case "„":
|
|
767
|
+
case "‟":
|
|
768
|
+
return '"';
|
|
769
|
+
case "‐":
|
|
770
|
+
case "‑":
|
|
771
|
+
case "‒":
|
|
772
|
+
case "–":
|
|
773
|
+
case "—":
|
|
774
|
+
case "―":
|
|
775
|
+
case "−":
|
|
776
|
+
return "-";
|
|
777
|
+
default:
|
|
778
|
+
return " ";
|
|
779
|
+
}
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
private static lineSimilarity(left: string, right: string): number {
|
|
784
|
+
const a = left.trim();
|
|
785
|
+
const b = right.trim();
|
|
786
|
+
const max = Math.max(a.length, b.length);
|
|
787
|
+
|
|
788
|
+
if (max === 0) {
|
|
789
|
+
return 1;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
return 1 - Levenshtein.distance(a, b) / max;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
private static lineNumberAt(content: string, offset: number): number {
|
|
796
|
+
let line = 1;
|
|
797
|
+
let index = 0;
|
|
798
|
+
|
|
799
|
+
while (index < offset) {
|
|
800
|
+
const newline = content.indexOf("\n", index);
|
|
801
|
+
|
|
802
|
+
if (newline === -1 || newline >= offset) {
|
|
803
|
+
break;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
line += 1;
|
|
807
|
+
index = newline + 1;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
return line;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
private static formatLineRange(start: number, end: number): string {
|
|
814
|
+
return start === end ? String(start) : `${start}-${end}`;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
private static escapeRegex(text: string): string {
|
|
818
|
+
return text.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
type OffsetLine = {
|
|
823
|
+
readonly start: number;
|
|
824
|
+
readonly end: number;
|
|
825
|
+
readonly text: string;
|
|
826
|
+
};
|