typst-mcp 1.0.2 → 1.2.0
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/README.md +49 -87
- package/dist/client/compiler.js +234 -6
- package/dist/client/typst-client.js +306 -52
- package/dist/index.js +97 -5
- package/dist/protocol/edit-diff.js +330 -0
- package/dist/protocol/search-replace.js +97 -0
- package/dist/protocol/yjs-doc.js +31 -38
- package/dist/tools/definitions.js +123 -6
- package/dist/utils/file-mutation-queue.js +30 -0
- package/dist/utils/mime.js +23 -0
- package/dist/utils/truncate.js +114 -0
- package/package.json +3 -1
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Surgical text editing and diff utilities, adapted from Pi (earendil-works/pi).
|
|
3
|
+
*/
|
|
4
|
+
import * as Diff from "diff";
|
|
5
|
+
export function detectLineEnding(content) {
|
|
6
|
+
const crlfIdx = content.indexOf("\r\n");
|
|
7
|
+
const lfIdx = content.indexOf("\n");
|
|
8
|
+
if (lfIdx === -1)
|
|
9
|
+
return "\n";
|
|
10
|
+
if (crlfIdx === -1)
|
|
11
|
+
return "\n";
|
|
12
|
+
return crlfIdx < lfIdx ? "\r\n" : "\n";
|
|
13
|
+
}
|
|
14
|
+
export function normalizeToLF(text) {
|
|
15
|
+
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
16
|
+
}
|
|
17
|
+
export function restoreLineEndings(text, ending) {
|
|
18
|
+
return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
|
|
19
|
+
}
|
|
20
|
+
export function splitBom(text) {
|
|
21
|
+
if (text.charCodeAt(0) === 0xfeff) {
|
|
22
|
+
return { bom: "\uFEFF", text: text.slice(1) };
|
|
23
|
+
}
|
|
24
|
+
return { bom: "", text };
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Normalize text for fuzzy matching:
|
|
28
|
+
* - NFKC unicode normalization
|
|
29
|
+
* - Strip trailing whitespace per line
|
|
30
|
+
* - Smart quotes -> ' and "
|
|
31
|
+
* - Unicode dashes/hyphens -> -
|
|
32
|
+
* - Special spaces -> standard space
|
|
33
|
+
*/
|
|
34
|
+
export function normalizeForFuzzyMatch(text) {
|
|
35
|
+
return (text
|
|
36
|
+
.normalize("NFKC")
|
|
37
|
+
.split("\n")
|
|
38
|
+
.map((line) => line.trimEnd())
|
|
39
|
+
.join("\n")
|
|
40
|
+
// Smart single quotes
|
|
41
|
+
.replace(/[\u2018\u2019\u201A\u201B]/g, "'")
|
|
42
|
+
// Smart double quotes
|
|
43
|
+
.replace(/[\u201C\u201D\u201E\u201F]/g, '"')
|
|
44
|
+
// Various dashes/hyphens
|
|
45
|
+
.replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-")
|
|
46
|
+
// Special spaces
|
|
47
|
+
.replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " "));
|
|
48
|
+
}
|
|
49
|
+
function splitLinesWithEndings(content) {
|
|
50
|
+
return content.match(/[^\n]*\n|[^\n]+/g) ?? [];
|
|
51
|
+
}
|
|
52
|
+
function getLineSpans(content) {
|
|
53
|
+
let offset = 0;
|
|
54
|
+
return splitLinesWithEndings(content).map((line) => {
|
|
55
|
+
const span = { start: offset, end: offset + line.length };
|
|
56
|
+
offset = span.end;
|
|
57
|
+
return span;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
function getReplacementLineRange(lines, replacement) {
|
|
61
|
+
const replacementStart = replacement.matchIndex;
|
|
62
|
+
const replacementEnd = replacement.matchIndex + replacement.matchLength;
|
|
63
|
+
let startLine = -1;
|
|
64
|
+
for (let i = 0; i < lines.length; i++) {
|
|
65
|
+
const line = lines[i];
|
|
66
|
+
if (replacementStart >= line.start && replacementStart < line.end) {
|
|
67
|
+
startLine = i;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (startLine === -1) {
|
|
72
|
+
throw new Error("Replacement range is outside base content");
|
|
73
|
+
}
|
|
74
|
+
let endLine = startLine;
|
|
75
|
+
while (endLine < lines.length && lines[endLine].end < replacementEnd) {
|
|
76
|
+
endLine++;
|
|
77
|
+
}
|
|
78
|
+
if (endLine >= lines.length) {
|
|
79
|
+
throw new Error("Replacement range is outside base content");
|
|
80
|
+
}
|
|
81
|
+
return { startLine, endLine: endLine + 1 };
|
|
82
|
+
}
|
|
83
|
+
function applyReplacements(content, replacements, offset = 0) {
|
|
84
|
+
let result = content;
|
|
85
|
+
for (let i = replacements.length - 1; i >= 0; i--) {
|
|
86
|
+
const replacement = replacements[i];
|
|
87
|
+
const matchIndex = replacement.matchIndex - offset;
|
|
88
|
+
result =
|
|
89
|
+
result.substring(0, matchIndex) +
|
|
90
|
+
replacement.newText +
|
|
91
|
+
result.substring(matchIndex + replacement.matchLength);
|
|
92
|
+
}
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
export function applyReplacementsPreservingUnchangedLines(originalContent, baseContent, replacements) {
|
|
96
|
+
const originalLines = splitLinesWithEndings(originalContent);
|
|
97
|
+
const baseLines = getLineSpans(baseContent);
|
|
98
|
+
if (originalLines.length !== baseLines.length) {
|
|
99
|
+
throw new Error("Cannot preserve unchanged lines: base content has different line count");
|
|
100
|
+
}
|
|
101
|
+
const groups = [];
|
|
102
|
+
const sortedReplacements = [...replacements].sort((a, b) => a.matchIndex - b.matchIndex);
|
|
103
|
+
for (const replacement of sortedReplacements) {
|
|
104
|
+
const range = getReplacementLineRange(baseLines, replacement);
|
|
105
|
+
const current = groups[groups.length - 1];
|
|
106
|
+
if (current && range.startLine < current.endLine) {
|
|
107
|
+
current.endLine = Math.max(current.endLine, range.endLine);
|
|
108
|
+
current.replacements.push(replacement);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
groups.push({ ...range, replacements: [replacement] });
|
|
112
|
+
}
|
|
113
|
+
let originalLineIndex = 0;
|
|
114
|
+
let result = "";
|
|
115
|
+
for (const group of groups) {
|
|
116
|
+
result += originalLines.slice(originalLineIndex, group.startLine).join("");
|
|
117
|
+
const groupStartOffset = baseLines[group.startLine].start;
|
|
118
|
+
const groupEndOffset = baseLines[group.endLine - 1].end;
|
|
119
|
+
result += applyReplacements(baseContent.slice(groupStartOffset, groupEndOffset), group.replacements, groupStartOffset);
|
|
120
|
+
originalLineIndex = group.endLine;
|
|
121
|
+
}
|
|
122
|
+
result += originalLines.slice(originalLineIndex).join("");
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
export function fuzzyFindText(content, oldText) {
|
|
126
|
+
const exactIndex = content.indexOf(oldText);
|
|
127
|
+
if (exactIndex !== -1) {
|
|
128
|
+
return {
|
|
129
|
+
found: true,
|
|
130
|
+
index: exactIndex,
|
|
131
|
+
matchLength: oldText.length,
|
|
132
|
+
usedFuzzyMatch: false,
|
|
133
|
+
contentForReplacement: content,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
const fuzzyContent = normalizeForFuzzyMatch(content);
|
|
137
|
+
const fuzzyOldText = normalizeForFuzzyMatch(oldText);
|
|
138
|
+
const fuzzyIndex = fuzzyContent.indexOf(fuzzyOldText);
|
|
139
|
+
if (fuzzyIndex === -1) {
|
|
140
|
+
return {
|
|
141
|
+
found: false,
|
|
142
|
+
index: -1,
|
|
143
|
+
matchLength: 0,
|
|
144
|
+
usedFuzzyMatch: false,
|
|
145
|
+
contentForReplacement: content,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
found: true,
|
|
150
|
+
index: fuzzyIndex,
|
|
151
|
+
matchLength: fuzzyOldText.length,
|
|
152
|
+
usedFuzzyMatch: true,
|
|
153
|
+
contentForReplacement: fuzzyContent,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
export function countOccurrences(content, oldText) {
|
|
157
|
+
const fuzzyContent = normalizeForFuzzyMatch(content);
|
|
158
|
+
const fuzzyOldText = normalizeForFuzzyMatch(oldText);
|
|
159
|
+
if (fuzzyOldText.length === 0)
|
|
160
|
+
return 0;
|
|
161
|
+
return fuzzyContent.split(fuzzyOldText).length - 1;
|
|
162
|
+
}
|
|
163
|
+
export function applyEditsToNormalizedContent(normalizedContent, edits, path) {
|
|
164
|
+
const normalizedEdits = edits.map((e) => ({
|
|
165
|
+
oldText: normalizeToLF(e.oldText),
|
|
166
|
+
newText: normalizeToLF(e.newText),
|
|
167
|
+
}));
|
|
168
|
+
for (let i = 0; i < normalizedEdits.length; i++) {
|
|
169
|
+
if (normalizedEdits[i].oldText.length === 0) {
|
|
170
|
+
throw new Error(`oldText cannot be empty for edit in ${path}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const initialMatches = normalizedEdits.map((e) => fuzzyFindText(normalizedContent, e.oldText));
|
|
174
|
+
const usedFuzzyMatch = initialMatches.some((m) => m.usedFuzzyMatch);
|
|
175
|
+
const replacementBaseContent = usedFuzzyMatch
|
|
176
|
+
? normalizeForFuzzyMatch(normalizedContent)
|
|
177
|
+
: normalizedContent;
|
|
178
|
+
const matchedEdits = [];
|
|
179
|
+
for (let i = 0; i < normalizedEdits.length; i++) {
|
|
180
|
+
const edit = normalizedEdits[i];
|
|
181
|
+
const matchResult = fuzzyFindText(replacementBaseContent, edit.oldText);
|
|
182
|
+
if (!matchResult.found) {
|
|
183
|
+
throw new Error(`Could not find match for oldText in ${path}: ${JSON.stringify(edit.oldText.slice(0, 80))}`);
|
|
184
|
+
}
|
|
185
|
+
const occurrences = countOccurrences(replacementBaseContent, edit.oldText);
|
|
186
|
+
if (occurrences > 1) {
|
|
187
|
+
throw new Error(`Found ${occurrences} occurrences of oldText in ${path} (must be unique): ${JSON.stringify(edit.oldText.slice(0, 80))}`);
|
|
188
|
+
}
|
|
189
|
+
matchedEdits.push({
|
|
190
|
+
editIndex: i,
|
|
191
|
+
matchIndex: matchResult.index,
|
|
192
|
+
matchLength: matchResult.matchLength,
|
|
193
|
+
newText: edit.newText,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
matchedEdits.sort((a, b) => a.matchIndex - b.matchIndex);
|
|
197
|
+
for (let i = 1; i < matchedEdits.length; i++) {
|
|
198
|
+
const prev = matchedEdits[i - 1];
|
|
199
|
+
const curr = matchedEdits[i];
|
|
200
|
+
if (prev.matchIndex + prev.matchLength > curr.matchIndex) {
|
|
201
|
+
throw new Error(`Edits overlap in ${path}. Merge overlapping changes into a single edit.`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const baseContent = normalizedContent;
|
|
205
|
+
const newContent = usedFuzzyMatch
|
|
206
|
+
? applyReplacementsPreservingUnchangedLines(normalizedContent, replacementBaseContent, matchedEdits)
|
|
207
|
+
: applyReplacements(replacementBaseContent, matchedEdits);
|
|
208
|
+
if (baseContent === newContent) {
|
|
209
|
+
throw new Error(`No changes made to ${path}: newText equals oldText.`);
|
|
210
|
+
}
|
|
211
|
+
return { baseContent, newContent };
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Generate a standard unified patch.
|
|
215
|
+
*/
|
|
216
|
+
export function generateUnifiedPatch(path, oldContent, newContent, contextLines = 4) {
|
|
217
|
+
return Diff.createTwoFilesPatch(path, path, oldContent, newContent, undefined, undefined, {
|
|
218
|
+
context: contextLines,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Generate a display-oriented diff string with line numbers and context.
|
|
223
|
+
*/
|
|
224
|
+
export function generateDiffString(oldContent, newContent, contextLines = 4) {
|
|
225
|
+
const parts = Diff.diffLines(oldContent, newContent);
|
|
226
|
+
const output = [];
|
|
227
|
+
const oldLines = oldContent.split("\n");
|
|
228
|
+
const newLines = newContent.split("\n");
|
|
229
|
+
const maxLineNum = Math.max(oldLines.length, newLines.length);
|
|
230
|
+
const lineNumWidth = String(maxLineNum).length;
|
|
231
|
+
let oldLineNum = 1;
|
|
232
|
+
let newLineNum = 1;
|
|
233
|
+
let lastWasChange = false;
|
|
234
|
+
let firstChangedLine;
|
|
235
|
+
for (let i = 0; i < parts.length; i++) {
|
|
236
|
+
const part = parts[i];
|
|
237
|
+
const raw = part.value.split("\n");
|
|
238
|
+
if (raw[raw.length - 1] === "") {
|
|
239
|
+
raw.pop();
|
|
240
|
+
}
|
|
241
|
+
if (part.added || part.removed) {
|
|
242
|
+
if (firstChangedLine === undefined && part.added) {
|
|
243
|
+
firstChangedLine = newLineNum;
|
|
244
|
+
}
|
|
245
|
+
for (const line of raw) {
|
|
246
|
+
if (part.added) {
|
|
247
|
+
const lineNum = String(newLineNum).padStart(lineNumWidth, " ");
|
|
248
|
+
output.push(`+${lineNum} ${line}`);
|
|
249
|
+
newLineNum++;
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
|
|
253
|
+
output.push(`-${lineNum} ${line}`);
|
|
254
|
+
oldLineNum++;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
lastWasChange = true;
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed);
|
|
261
|
+
const hasLeadingChange = lastWasChange;
|
|
262
|
+
const hasTrailingChange = nextPartIsChange;
|
|
263
|
+
if (hasLeadingChange && hasTrailingChange) {
|
|
264
|
+
if (raw.length <= contextLines * 2) {
|
|
265
|
+
for (const line of raw) {
|
|
266
|
+
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
|
|
267
|
+
output.push(` ${lineNum} ${line}`);
|
|
268
|
+
oldLineNum++;
|
|
269
|
+
newLineNum++;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
const leadingLines = raw.slice(0, contextLines);
|
|
274
|
+
const trailingLines = raw.slice(raw.length - contextLines);
|
|
275
|
+
const skippedLines = raw.length - leadingLines.length - trailingLines.length;
|
|
276
|
+
for (const line of leadingLines) {
|
|
277
|
+
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
|
|
278
|
+
output.push(` ${lineNum} ${line}`);
|
|
279
|
+
oldLineNum++;
|
|
280
|
+
newLineNum++;
|
|
281
|
+
}
|
|
282
|
+
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
|
|
283
|
+
oldLineNum += skippedLines;
|
|
284
|
+
newLineNum += skippedLines;
|
|
285
|
+
for (const line of trailingLines) {
|
|
286
|
+
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
|
|
287
|
+
output.push(` ${lineNum} ${line}`);
|
|
288
|
+
oldLineNum++;
|
|
289
|
+
newLineNum++;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
else if (hasLeadingChange) {
|
|
294
|
+
const shownLines = raw.slice(0, contextLines);
|
|
295
|
+
const skippedLines = raw.length - shownLines.length;
|
|
296
|
+
for (const line of shownLines) {
|
|
297
|
+
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
|
|
298
|
+
output.push(` ${lineNum} ${line}`);
|
|
299
|
+
oldLineNum++;
|
|
300
|
+
newLineNum++;
|
|
301
|
+
}
|
|
302
|
+
if (skippedLines > 0) {
|
|
303
|
+
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
|
|
304
|
+
oldLineNum += skippedLines;
|
|
305
|
+
newLineNum += skippedLines;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
else if (hasTrailingChange) {
|
|
309
|
+
const skippedLines = Math.max(0, raw.length - contextLines);
|
|
310
|
+
if (skippedLines > 0) {
|
|
311
|
+
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
|
|
312
|
+
oldLineNum += skippedLines;
|
|
313
|
+
newLineNum += skippedLines;
|
|
314
|
+
}
|
|
315
|
+
for (const line of raw.slice(skippedLines)) {
|
|
316
|
+
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
|
|
317
|
+
output.push(` ${lineNum} ${line}`);
|
|
318
|
+
oldLineNum++;
|
|
319
|
+
newLineNum++;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
oldLineNum += raw.length;
|
|
324
|
+
newLineNum += raw.length;
|
|
325
|
+
}
|
|
326
|
+
lastWasChange = false;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return { diff: output.join("\n"), firstChangedLine };
|
|
330
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search and global replace utilities for Typst projects.
|
|
3
|
+
*/
|
|
4
|
+
import { generateDiffString, generateUnifiedPatch } from "./edit-diff.js";
|
|
5
|
+
export function matchGlob(path, pattern) {
|
|
6
|
+
if (!pattern || pattern === "*" || pattern === "**" || pattern === "**/*") {
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
const normalizedPath = path.replace(/^\/+/, "");
|
|
10
|
+
const fileName = normalizedPath.split("/").pop() ?? normalizedPath;
|
|
11
|
+
const escaped = pattern
|
|
12
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
13
|
+
.replace(/\*\*/g, ".*")
|
|
14
|
+
.replace(/(?<!\.)\*/g, "[^/]*")
|
|
15
|
+
.replace(/\?/g, ".");
|
|
16
|
+
const re = new RegExp(`^${escaped}$`, "i");
|
|
17
|
+
return re.test(normalizedPath) || re.test(fileName);
|
|
18
|
+
}
|
|
19
|
+
export function searchInContent(filePath, content, options) {
|
|
20
|
+
const lines = content.split("\n");
|
|
21
|
+
const matches = [];
|
|
22
|
+
const limit = options.limit ?? 100;
|
|
23
|
+
const context = Math.max(0, options.context ?? 0);
|
|
24
|
+
let regex;
|
|
25
|
+
if (options.literal) {
|
|
26
|
+
const escaped = options.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
27
|
+
regex = new RegExp(escaped, options.ignoreCase ? "i" : "");
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
try {
|
|
31
|
+
regex = new RegExp(options.pattern, options.ignoreCase ? "i" : "");
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
const escaped = options.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
35
|
+
regex = new RegExp(escaped, options.ignoreCase ? "i" : "");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
let totalMatches = 0;
|
|
39
|
+
for (let i = 0; i < lines.length; i++) {
|
|
40
|
+
const line = lines[i];
|
|
41
|
+
if (regex.test(line)) {
|
|
42
|
+
totalMatches++;
|
|
43
|
+
if (matches.length < limit) {
|
|
44
|
+
const contextBefore = context > 0 ? lines.slice(Math.max(0, i - context), i) : undefined;
|
|
45
|
+
const contextAfter = context > 0
|
|
46
|
+
? lines.slice(i + 1, Math.min(lines.length, i + 1 + context))
|
|
47
|
+
: undefined;
|
|
48
|
+
matches.push({
|
|
49
|
+
file: filePath,
|
|
50
|
+
line: i + 1,
|
|
51
|
+
text: line,
|
|
52
|
+
contextBefore,
|
|
53
|
+
contextAfter,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { matches, totalMatches };
|
|
59
|
+
}
|
|
60
|
+
export function replaceInContent(filePath, content, options) {
|
|
61
|
+
let flags = "g";
|
|
62
|
+
if (options.ignoreCase)
|
|
63
|
+
flags += "i";
|
|
64
|
+
let searchRegex;
|
|
65
|
+
if (options.regex) {
|
|
66
|
+
searchRegex = new RegExp(options.pattern, flags);
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
const escaped = options.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
70
|
+
searchRegex = new RegExp(escaped, flags);
|
|
71
|
+
}
|
|
72
|
+
let count = 0;
|
|
73
|
+
const limit = options.limit;
|
|
74
|
+
const updatedText = content.replace(searchRegex, (match, ...args) => {
|
|
75
|
+
if (limit !== undefined && count >= limit) {
|
|
76
|
+
return match;
|
|
77
|
+
}
|
|
78
|
+
count++;
|
|
79
|
+
if (!options.regex) {
|
|
80
|
+
return options.replacement;
|
|
81
|
+
}
|
|
82
|
+
// For regex, support standard replacement with capture groups ($1, $2)
|
|
83
|
+
return options.replacement.replace(/\$(\d+)/g, (_, groupIdx) => {
|
|
84
|
+
const idx = parseInt(groupIdx, 10);
|
|
85
|
+
return args[idx - 1] ?? "";
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
const diffResult = generateDiffString(content, updatedText);
|
|
89
|
+
const patch = generateUnifiedPatch(filePath, content, updatedText);
|
|
90
|
+
return {
|
|
91
|
+
updatedText,
|
|
92
|
+
replacementCount: count,
|
|
93
|
+
diff: diffResult.diff,
|
|
94
|
+
patch,
|
|
95
|
+
firstChangedLine: diffResult.firstChangedLine,
|
|
96
|
+
};
|
|
97
|
+
}
|
package/dist/protocol/yjs-doc.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import * as Y from "yjs";
|
|
2
|
+
import * as Diff from "diff";
|
|
3
|
+
import { applyEditsToNormalizedContent, detectLineEnding, generateDiffString, generateUnifiedPatch, normalizeToLF, restoreLineEndings, splitBom, } from "./edit-diff.js";
|
|
2
4
|
function computeUpdateDiff(doc, remoteUpdate) {
|
|
3
5
|
let stateVector;
|
|
4
6
|
if (remoteUpdate.byteLength > 0) {
|
|
@@ -73,11 +75,11 @@ export function replaceYjsText(remoteUpdate, newContent) {
|
|
|
73
75
|
}
|
|
74
76
|
}
|
|
75
77
|
/**
|
|
76
|
-
* Applies one or more
|
|
77
|
-
* directly on the `Y.Text("text")` CRDT
|
|
78
|
-
*
|
|
78
|
+
* Applies one or more surgical text replacements (`oldText` -> `newText`)
|
|
79
|
+
* directly on the `Y.Text("text")` CRDT, with CRLF/BOM resilience, fuzzy matching,
|
|
80
|
+
* and unified diff generation.
|
|
79
81
|
*/
|
|
80
|
-
export function editYjsText(remoteUpdate, edits) {
|
|
82
|
+
export function editYjsText(remoteUpdate, edits, filePath = "file") {
|
|
81
83
|
if (edits.length === 0) {
|
|
82
84
|
throw new Error("At least one edit operation is required");
|
|
83
85
|
}
|
|
@@ -88,49 +90,40 @@ export function editYjsText(remoteUpdate, edits) {
|
|
|
88
90
|
}
|
|
89
91
|
const ytext = doc.getText("text");
|
|
90
92
|
const previousText = ytext.toString();
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
throw new Error(`Could not find exact oldText match: ${JSON.stringify(edit.oldText.slice(0, 80))}`);
|
|
99
|
-
}
|
|
100
|
-
const secondIdx = previousText.indexOf(edit.oldText, firstIdx + 1);
|
|
101
|
-
if (secondIdx !== -1) {
|
|
102
|
-
throw new Error(`oldText is not unique in file (found at offset ${firstIdx} and ${secondIdx}): ${JSON.stringify(edit.oldText.slice(0, 80))}`);
|
|
103
|
-
}
|
|
104
|
-
matchedRegions.push({
|
|
105
|
-
start: firstIdx,
|
|
106
|
-
end: firstIdx + edit.oldText.length,
|
|
107
|
-
oldText: edit.oldText,
|
|
108
|
-
newText: edit.newText,
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
matchedRegions.sort((a, b) => b.start - a.start);
|
|
112
|
-
for (let i = 0; i < matchedRegions.length - 1; i++) {
|
|
113
|
-
const upper = matchedRegions[i];
|
|
114
|
-
const lower = matchedRegions[i + 1];
|
|
115
|
-
if (lower.end > upper.start) {
|
|
116
|
-
throw new Error(`Overlapping edit regions detected between [${lower.start}, ${lower.end}) and [${upper.start}, ${upper.end})`);
|
|
117
|
-
}
|
|
118
|
-
}
|
|
93
|
+
const { bom, text: contentWithoutBom } = splitBom(previousText);
|
|
94
|
+
const originalEnding = detectLineEnding(contentWithoutBom);
|
|
95
|
+
const normalizedContent = normalizeToLF(contentWithoutBom);
|
|
96
|
+
const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, filePath);
|
|
97
|
+
const finalContent = bom + restoreLineEndings(newContent, originalEnding);
|
|
98
|
+
// Apply minimal surgical character changes to Y.Text
|
|
99
|
+
const diffs = Diff.diffChars(previousText, finalContent);
|
|
119
100
|
doc.transact(() => {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if (
|
|
123
|
-
ytext.insert(
|
|
101
|
+
let offset = 0;
|
|
102
|
+
for (const part of diffs) {
|
|
103
|
+
if (part.added) {
|
|
104
|
+
ytext.insert(offset, part.value);
|
|
105
|
+
offset += part.value.length;
|
|
106
|
+
}
|
|
107
|
+
else if (part.removed) {
|
|
108
|
+
ytext.delete(offset, part.value.length);
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
offset += part.value.length;
|
|
124
112
|
}
|
|
125
113
|
}
|
|
126
114
|
});
|
|
127
115
|
const { encodedUpdate, mergedState } = computeUpdateDiff(doc, remoteUpdate);
|
|
116
|
+
const diffResult = generateDiffString(baseContent, newContent);
|
|
117
|
+
const patch = generateUnifiedPatch(filePath, baseContent, newContent);
|
|
128
118
|
return {
|
|
129
119
|
encodedUpdate,
|
|
130
120
|
mergedState,
|
|
131
121
|
previousText,
|
|
132
|
-
updatedText:
|
|
133
|
-
appliedCount:
|
|
122
|
+
updatedText: finalContent,
|
|
123
|
+
appliedCount: edits.length,
|
|
124
|
+
diff: diffResult.diff,
|
|
125
|
+
patch,
|
|
126
|
+
firstChangedLine: diffResult.firstChangedLine,
|
|
134
127
|
};
|
|
135
128
|
}
|
|
136
129
|
finally {
|