@claudiu-ceia/spatch 0.3.0 → 0.3.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/README.md +187 -114
- package/dist/app.d.ts +1 -0
- package/dist/app.js +28 -0
- package/dist/cli.js +3 -119
- package/dist/command/flags.d.ts +64 -0
- package/dist/command/flags.js +73 -0
- package/dist/command/interactive/path-resolution.d.ts +5 -0
- package/dist/command/interactive/path-resolution.js +55 -0
- package/dist/command/interactive/run.d.ts +3 -0
- package/dist/command/interactive/run.js +166 -0
- package/dist/command/interactive/terminal.d.ts +32 -0
- package/dist/command/interactive/terminal.js +79 -0
- package/dist/command/interactive/types.d.ts +13 -0
- package/dist/command/interactive/types.js +0 -0
- package/dist/command/interactive/validation.d.ts +2 -0
- package/dist/command/interactive/validation.js +19 -0
- package/dist/command/interactive.d.ts +1 -0
- package/dist/command/interactive.js +1 -0
- package/dist/command/output.d.ts +11 -0
- package/dist/command/output.js +82 -0
- package/dist/command.d.ts +22 -25
- package/dist/command.js +36 -334
- package/dist/file-write.d.ts +24 -0
- package/dist/file-write.js +50 -0
- package/dist/index.d.ts +3 -5
- package/dist/index.js +2 -3
- package/dist/internal/command.d.ts +1 -0
- package/dist/internal/command.js +1 -0
- package/dist/phases/output.d.ts +2 -1
- package/dist/phases/parse.d.ts +2 -2
- package/dist/phases/parse.js +6 -6
- package/dist/phases/patch-document.d.ts +6 -0
- package/dist/{patch-document.js → phases/patch-document.js} +6 -15
- package/dist/phases/rewrite.js +128 -33
- package/dist/replacement-spans.d.ts +7 -0
- package/dist/replacement-spans.js +26 -0
- package/dist/spatch.d.ts +0 -1
- package/dist/spatch.js +1 -2
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +19 -13
- package/src/app.ts +34 -0
- package/src/cli.ts +3 -143
- package/src/command/flags.ts +85 -0
- package/src/command/interactive/path-resolution.ts +72 -0
- package/src/command/interactive/run.ts +207 -0
- package/src/command/interactive/terminal.ts +134 -0
- package/src/command/interactive/types.ts +20 -0
- package/src/command/interactive/validation.ts +36 -0
- package/src/command/interactive.ts +1 -0
- package/src/command/output.ts +109 -0
- package/src/command.ts +82 -484
- package/src/file-write.ts +80 -0
- package/src/index.ts +3 -21
- package/src/internal/command.ts +1 -0
- package/src/phases/output.ts +1 -1
- package/src/phases/parse.ts +7 -7
- package/src/{patch-document.ts → phases/patch-document.ts} +16 -30
- package/src/phases/rewrite.ts +177 -53
- package/src/replacement-spans.ts +37 -0
- package/src/spatch.ts +1 -6
- package/dist/patch-document.d.ts +0 -9
- package/dist/template.d.ts +0 -2
- package/dist/template.js +0 -1
- package/src/template.ts +0 -2
package/dist/command.js
CHANGED
|
@@ -1,143 +1,58 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { stderr as processStderr, stdin as processStdin, stdout as processStdout, } from "node:process";
|
|
4
|
-
import { createInterface } from "node:readline/promises";
|
|
1
|
+
import { stderr as processStderr, stdin as processStdin } from "node:process";
|
|
5
2
|
import { buildCommand } from "@stricli/core";
|
|
6
|
-
import chalk, { Chalk } from "chalk";
|
|
7
3
|
import { resolveTextInput } from "@claudiu-ceia/astkit-core";
|
|
4
|
+
import { patchCommandFlagParameters, validatePatchCommandFlags, } from "./command/flags.js";
|
|
5
|
+
import { runInteractivePatchCommand } from "./command/interactive.js";
|
|
6
|
+
import { formatPatchOutput } from "./command/output.js";
|
|
8
7
|
import { patchProject } from "./spatch.js";
|
|
9
8
|
export async function runPatchCommand(patchInput, scope, flags, options = {}) {
|
|
9
|
+
validatePatchCommandFlags(flags);
|
|
10
10
|
const patchScope = scope ?? ".";
|
|
11
11
|
const patchCwd = flags.cwd;
|
|
12
|
+
const logger = options.logger ??
|
|
13
|
+
(flags.verbose ? (line) => processStderr.write(`${line}\n`) : undefined);
|
|
12
14
|
const resolvedPatchInput = await resolvePatchInput(patchInput, {
|
|
13
15
|
cwd: patchCwd,
|
|
14
16
|
encoding: "utf8",
|
|
15
17
|
readStdin: options.readStdin,
|
|
18
|
+
stdinStream: options.stdinStream,
|
|
16
19
|
});
|
|
17
|
-
|
|
18
|
-
if (flags["dry-run"] ?? false) {
|
|
19
|
-
throw new Error("Cannot combine --interactive with --dry-run.");
|
|
20
|
-
}
|
|
21
|
-
return runInteractivePatchCommand(resolvedPatchInput, patchScope, patchCwd, flags["no-color"] ?? false, options.interactiveDecider);
|
|
22
|
-
}
|
|
23
|
-
return patchProject(resolvedPatchInput, {
|
|
20
|
+
const patchOptions = {
|
|
24
21
|
concurrency: flags.concurrency,
|
|
25
22
|
cwd: patchCwd,
|
|
26
|
-
|
|
23
|
+
encoding: options.encoding,
|
|
24
|
+
logger,
|
|
27
25
|
scope: patchScope,
|
|
28
26
|
verbose: flags.verbose,
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const changedFiles = result.files.filter((file) => file.replacementCount > 0);
|
|
37
|
-
for (const file of changedFiles) {
|
|
38
|
-
const headerPrefix = useColor ? chalkInstance.bold : (value) => value;
|
|
39
|
-
lines.push(headerPrefix(`diff --git a/${file.file} b/${file.file}`));
|
|
40
|
-
lines.push(useColor ? chalkInstance.gray(`--- a/${file.file}`) : `--- a/${file.file}`);
|
|
41
|
-
lines.push(useColor ? chalkInstance.gray(`+++ b/${file.file}`) : `+++ b/${file.file}`);
|
|
42
|
-
for (const occurrence of file.occurrences) {
|
|
43
|
-
if (occurrence.matched === occurrence.replacement) {
|
|
44
|
-
continue;
|
|
45
|
-
}
|
|
46
|
-
const oldCount = countLines(occurrence.matched);
|
|
47
|
-
const newCount = countLines(occurrence.replacement);
|
|
48
|
-
const hunkHeader = `@@ -${occurrence.line},${oldCount} +${occurrence.line},${newCount} @@`;
|
|
49
|
-
lines.push(useColor ? chalkInstance.cyan(hunkHeader) : hunkHeader);
|
|
50
|
-
for (const oldLine of splitDiffLines(occurrence.matched)) {
|
|
51
|
-
const line = `-${oldLine}`;
|
|
52
|
-
lines.push(useColor ? chalkInstance.red(line) : line);
|
|
53
|
-
}
|
|
54
|
-
for (const newLine of splitDiffLines(occurrence.replacement)) {
|
|
55
|
-
const line = `+${newLine}`;
|
|
56
|
-
lines.push(useColor ? chalkInstance.green(line) : line);
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
if (changedFiles.length === 0) {
|
|
61
|
-
lines.push(useColor ? chalkInstance.gray("No changes.") : "No changes.");
|
|
27
|
+
};
|
|
28
|
+
if (flags.interactive ?? false) {
|
|
29
|
+
return runInteractivePatchCommand(resolvedPatchInput, {
|
|
30
|
+
...patchOptions,
|
|
31
|
+
noColor: flags["no-color"] ?? false,
|
|
32
|
+
interactiveDecider: options.interactiveDecider,
|
|
33
|
+
});
|
|
62
34
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
]
|
|
68
|
-
.filter((part) => part !== null)
|
|
69
|
-
.join(", ");
|
|
70
|
-
lines.push(useColor ? chalkInstance.gray(summary) : summary);
|
|
71
|
-
return lines.join("\n");
|
|
35
|
+
return patchProject(resolvedPatchInput, {
|
|
36
|
+
...patchOptions,
|
|
37
|
+
dryRun: (flags["dry-run"] ?? false) || (flags.check ?? false),
|
|
38
|
+
});
|
|
72
39
|
}
|
|
73
40
|
export const patchCommand = buildCommand({
|
|
74
41
|
async func(flags, patchInput, scope) {
|
|
75
42
|
const result = await runPatchCommand(patchInput, scope, flags);
|
|
76
43
|
if (flags.json ?? false) {
|
|
77
44
|
this.process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
45
|
+
enforceCheckMode(flags, result);
|
|
78
46
|
return;
|
|
79
47
|
}
|
|
80
48
|
const output = formatPatchOutput(result, {
|
|
81
|
-
color: Boolean(
|
|
49
|
+
color: Boolean(this.process.stdout.isTTY) && !(flags["no-color"] ?? false),
|
|
82
50
|
});
|
|
83
51
|
this.process.stdout.write(`${output}\n`);
|
|
52
|
+
enforceCheckMode(flags, result);
|
|
84
53
|
},
|
|
85
54
|
parameters: {
|
|
86
|
-
flags:
|
|
87
|
-
concurrency: {
|
|
88
|
-
kind: "parsed",
|
|
89
|
-
optional: true,
|
|
90
|
-
brief: "Max files processed concurrently (default: 8)",
|
|
91
|
-
placeholder: "n",
|
|
92
|
-
parse: (input) => {
|
|
93
|
-
const value = Number(input);
|
|
94
|
-
if (!Number.isFinite(value) || value <= 0) {
|
|
95
|
-
throw new Error("--concurrency must be a positive number");
|
|
96
|
-
}
|
|
97
|
-
return Math.floor(value);
|
|
98
|
-
},
|
|
99
|
-
},
|
|
100
|
-
verbose: {
|
|
101
|
-
kind: "parsed",
|
|
102
|
-
optional: true,
|
|
103
|
-
brief: "Print perf tracing (1=summary, 2=includes slow files)",
|
|
104
|
-
placeholder: "level",
|
|
105
|
-
parse: (input) => {
|
|
106
|
-
const value = Number(input);
|
|
107
|
-
if (!Number.isFinite(value) || value < 0) {
|
|
108
|
-
throw new Error("--verbose must be a non-negative number");
|
|
109
|
-
}
|
|
110
|
-
return Math.floor(value);
|
|
111
|
-
},
|
|
112
|
-
},
|
|
113
|
-
interactive: {
|
|
114
|
-
kind: "boolean",
|
|
115
|
-
optional: true,
|
|
116
|
-
brief: "Interactively select which matches to apply",
|
|
117
|
-
},
|
|
118
|
-
json: {
|
|
119
|
-
kind: "boolean",
|
|
120
|
-
optional: true,
|
|
121
|
-
brief: "Output structured JSON instead of compact diff-style text",
|
|
122
|
-
},
|
|
123
|
-
"no-color": {
|
|
124
|
-
kind: "boolean",
|
|
125
|
-
optional: true,
|
|
126
|
-
brief: "Disable colored output",
|
|
127
|
-
},
|
|
128
|
-
"dry-run": {
|
|
129
|
-
kind: "boolean",
|
|
130
|
-
optional: true,
|
|
131
|
-
brief: "Preview changes without writing files",
|
|
132
|
-
},
|
|
133
|
-
cwd: {
|
|
134
|
-
kind: "parsed",
|
|
135
|
-
optional: true,
|
|
136
|
-
brief: "Working directory for resolving patch file and scope",
|
|
137
|
-
placeholder: "path",
|
|
138
|
-
parse: (input) => input,
|
|
139
|
-
},
|
|
140
|
-
},
|
|
55
|
+
flags: patchCommandFlagParameters,
|
|
141
56
|
positional: {
|
|
142
57
|
kind: "tuple",
|
|
143
58
|
parameters: [
|
|
@@ -159,244 +74,31 @@ export const patchCommand = buildCommand({
|
|
|
159
74
|
brief: "Apply structural rewrite from a patch document",
|
|
160
75
|
},
|
|
161
76
|
});
|
|
162
|
-
function
|
|
163
|
-
if (
|
|
164
|
-
return
|
|
165
|
-
}
|
|
166
|
-
const shouldColor = options.color ?? false;
|
|
167
|
-
if (!shouldColor) {
|
|
168
|
-
return new Chalk({ level: 0 });
|
|
169
|
-
}
|
|
170
|
-
const level = chalk.level > 0 ? chalk.level : 1;
|
|
171
|
-
return new Chalk({ level });
|
|
172
|
-
}
|
|
173
|
-
function splitDiffLines(text) {
|
|
174
|
-
const normalized = text.replaceAll("\r\n", "\n");
|
|
175
|
-
if (normalized.length === 0) {
|
|
176
|
-
return [""];
|
|
77
|
+
function enforceCheckMode(flags, result) {
|
|
78
|
+
if (!(flags.check ?? false)) {
|
|
79
|
+
return;
|
|
177
80
|
}
|
|
178
|
-
|
|
179
|
-
}
|
|
180
|
-
function countLines(text) {
|
|
181
|
-
const normalized = text.replaceAll("\r\n", "\n");
|
|
182
|
-
if (normalized.length === 0) {
|
|
183
|
-
return 0;
|
|
81
|
+
if (result.totalReplacements > 0) {
|
|
82
|
+
throw new Error(`Check failed: ${result.totalReplacements} replacements would be applied in ${result.filesChanged} files.`);
|
|
184
83
|
}
|
|
185
|
-
return normalized.split("\n").length;
|
|
186
|
-
}
|
|
187
|
-
function pluralize(word, count) {
|
|
188
|
-
return count === 1 ? word : `${word}s`;
|
|
189
|
-
}
|
|
190
|
-
async function runInteractivePatchCommand(patchInput, scope, cwd, noColor, interactiveDecider) {
|
|
191
|
-
if (!interactiveDecider && (!processStdin.isTTY || !processStdout.isTTY)) {
|
|
192
|
-
throw new Error("Interactive mode requires a TTY stdin/stdout.");
|
|
193
|
-
}
|
|
194
|
-
const startedAt = Date.now();
|
|
195
|
-
const dryResult = await patchProject(patchInput, {
|
|
196
|
-
cwd,
|
|
197
|
-
dryRun: true,
|
|
198
|
-
scope,
|
|
199
|
-
});
|
|
200
|
-
const totalChanges = dryResult.files.reduce((count, file) => count +
|
|
201
|
-
file.occurrences.filter((occurrence) => occurrence.matched !== occurrence.replacement).length, 0);
|
|
202
|
-
let interactivePrompt = null;
|
|
203
|
-
const decider = interactiveDecider ??
|
|
204
|
-
((interactivePrompt = await createTerminalInteractiveDecider(noColor)),
|
|
205
|
-
interactivePrompt.decider);
|
|
206
|
-
const selectedByFile = new Map();
|
|
207
|
-
let applyAll = false;
|
|
208
|
-
let stop = false;
|
|
209
|
-
let changeNumber = 0;
|
|
210
|
-
try {
|
|
211
|
-
for (const file of dryResult.files) {
|
|
212
|
-
const selected = [];
|
|
213
|
-
for (const occurrence of file.occurrences) {
|
|
214
|
-
if (occurrence.matched === occurrence.replacement) {
|
|
215
|
-
continue;
|
|
216
|
-
}
|
|
217
|
-
changeNumber += 1;
|
|
218
|
-
if (applyAll) {
|
|
219
|
-
selected.push(occurrence);
|
|
220
|
-
continue;
|
|
221
|
-
}
|
|
222
|
-
const choice = await decider({
|
|
223
|
-
file: file.file,
|
|
224
|
-
occurrence,
|
|
225
|
-
changeNumber,
|
|
226
|
-
totalChanges,
|
|
227
|
-
});
|
|
228
|
-
if (choice === "yes") {
|
|
229
|
-
selected.push(occurrence);
|
|
230
|
-
continue;
|
|
231
|
-
}
|
|
232
|
-
if (choice === "all") {
|
|
233
|
-
applyAll = true;
|
|
234
|
-
selected.push(occurrence);
|
|
235
|
-
continue;
|
|
236
|
-
}
|
|
237
|
-
if (choice === "quit") {
|
|
238
|
-
stop = true;
|
|
239
|
-
break;
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
selectedByFile.set(file.file, selected);
|
|
243
|
-
if (stop) {
|
|
244
|
-
break;
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
finally {
|
|
249
|
-
interactivePrompt?.close();
|
|
250
|
-
}
|
|
251
|
-
const fileResults = [];
|
|
252
|
-
let filesChanged = 0;
|
|
253
|
-
let totalReplacements = 0;
|
|
254
|
-
for (const file of dryResult.files) {
|
|
255
|
-
const selected = selectedByFile.get(file.file) ?? [];
|
|
256
|
-
if (selected.length === 0) {
|
|
257
|
-
fileResults.push({
|
|
258
|
-
...file,
|
|
259
|
-
replacementCount: 0,
|
|
260
|
-
changed: false,
|
|
261
|
-
byteDelta: 0,
|
|
262
|
-
occurrences: [],
|
|
263
|
-
});
|
|
264
|
-
continue;
|
|
265
|
-
}
|
|
266
|
-
const absolutePath = path.resolve(cwd ?? process.cwd(), file.file);
|
|
267
|
-
const originalText = await readFile(absolutePath, "utf8");
|
|
268
|
-
const rewrittenText = applySelectedOccurrences(originalText, selected);
|
|
269
|
-
const changed = rewrittenText !== originalText;
|
|
270
|
-
if (changed) {
|
|
271
|
-
await writeFile(absolutePath, rewrittenText, "utf8");
|
|
272
|
-
}
|
|
273
|
-
const replacementCount = selected.filter((occurrence) => occurrence.matched !== occurrence.replacement).length;
|
|
274
|
-
totalReplacements += replacementCount;
|
|
275
|
-
if (changed) {
|
|
276
|
-
filesChanged += 1;
|
|
277
|
-
}
|
|
278
|
-
fileResults.push({
|
|
279
|
-
...file,
|
|
280
|
-
replacementCount,
|
|
281
|
-
changed,
|
|
282
|
-
byteDelta: changed
|
|
283
|
-
? Buffer.byteLength(rewrittenText, "utf8") -
|
|
284
|
-
Buffer.byteLength(originalText, "utf8")
|
|
285
|
-
: 0,
|
|
286
|
-
occurrences: selected,
|
|
287
|
-
});
|
|
288
|
-
}
|
|
289
|
-
return {
|
|
290
|
-
...dryResult,
|
|
291
|
-
dryRun: false,
|
|
292
|
-
filesChanged,
|
|
293
|
-
totalReplacements,
|
|
294
|
-
elapsedMs: Date.now() - startedAt,
|
|
295
|
-
files: fileResults,
|
|
296
|
-
};
|
|
297
84
|
}
|
|
298
85
|
async function resolvePatchInput(patchInput, options) {
|
|
299
86
|
if (patchInput !== "-") {
|
|
300
87
|
return await resolveTextInput(patchInput, { cwd: options.cwd, encoding: options.encoding });
|
|
301
88
|
}
|
|
302
|
-
const reader = options.readStdin ??
|
|
89
|
+
const reader = options.readStdin ??
|
|
90
|
+
(() => readAllFromStream(options.stdinStream ?? processStdin, options.encoding));
|
|
303
91
|
const text = await reader();
|
|
304
92
|
if (text.length === 0) {
|
|
305
93
|
throw new Error("Patch document read from stdin was empty.");
|
|
306
94
|
}
|
|
307
95
|
return text;
|
|
308
96
|
}
|
|
309
|
-
async function
|
|
310
|
-
|
|
311
|
-
// `node:process` stdin is a stream in both Node and Bun.
|
|
312
|
-
const stdin = processStdin;
|
|
313
|
-
stdin.setEncoding(encoding);
|
|
97
|
+
async function readAllFromStream(stream, encoding) {
|
|
98
|
+
stream.setEncoding?.(encoding);
|
|
314
99
|
let text = "";
|
|
315
|
-
for await (const chunk of
|
|
100
|
+
for await (const chunk of stream) {
|
|
316
101
|
text += String(chunk);
|
|
317
102
|
}
|
|
318
103
|
return text;
|
|
319
104
|
}
|
|
320
|
-
function applySelectedOccurrences(source, occurrences) {
|
|
321
|
-
if (occurrences.length === 0) {
|
|
322
|
-
return source;
|
|
323
|
-
}
|
|
324
|
-
const sorted = [...occurrences].sort((left, right) => left.start - right.start);
|
|
325
|
-
const parts = [];
|
|
326
|
-
let cursor = 0;
|
|
327
|
-
for (const occurrence of sorted) {
|
|
328
|
-
parts.push(source.slice(cursor, occurrence.start));
|
|
329
|
-
parts.push(occurrence.replacement);
|
|
330
|
-
cursor = occurrence.end;
|
|
331
|
-
}
|
|
332
|
-
parts.push(source.slice(cursor));
|
|
333
|
-
return parts.join("");
|
|
334
|
-
}
|
|
335
|
-
async function createTerminalInteractiveDecider(noColor) {
|
|
336
|
-
const chalkInstance = buildChalk({
|
|
337
|
-
color: processStdout.isTTY && !noColor,
|
|
338
|
-
});
|
|
339
|
-
const useColor = chalkInstance.level > 0;
|
|
340
|
-
const rl = createInterface({
|
|
341
|
-
input: processStdin,
|
|
342
|
-
output: processStdout,
|
|
343
|
-
});
|
|
344
|
-
return {
|
|
345
|
-
decider: async ({ file, occurrence, changeNumber, totalChanges }) => {
|
|
346
|
-
processStdout.write(`\n${formatInteractiveChangeBlock({ file, occurrence, changeNumber, totalChanges }, {
|
|
347
|
-
chalkInstance,
|
|
348
|
-
color: useColor,
|
|
349
|
-
})}\n`);
|
|
350
|
-
while (true) {
|
|
351
|
-
const answer = await rl.question(useColor
|
|
352
|
-
? chalkInstance.bold("Choice [y/n/a/q] (default: n): ")
|
|
353
|
-
: "Choice [y/n/a/q] (default: n): ");
|
|
354
|
-
const parsed = parseInteractiveChoice(answer);
|
|
355
|
-
if (parsed) {
|
|
356
|
-
return parsed;
|
|
357
|
-
}
|
|
358
|
-
processStdout.write(useColor
|
|
359
|
-
? `${chalkInstance.yellow("Invalid choice.")} Use y, n, a, or q.\n`
|
|
360
|
-
: "Invalid choice. Use y, n, a, or q.\n");
|
|
361
|
-
}
|
|
362
|
-
},
|
|
363
|
-
close: () => rl.close(),
|
|
364
|
-
};
|
|
365
|
-
}
|
|
366
|
-
function formatInteractiveChangeBlock(ctx, options = {}) {
|
|
367
|
-
const chalkInstance = buildChalk(options);
|
|
368
|
-
const useColor = chalkInstance.level > 0;
|
|
369
|
-
const divider = "─".repeat(72);
|
|
370
|
-
const oldCount = countLines(ctx.occurrence.matched);
|
|
371
|
-
const newCount = countLines(ctx.occurrence.replacement);
|
|
372
|
-
const hunkHeader = `@@ -${ctx.occurrence.line},${oldCount} +${ctx.occurrence.line},${newCount} @@`;
|
|
373
|
-
const lines = [
|
|
374
|
-
useColor ? chalkInstance.gray(divider) : divider,
|
|
375
|
-
useColor
|
|
376
|
-
? chalkInstance.bold(`Change ${ctx.changeNumber}/${ctx.totalChanges} · ${ctx.file}:${ctx.occurrence.line}:${ctx.occurrence.character}`)
|
|
377
|
-
: `Change ${ctx.changeNumber}/${ctx.totalChanges} · ${ctx.file}:${ctx.occurrence.line}:${ctx.occurrence.character}`,
|
|
378
|
-
useColor ? chalkInstance.cyan(hunkHeader) : hunkHeader,
|
|
379
|
-
...splitDiffLines(ctx.occurrence.matched).map((line) => useColor ? chalkInstance.red(`-${line}`) : `-${line}`),
|
|
380
|
-
...splitDiffLines(ctx.occurrence.replacement).map((line) => useColor ? chalkInstance.green(`+${line}`) : `+${line}`),
|
|
381
|
-
useColor
|
|
382
|
-
? chalkInstance.gray("Actions: [y] apply · [n] skip · [a] apply remaining · [q] quit")
|
|
383
|
-
: "Actions: [y] apply · [n] skip · [a] apply remaining · [q] quit",
|
|
384
|
-
];
|
|
385
|
-
return lines.join("\n");
|
|
386
|
-
}
|
|
387
|
-
function parseInteractiveChoice(answer) {
|
|
388
|
-
const normalized = answer.trim().toLowerCase();
|
|
389
|
-
if (normalized.length === 0 || normalized === "n" || normalized === "no") {
|
|
390
|
-
return "no";
|
|
391
|
-
}
|
|
392
|
-
if (normalized === "y" || normalized === "yes") {
|
|
393
|
-
return "yes";
|
|
394
|
-
}
|
|
395
|
-
if (normalized === "a" || normalized === "all") {
|
|
396
|
-
return "all";
|
|
397
|
-
}
|
|
398
|
-
if (normalized === "q" || normalized === "quit") {
|
|
399
|
-
return "quit";
|
|
400
|
-
}
|
|
401
|
-
return null;
|
|
402
|
-
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
type WriteFileIfUnchangedAtomicallyInput = {
|
|
2
|
+
filePath: string;
|
|
3
|
+
originalText: string;
|
|
4
|
+
rewrittenText: string;
|
|
5
|
+
encoding: BufferEncoding;
|
|
6
|
+
operationName: string;
|
|
7
|
+
fs?: FileWriteFs;
|
|
8
|
+
};
|
|
9
|
+
type FileWriteFs = {
|
|
10
|
+
readFile: (path: string, encoding: BufferEncoding) => Promise<string>;
|
|
11
|
+
stat: (path: string) => Promise<{
|
|
12
|
+
mode: number;
|
|
13
|
+
}>;
|
|
14
|
+
writeFile: (path: string, data: string, options: {
|
|
15
|
+
encoding: BufferEncoding;
|
|
16
|
+
mode: number;
|
|
17
|
+
}) => Promise<void>;
|
|
18
|
+
rename: (oldPath: string, newPath: string) => Promise<void>;
|
|
19
|
+
rm: (path: string, options: {
|
|
20
|
+
force: boolean;
|
|
21
|
+
}) => Promise<void>;
|
|
22
|
+
};
|
|
23
|
+
export declare function writeFileIfUnchangedAtomically(input: WriteFileIfUnchangedAtomicallyInput): Promise<void>;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const defaultFs = {
|
|
5
|
+
readFile,
|
|
6
|
+
stat,
|
|
7
|
+
writeFile,
|
|
8
|
+
rename,
|
|
9
|
+
rm,
|
|
10
|
+
};
|
|
11
|
+
export async function writeFileIfUnchangedAtomically(input) {
|
|
12
|
+
const fs = input.fs ?? defaultFs;
|
|
13
|
+
let currentText;
|
|
14
|
+
try {
|
|
15
|
+
currentText = await fs.readFile(input.filePath, input.encoding);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
throw buildStaleApplyError(input.filePath, input.operationName);
|
|
19
|
+
}
|
|
20
|
+
if (currentText !== input.originalText) {
|
|
21
|
+
throw buildStaleApplyError(input.filePath, input.operationName);
|
|
22
|
+
}
|
|
23
|
+
let fileStats;
|
|
24
|
+
try {
|
|
25
|
+
fileStats = await fs.stat(input.filePath);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
throw buildStaleApplyError(input.filePath, input.operationName);
|
|
29
|
+
}
|
|
30
|
+
const tempPath = buildAtomicTempPath(input.filePath);
|
|
31
|
+
await fs.writeFile(tempPath, input.rewrittenText, {
|
|
32
|
+
encoding: input.encoding,
|
|
33
|
+
mode: fileStats.mode,
|
|
34
|
+
});
|
|
35
|
+
try {
|
|
36
|
+
await fs.rename(tempPath, input.filePath);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
await fs.rm(tempPath, { force: true }).catch(() => undefined);
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function buildAtomicTempPath(filePath) {
|
|
44
|
+
const directory = path.dirname(filePath);
|
|
45
|
+
const fileName = path.basename(filePath);
|
|
46
|
+
return path.join(directory, `.${fileName}.spatch-${process.pid}-${randomUUID()}.tmp`);
|
|
47
|
+
}
|
|
48
|
+
function buildStaleApplyError(filePath, operationName) {
|
|
49
|
+
return new Error(`File changed during ${operationName}: ${filePath}. Re-run spatch to avoid overwriting concurrent edits.`);
|
|
50
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
export { patchProject
|
|
2
|
-
export type { SpatchFileResult, SpatchOccurrence, SpatchOptions, SpatchResult
|
|
3
|
-
export { DEFAULT_EXCLUDED_DIRECTORIES, DEFAULT_PATCHABLE_EXTENSIONS
|
|
4
|
-
export { formatPatchOutput, patchCommand, runPatchCommand, } from "./command.ts";
|
|
5
|
-
export type { PatchCommandFlags } from "./command.ts";
|
|
1
|
+
export { patchProject } from "./spatch.ts";
|
|
2
|
+
export type { SpatchFileResult, SpatchOccurrence, SpatchOptions, SpatchResult } from "./types.ts";
|
|
3
|
+
export { DEFAULT_EXCLUDED_DIRECTORIES, DEFAULT_PATCHABLE_EXTENSIONS } from "./types.ts";
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,2 @@
|
|
|
1
|
-
export { patchProject
|
|
2
|
-
export { DEFAULT_EXCLUDED_DIRECTORIES, DEFAULT_PATCHABLE_EXTENSIONS
|
|
3
|
-
export { formatPatchOutput, patchCommand, runPatchCommand, } from "./command.js";
|
|
1
|
+
export { patchProject } from "./spatch.js";
|
|
2
|
+
export { DEFAULT_EXCLUDED_DIRECTORIES, DEFAULT_PATCHABLE_EXTENSIONS } from "./types.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { patchCommand } from "../command.ts";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { patchCommand } from "../command.js";
|
package/dist/phases/output.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { SpatchResult } from "../types.ts";
|
|
2
2
|
import type { ParsedPatchSpec } from "./parse.ts";
|
|
3
3
|
import type { RewritePhaseResult } from "./rewrite.ts";
|
|
4
|
-
|
|
4
|
+
type OutputPhaseInput = {
|
|
5
5
|
patch: ParsedPatchSpec;
|
|
6
6
|
rewrite: RewritePhaseResult;
|
|
7
7
|
elapsedMs: number;
|
|
8
8
|
};
|
|
9
9
|
export declare function buildSpatchResult(input: OutputPhaseInput): SpatchResult;
|
|
10
|
+
export {};
|
package/dist/phases/parse.d.ts
CHANGED
|
@@ -3,9 +3,9 @@ export type ParsedPatchSpec = {
|
|
|
3
3
|
pattern: string;
|
|
4
4
|
replacement: string;
|
|
5
5
|
};
|
|
6
|
-
|
|
6
|
+
type ParsedPatchInvocation = {
|
|
7
7
|
patch: ParsedPatchSpec;
|
|
8
8
|
options: SpatchOptions;
|
|
9
9
|
};
|
|
10
|
-
export declare function parsePatchSpec(patchDocument: string): ParsedPatchSpec;
|
|
11
10
|
export declare function parsePatchInvocation(patchInput: string, options?: SpatchOptions): Promise<ParsedPatchInvocation>;
|
|
11
|
+
export {};
|
package/dist/phases/parse.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { parsePatchDocument } from "
|
|
3
|
-
|
|
1
|
+
import { parseTextInvocation } from "@claudiu-ceia/astkit-core";
|
|
2
|
+
import { parsePatchDocument } from "./patch-document.js";
|
|
3
|
+
function parsePatchSpec(patchDocument) {
|
|
4
4
|
const parsed = parsePatchDocument(patchDocument);
|
|
5
5
|
return {
|
|
6
6
|
pattern: parsed.pattern,
|
|
@@ -8,9 +8,9 @@ export function parsePatchSpec(patchDocument) {
|
|
|
8
8
|
};
|
|
9
9
|
}
|
|
10
10
|
export async function parsePatchInvocation(patchInput, options = {}) {
|
|
11
|
-
const
|
|
11
|
+
const invocation = await parseTextInvocation(patchInput, options, parsePatchSpec);
|
|
12
12
|
return {
|
|
13
|
-
patch:
|
|
14
|
-
options,
|
|
13
|
+
patch: invocation.spec,
|
|
14
|
+
options: invocation.options,
|
|
15
15
|
};
|
|
16
16
|
}
|
|
@@ -8,10 +8,7 @@ const additionLineParser = map(seq(str("+"), lineContentParser), ([, content]) =
|
|
|
8
8
|
const deletionLineParser = map(seq(str("-"), lineContentParser), ([, content]) => ({ kind: "deletion", value: content }));
|
|
9
9
|
const contextLineParser = map(lineContentParser, (content) => ({ kind: "context", value: content }));
|
|
10
10
|
const patchLineParser = any(escapedMarkerLineParser, additionLineParser, deletionLineParser, contextLineParser);
|
|
11
|
-
const patchDocumentParser = map(seq(patchLineParser, many(map(seq(str("\n"), patchLineParser), ([, line]) => line)), optional(str("\n")), eof()), ([firstLine, remainingLines
|
|
12
|
-
lines: [firstLine, ...remainingLines],
|
|
13
|
-
trailingNewline: trailingNewline !== null,
|
|
14
|
-
}));
|
|
11
|
+
const patchDocumentParser = map(seq(patchLineParser, many(map(seq(str("\n"), patchLineParser), ([, line]) => line)), optional(str("\n")), eof()), ([firstLine, remainingLines]) => [firstLine, ...remainingLines]);
|
|
15
12
|
export function parsePatchDocument(source) {
|
|
16
13
|
if (source.length === 0) {
|
|
17
14
|
throw new Error("Patch document cannot be empty.");
|
|
@@ -23,21 +20,19 @@ export function parsePatchDocument(source) {
|
|
|
23
20
|
throw new Error(`Invalid patch document: ${formatErrorCompact(parsed)}`);
|
|
24
21
|
}
|
|
25
22
|
const lines = trailingNewline &&
|
|
26
|
-
parsed.value.
|
|
27
|
-
parsed.value
|
|
28
|
-
parsed.value
|
|
29
|
-
? parsed.value.
|
|
30
|
-
: parsed.value
|
|
23
|
+
parsed.value.length > 0 &&
|
|
24
|
+
parsed.value[parsed.value.length - 1]?.kind === "context" &&
|
|
25
|
+
parsed.value[parsed.value.length - 1]?.value === ""
|
|
26
|
+
? parsed.value.slice(0, -1)
|
|
27
|
+
: parsed.value;
|
|
31
28
|
const patternLines = [];
|
|
32
29
|
const replacementLines = [];
|
|
33
30
|
let additions = 0;
|
|
34
31
|
let deletions = 0;
|
|
35
|
-
let contextLines = 0;
|
|
36
32
|
for (const line of lines) {
|
|
37
33
|
if (line.kind === "context") {
|
|
38
34
|
patternLines.push(line.value);
|
|
39
35
|
replacementLines.push(line.value);
|
|
40
|
-
contextLines += 1;
|
|
41
36
|
continue;
|
|
42
37
|
}
|
|
43
38
|
if (line.kind === "addition") {
|
|
@@ -56,9 +51,5 @@ export function parsePatchDocument(source) {
|
|
|
56
51
|
return {
|
|
57
52
|
pattern: trailingNewline ? `${pattern}\n` : pattern,
|
|
58
53
|
replacement: trailingNewline ? `${replacement}\n` : replacement,
|
|
59
|
-
additions,
|
|
60
|
-
deletions,
|
|
61
|
-
contextLines,
|
|
62
|
-
trailingNewline,
|
|
63
54
|
};
|
|
64
55
|
}
|