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.
@@ -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
+ }
@@ -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 exact surgical text replacements (`oldText` -> `newText`)
77
- * directly on the `Y.Text("text")` CRDT so concurrent collaborator cursors and
78
- * history are preserved.
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 matchedRegions = [];
92
- for (const edit of edits) {
93
- if (!edit.oldText) {
94
- throw new Error("oldText cannot be empty in surgical edit");
95
- }
96
- const firstIdx = previousText.indexOf(edit.oldText);
97
- if (firstIdx === -1) {
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
- for (const region of matchedRegions) {
121
- ytext.delete(region.start, region.oldText.length);
122
- if (region.newText.length > 0) {
123
- ytext.insert(region.start, region.newText);
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: ytext.toString(),
133
- appliedCount: matchedRegions.length,
122
+ updatedText: finalContent,
123
+ appliedCount: edits.length,
124
+ diff: diffResult.diff,
125
+ patch,
126
+ firstChangedLine: diffResult.firstChangedLine,
134
127
  };
135
128
  }
136
129
  finally {