@claudiu-ceia/spatch 0.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 +180 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +120 -0
- package/dist/command.d.ts +29 -0
- package/dist/command.js +374 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/patch-document.d.ts +9 -0
- package/dist/patch-document.js +64 -0
- package/dist/phases/output.d.ts +9 -0
- package/dist/phases/output.js +15 -0
- package/dist/phases/parse.d.ts +11 -0
- package/dist/phases/parse.js +16 -0
- package/dist/phases/rewrite.d.ts +14 -0
- package/dist/phases/rewrite.js +185 -0
- package/dist/spatch.d.ts +3 -0
- package/dist/spatch.js +17 -0
- package/dist/template.d.ts +2 -0
- package/dist/template.js +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/dist/types.d.ts +42 -0
- package/dist/types.js +1 -0
- package/package.json +38 -0
- package/src/cli.ts +145 -0
- package/src/command.ts +534 -0
- package/src/index.ts +21 -0
- package/src/patch-document.ts +133 -0
- package/src/phases/output.ts +25 -0
- package/src/phases/parse.ts +33 -0
- package/src/phases/rewrite.ts +287 -0
- package/src/spatch.ts +32 -0
- package/src/template.ts +2 -0
- package/src/types.ts +49 -0
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@claudiu-ceia/spatch",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Structural patcher for TS/JS using a patch-document template format.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"bun": "./src/index.ts",
|
|
13
|
+
"import": "./dist/index.js",
|
|
14
|
+
"default": "./dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./package.json": "./package.json"
|
|
17
|
+
},
|
|
18
|
+
"bin": {
|
|
19
|
+
"spatch": "./dist/cli.js"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"src",
|
|
24
|
+
"README.md"
|
|
25
|
+
],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@claudiu-ceia/astkit-core": "^0.2.0",
|
|
34
|
+
"@claudiu-ceia/combine": "^0.2.6",
|
|
35
|
+
"@stricli/core": "^1.2.5",
|
|
36
|
+
"chalk": "^5.6.2"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { formatPatchOutput, runPatchCommand } from "./command.ts";
|
|
3
|
+
|
|
4
|
+
type Flags = {
|
|
5
|
+
"dry-run"?: boolean;
|
|
6
|
+
interactive?: boolean;
|
|
7
|
+
json?: boolean;
|
|
8
|
+
"no-color"?: boolean;
|
|
9
|
+
cwd?: string;
|
|
10
|
+
concurrency?: number;
|
|
11
|
+
verbose?: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function parsePositiveInteger(name: string, raw: string | undefined): number {
|
|
15
|
+
const value = Number(raw);
|
|
16
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
17
|
+
throw new Error(`${name} must be a positive number`);
|
|
18
|
+
}
|
|
19
|
+
return Math.floor(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function readFlagValue(argv: string[], index: number): { value: string; consumed: number } {
|
|
23
|
+
const token = argv[index];
|
|
24
|
+
if (!token) {
|
|
25
|
+
throw new Error("Missing flag token");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const eqIndex = token.indexOf("=");
|
|
29
|
+
if (eqIndex >= 0) {
|
|
30
|
+
const value = token.slice(eqIndex + 1);
|
|
31
|
+
if (value.length === 0) {
|
|
32
|
+
throw new Error(`Missing value for ${token.slice(0, eqIndex)}`);
|
|
33
|
+
}
|
|
34
|
+
return { value, consumed: 1 };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const next = argv[index + 1];
|
|
38
|
+
if (!next) {
|
|
39
|
+
throw new Error(`Missing value for ${token}`);
|
|
40
|
+
}
|
|
41
|
+
return { value: next, consumed: 2 };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function printHelp(): void {
|
|
45
|
+
process.stdout.write(
|
|
46
|
+
[
|
|
47
|
+
"spatch - structural patch for TS/JS",
|
|
48
|
+
"",
|
|
49
|
+
"Usage:",
|
|
50
|
+
" spatch [--dry-run] [--interactive] [--json] [--no-color] [--cwd <path>] <patch-input> [scope]",
|
|
51
|
+
"",
|
|
52
|
+
"Flags:",
|
|
53
|
+
" --dry-run Preview changes without writing files",
|
|
54
|
+
" --interactive Interactively select which matches to apply",
|
|
55
|
+
" --json Output structured JSON",
|
|
56
|
+
" --no-color Disable colored output",
|
|
57
|
+
" --cwd <path> Working directory for resolving patch and scope",
|
|
58
|
+
" --concurrency <n> Max files processed concurrently (default: 8)",
|
|
59
|
+
" --verbose <level> Perf tracing to stderr (1=summary, 2=slow files)",
|
|
60
|
+
"",
|
|
61
|
+
].join("\n"),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const argv = process.argv.slice(2);
|
|
66
|
+
if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) {
|
|
67
|
+
printHelp();
|
|
68
|
+
process.exit(0);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const flags: Flags = {};
|
|
72
|
+
const positional: string[] = [];
|
|
73
|
+
|
|
74
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
75
|
+
const token = argv[i];
|
|
76
|
+
if (!token) continue;
|
|
77
|
+
|
|
78
|
+
if (!token.startsWith("-")) {
|
|
79
|
+
positional.push(token);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (token === "--dry-run") {
|
|
84
|
+
flags["dry-run"] = true;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (token === "--interactive") {
|
|
88
|
+
flags.interactive = true;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (token === "--json") {
|
|
92
|
+
flags.json = true;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (token === "--no-color") {
|
|
96
|
+
flags["no-color"] = true;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (token === "--cwd" || token.startsWith("--cwd=")) {
|
|
101
|
+
const { value, consumed } = readFlagValue(argv, i);
|
|
102
|
+
flags.cwd = value;
|
|
103
|
+
i += consumed - 1;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (token === "--concurrency" || token.startsWith("--concurrency=")) {
|
|
107
|
+
const { value, consumed } = readFlagValue(argv, i);
|
|
108
|
+
flags.concurrency = parsePositiveInteger("--concurrency", value);
|
|
109
|
+
i += consumed - 1;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (token === "--verbose" || token.startsWith("--verbose=")) {
|
|
113
|
+
const { value, consumed } = readFlagValue(argv, i);
|
|
114
|
+
const level = Number(value);
|
|
115
|
+
if (!Number.isFinite(level) || level < 0) {
|
|
116
|
+
throw new Error("--verbose must be a non-negative number");
|
|
117
|
+
}
|
|
118
|
+
flags.verbose = Math.floor(level);
|
|
119
|
+
i += consumed - 1;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
throw new Error(`Unknown flag: ${token}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const patchInput = positional[0];
|
|
127
|
+
const scope = positional[1];
|
|
128
|
+
if (!patchInput) {
|
|
129
|
+
printHelp();
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
if (positional.length > 2) {
|
|
133
|
+
throw new Error("Too many positional arguments.");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const result = await runPatchCommand(patchInput, scope, flags);
|
|
137
|
+
if (flags.json ?? false) {
|
|
138
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
139
|
+
} else {
|
|
140
|
+
const output = formatPatchOutput(result, {
|
|
141
|
+
color: Boolean(process.stdout.isTTY) && !(flags["no-color"] ?? false),
|
|
142
|
+
});
|
|
143
|
+
process.stdout.write(`${output}\n`);
|
|
144
|
+
}
|
|
145
|
+
|
package/src/command.ts
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
stderr as processStderr,
|
|
5
|
+
stdin as processStdin,
|
|
6
|
+
stdout as processStdout,
|
|
7
|
+
} from "node:process";
|
|
8
|
+
import { createInterface } from "node:readline/promises";
|
|
9
|
+
import { buildCommand } from "@stricli/core";
|
|
10
|
+
import chalk, { Chalk, type ChalkInstance } from "chalk";
|
|
11
|
+
import { patchProject } from "./spatch.ts";
|
|
12
|
+
import type {
|
|
13
|
+
SpatchFileResult,
|
|
14
|
+
SpatchOccurrence,
|
|
15
|
+
SpatchResult,
|
|
16
|
+
} from "./types.ts";
|
|
17
|
+
|
|
18
|
+
export type PatchCommandFlags = {
|
|
19
|
+
"dry-run"?: boolean;
|
|
20
|
+
interactive?: boolean;
|
|
21
|
+
json?: boolean;
|
|
22
|
+
"no-color"?: boolean;
|
|
23
|
+
cwd?: string;
|
|
24
|
+
concurrency?: number;
|
|
25
|
+
verbose?: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type InteractiveChoice = "yes" | "no" | "all" | "quit";
|
|
29
|
+
|
|
30
|
+
export type InteractiveContext = {
|
|
31
|
+
file: string;
|
|
32
|
+
occurrence: SpatchOccurrence;
|
|
33
|
+
changeNumber: number;
|
|
34
|
+
totalChanges: number;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type RunPatchCommandOptions = {
|
|
38
|
+
interactiveDecider?: (ctx: InteractiveContext) => Promise<InteractiveChoice>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export async function runPatchCommand(
|
|
42
|
+
patchInput: string,
|
|
43
|
+
scope: string | undefined,
|
|
44
|
+
flags: PatchCommandFlags,
|
|
45
|
+
options: RunPatchCommandOptions = {},
|
|
46
|
+
): Promise<SpatchResult> {
|
|
47
|
+
const patchScope = scope ?? ".";
|
|
48
|
+
const patchCwd = flags.cwd;
|
|
49
|
+
|
|
50
|
+
if (flags.interactive ?? false) {
|
|
51
|
+
if (flags["dry-run"] ?? false) {
|
|
52
|
+
throw new Error("Cannot combine --interactive with --dry-run.");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return runInteractivePatchCommand(
|
|
56
|
+
patchInput,
|
|
57
|
+
patchScope,
|
|
58
|
+
patchCwd,
|
|
59
|
+
flags["no-color"] ?? false,
|
|
60
|
+
options.interactiveDecider,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return patchProject(patchInput, {
|
|
65
|
+
concurrency: flags.concurrency,
|
|
66
|
+
cwd: patchCwd,
|
|
67
|
+
dryRun: flags["dry-run"] ?? false,
|
|
68
|
+
scope: patchScope,
|
|
69
|
+
verbose: flags.verbose,
|
|
70
|
+
logger: flags.verbose ? (line) => processStderr.write(`${line}\n`) : undefined,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type FormatPatchOutputOptions = {
|
|
75
|
+
color?: boolean;
|
|
76
|
+
chalkInstance?: ChalkInstance;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export function formatPatchOutput(
|
|
80
|
+
result: SpatchResult,
|
|
81
|
+
options: FormatPatchOutputOptions = {},
|
|
82
|
+
): string {
|
|
83
|
+
const chalkInstance = buildChalk(options);
|
|
84
|
+
const useColor = chalkInstance.level > 0;
|
|
85
|
+
const lines: string[] = [];
|
|
86
|
+
const changedFiles = result.files.filter((file) => file.replacementCount > 0);
|
|
87
|
+
|
|
88
|
+
for (const file of changedFiles) {
|
|
89
|
+
const headerPrefix = useColor ? chalkInstance.bold : (value: string) => value;
|
|
90
|
+
lines.push(headerPrefix(`diff --git a/${file.file} b/${file.file}`));
|
|
91
|
+
lines.push(useColor ? chalkInstance.gray(`--- a/${file.file}`) : `--- a/${file.file}`);
|
|
92
|
+
lines.push(useColor ? chalkInstance.gray(`+++ b/${file.file}`) : `+++ b/${file.file}`);
|
|
93
|
+
|
|
94
|
+
for (const occurrence of file.occurrences) {
|
|
95
|
+
if (occurrence.matched === occurrence.replacement) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const oldCount = countLines(occurrence.matched);
|
|
100
|
+
const newCount = countLines(occurrence.replacement);
|
|
101
|
+
const hunkHeader = `@@ -${occurrence.line},${oldCount} +${occurrence.line},${newCount} @@`;
|
|
102
|
+
lines.push(useColor ? chalkInstance.cyan(hunkHeader) : hunkHeader);
|
|
103
|
+
|
|
104
|
+
for (const oldLine of splitDiffLines(occurrence.matched)) {
|
|
105
|
+
const line = `-${oldLine}`;
|
|
106
|
+
lines.push(useColor ? chalkInstance.red(line) : line);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const newLine of splitDiffLines(occurrence.replacement)) {
|
|
110
|
+
const line = `+${newLine}`;
|
|
111
|
+
lines.push(useColor ? chalkInstance.green(line) : line);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (changedFiles.length === 0) {
|
|
117
|
+
lines.push(useColor ? chalkInstance.gray("No changes.") : "No changes.");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const summary = [
|
|
121
|
+
`${result.filesChanged} ${pluralize("file", result.filesChanged)} changed`,
|
|
122
|
+
`${result.totalReplacements} ${pluralize("replacement", result.totalReplacements)}`,
|
|
123
|
+
result.dryRun ? "(dry-run)" : null,
|
|
124
|
+
]
|
|
125
|
+
.filter((part) => part !== null)
|
|
126
|
+
.join(", ");
|
|
127
|
+
lines.push(useColor ? chalkInstance.gray(summary) : summary);
|
|
128
|
+
|
|
129
|
+
return lines.join("\n");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export const patchCommand = buildCommand({
|
|
133
|
+
async func(
|
|
134
|
+
this: { process: { stdout: { write(s: string): void } } },
|
|
135
|
+
flags: PatchCommandFlags,
|
|
136
|
+
patchInput: string,
|
|
137
|
+
scope?: string,
|
|
138
|
+
) {
|
|
139
|
+
const result = await runPatchCommand(patchInput, scope, flags);
|
|
140
|
+
if (flags.json ?? false) {
|
|
141
|
+
this.process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const output = formatPatchOutput(result, {
|
|
146
|
+
color: Boolean(processStdout.isTTY) && !(flags["no-color"] ?? false),
|
|
147
|
+
});
|
|
148
|
+
this.process.stdout.write(`${output}\n`);
|
|
149
|
+
},
|
|
150
|
+
parameters: {
|
|
151
|
+
flags: {
|
|
152
|
+
concurrency: {
|
|
153
|
+
kind: "parsed" as const,
|
|
154
|
+
optional: true,
|
|
155
|
+
brief: "Max files processed concurrently (default: 8)",
|
|
156
|
+
placeholder: "n",
|
|
157
|
+
parse: (input: string) => {
|
|
158
|
+
const value = Number(input);
|
|
159
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
160
|
+
throw new Error("--concurrency must be a positive number");
|
|
161
|
+
}
|
|
162
|
+
return Math.floor(value);
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
verbose: {
|
|
166
|
+
kind: "parsed" as const,
|
|
167
|
+
optional: true,
|
|
168
|
+
brief: "Print perf tracing (1=summary, 2=includes slow files)",
|
|
169
|
+
placeholder: "level",
|
|
170
|
+
parse: (input: string) => {
|
|
171
|
+
const value = Number(input);
|
|
172
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
173
|
+
throw new Error("--verbose must be a non-negative number");
|
|
174
|
+
}
|
|
175
|
+
return Math.floor(value);
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
interactive: {
|
|
179
|
+
kind: "boolean" as const,
|
|
180
|
+
optional: true,
|
|
181
|
+
brief: "Interactively select which matches to apply",
|
|
182
|
+
},
|
|
183
|
+
json: {
|
|
184
|
+
kind: "boolean" as const,
|
|
185
|
+
optional: true,
|
|
186
|
+
brief: "Output structured JSON instead of compact diff-style text",
|
|
187
|
+
},
|
|
188
|
+
"no-color": {
|
|
189
|
+
kind: "boolean" as const,
|
|
190
|
+
optional: true,
|
|
191
|
+
brief: "Disable colored output",
|
|
192
|
+
},
|
|
193
|
+
"dry-run": {
|
|
194
|
+
kind: "boolean" as const,
|
|
195
|
+
optional: true,
|
|
196
|
+
brief: "Preview changes without writing files",
|
|
197
|
+
},
|
|
198
|
+
cwd: {
|
|
199
|
+
kind: "parsed" as const,
|
|
200
|
+
optional: true,
|
|
201
|
+
brief: "Working directory for resolving patch file and scope",
|
|
202
|
+
placeholder: "path",
|
|
203
|
+
parse: (input: string) => input,
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
positional: {
|
|
207
|
+
kind: "tuple" as const,
|
|
208
|
+
parameters: [
|
|
209
|
+
{
|
|
210
|
+
brief: "Patch document text or path to patch document file",
|
|
211
|
+
placeholder: "patch",
|
|
212
|
+
parse: (input: string) => input,
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
brief: "Scope file or directory (defaults to current directory)",
|
|
216
|
+
placeholder: "scope",
|
|
217
|
+
parse: (input: string) => input,
|
|
218
|
+
optional: true,
|
|
219
|
+
},
|
|
220
|
+
],
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
docs: {
|
|
224
|
+
brief: "Apply structural rewrite from a patch document",
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
function buildChalk(options: FormatPatchOutputOptions): ChalkInstance {
|
|
229
|
+
if (options.chalkInstance) {
|
|
230
|
+
return options.chalkInstance;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const shouldColor = options.color ?? false;
|
|
234
|
+
if (!shouldColor) {
|
|
235
|
+
return new Chalk({ level: 0 });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const level = chalk.level > 0 ? chalk.level : 1;
|
|
239
|
+
return new Chalk({ level });
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function splitDiffLines(text: string): string[] {
|
|
243
|
+
const normalized = text.replaceAll("\r\n", "\n");
|
|
244
|
+
if (normalized.length === 0) {
|
|
245
|
+
return [""];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return normalized.split("\n");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function countLines(text: string): number {
|
|
252
|
+
const normalized = text.replaceAll("\r\n", "\n");
|
|
253
|
+
if (normalized.length === 0) {
|
|
254
|
+
return 0;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return normalized.split("\n").length;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function pluralize(word: string, count: number): string {
|
|
261
|
+
return count === 1 ? word : `${word}s`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function runInteractivePatchCommand(
|
|
265
|
+
patchInput: string,
|
|
266
|
+
scope: string,
|
|
267
|
+
cwd: string | undefined,
|
|
268
|
+
noColor: boolean,
|
|
269
|
+
interactiveDecider?: (ctx: InteractiveContext) => Promise<InteractiveChoice>,
|
|
270
|
+
): Promise<SpatchResult> {
|
|
271
|
+
if (!interactiveDecider && (!processStdin.isTTY || !processStdout.isTTY)) {
|
|
272
|
+
throw new Error("Interactive mode requires a TTY stdin/stdout.");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const startedAt = Date.now();
|
|
276
|
+
const dryResult = await patchProject(patchInput, {
|
|
277
|
+
cwd,
|
|
278
|
+
dryRun: true,
|
|
279
|
+
scope,
|
|
280
|
+
});
|
|
281
|
+
const totalChanges = dryResult.files.reduce(
|
|
282
|
+
(count, file) =>
|
|
283
|
+
count +
|
|
284
|
+
file.occurrences.filter(
|
|
285
|
+
(occurrence) => occurrence.matched !== occurrence.replacement,
|
|
286
|
+
).length,
|
|
287
|
+
0,
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
let interactivePrompt: Awaited<
|
|
291
|
+
ReturnType<typeof createTerminalInteractiveDecider>
|
|
292
|
+
> | null = null;
|
|
293
|
+
const decider =
|
|
294
|
+
interactiveDecider ??
|
|
295
|
+
(
|
|
296
|
+
(interactivePrompt = await createTerminalInteractiveDecider(noColor)),
|
|
297
|
+
interactivePrompt.decider
|
|
298
|
+
);
|
|
299
|
+
const selectedByFile = new Map<string, SpatchOccurrence[]>();
|
|
300
|
+
let applyAll = false;
|
|
301
|
+
let stop = false;
|
|
302
|
+
let changeNumber = 0;
|
|
303
|
+
|
|
304
|
+
try {
|
|
305
|
+
for (const file of dryResult.files) {
|
|
306
|
+
const selected: SpatchOccurrence[] = [];
|
|
307
|
+
|
|
308
|
+
for (const occurrence of file.occurrences) {
|
|
309
|
+
if (occurrence.matched === occurrence.replacement) {
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
changeNumber += 1;
|
|
313
|
+
|
|
314
|
+
if (applyAll) {
|
|
315
|
+
selected.push(occurrence);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const choice = await decider({
|
|
320
|
+
file: file.file,
|
|
321
|
+
occurrence,
|
|
322
|
+
changeNumber,
|
|
323
|
+
totalChanges,
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
if (choice === "yes") {
|
|
327
|
+
selected.push(occurrence);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (choice === "all") {
|
|
332
|
+
applyAll = true;
|
|
333
|
+
selected.push(occurrence);
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (choice === "quit") {
|
|
338
|
+
stop = true;
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
selectedByFile.set(file.file, selected);
|
|
344
|
+
if (stop) {
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
} finally {
|
|
349
|
+
interactivePrompt?.close();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const fileResults: SpatchFileResult[] = [];
|
|
353
|
+
let filesChanged = 0;
|
|
354
|
+
let totalReplacements = 0;
|
|
355
|
+
|
|
356
|
+
for (const file of dryResult.files) {
|
|
357
|
+
const selected = selectedByFile.get(file.file) ?? [];
|
|
358
|
+
if (selected.length === 0) {
|
|
359
|
+
fileResults.push({
|
|
360
|
+
...file,
|
|
361
|
+
replacementCount: 0,
|
|
362
|
+
changed: false,
|
|
363
|
+
byteDelta: 0,
|
|
364
|
+
occurrences: [],
|
|
365
|
+
});
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const absolutePath = path.resolve(cwd ?? process.cwd(), file.file);
|
|
370
|
+
const originalText = await readFile(absolutePath, "utf8");
|
|
371
|
+
const rewrittenText = applySelectedOccurrences(originalText, selected);
|
|
372
|
+
const changed = rewrittenText !== originalText;
|
|
373
|
+
|
|
374
|
+
if (changed) {
|
|
375
|
+
await writeFile(absolutePath, rewrittenText, "utf8");
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const replacementCount = selected.filter(
|
|
379
|
+
(occurrence) => occurrence.matched !== occurrence.replacement,
|
|
380
|
+
).length;
|
|
381
|
+
totalReplacements += replacementCount;
|
|
382
|
+
if (changed) {
|
|
383
|
+
filesChanged += 1;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
fileResults.push({
|
|
387
|
+
...file,
|
|
388
|
+
replacementCount,
|
|
389
|
+
changed,
|
|
390
|
+
byteDelta: changed
|
|
391
|
+
? Buffer.byteLength(rewrittenText, "utf8") -
|
|
392
|
+
Buffer.byteLength(originalText, "utf8")
|
|
393
|
+
: 0,
|
|
394
|
+
occurrences: selected,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return {
|
|
399
|
+
...dryResult,
|
|
400
|
+
dryRun: false,
|
|
401
|
+
filesChanged,
|
|
402
|
+
totalReplacements,
|
|
403
|
+
elapsedMs: Date.now() - startedAt,
|
|
404
|
+
files: fileResults,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function applySelectedOccurrences(
|
|
409
|
+
source: string,
|
|
410
|
+
occurrences: readonly SpatchOccurrence[],
|
|
411
|
+
): string {
|
|
412
|
+
if (occurrences.length === 0) {
|
|
413
|
+
return source;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const sorted = [...occurrences].sort((left, right) => left.start - right.start);
|
|
417
|
+
const parts: string[] = [];
|
|
418
|
+
let cursor = 0;
|
|
419
|
+
|
|
420
|
+
for (const occurrence of sorted) {
|
|
421
|
+
parts.push(source.slice(cursor, occurrence.start));
|
|
422
|
+
parts.push(occurrence.replacement);
|
|
423
|
+
cursor = occurrence.end;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
parts.push(source.slice(cursor));
|
|
427
|
+
return parts.join("");
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async function createTerminalInteractiveDecider(noColor: boolean): Promise<
|
|
431
|
+
{
|
|
432
|
+
decider: (ctx: InteractiveContext) => Promise<InteractiveChoice>;
|
|
433
|
+
close: () => void;
|
|
434
|
+
}
|
|
435
|
+
> {
|
|
436
|
+
const chalkInstance = buildChalk({
|
|
437
|
+
color: processStdout.isTTY && !noColor,
|
|
438
|
+
});
|
|
439
|
+
const useColor = chalkInstance.level > 0;
|
|
440
|
+
const rl = createInterface({
|
|
441
|
+
input: processStdin,
|
|
442
|
+
output: processStdout,
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
return {
|
|
446
|
+
decider: async ({ file, occurrence, changeNumber, totalChanges }) => {
|
|
447
|
+
processStdout.write(
|
|
448
|
+
`\n${formatInteractiveChangeBlock(
|
|
449
|
+
{ file, occurrence, changeNumber, totalChanges },
|
|
450
|
+
{
|
|
451
|
+
chalkInstance,
|
|
452
|
+
color: useColor,
|
|
453
|
+
},
|
|
454
|
+
)}\n`,
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
while (true) {
|
|
458
|
+
const answer = await rl.question(
|
|
459
|
+
useColor
|
|
460
|
+
? chalkInstance.bold("Choice [y/n/a/q] (default: n): ")
|
|
461
|
+
: "Choice [y/n/a/q] (default: n): ",
|
|
462
|
+
);
|
|
463
|
+
const parsed = parseInteractiveChoice(answer);
|
|
464
|
+
if (parsed) {
|
|
465
|
+
return parsed;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
processStdout.write(
|
|
469
|
+
useColor
|
|
470
|
+
? `${chalkInstance.yellow("Invalid choice.")} Use y, n, a, or q.\n`
|
|
471
|
+
: "Invalid choice. Use y, n, a, or q.\n",
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
},
|
|
475
|
+
close: () => rl.close(),
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
type FormatInteractiveChangeBlockOptions = {
|
|
480
|
+
color?: boolean;
|
|
481
|
+
chalkInstance?: ChalkInstance;
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
function formatInteractiveChangeBlock(
|
|
485
|
+
ctx: InteractiveContext,
|
|
486
|
+
options: FormatInteractiveChangeBlockOptions = {},
|
|
487
|
+
): string {
|
|
488
|
+
const chalkInstance = buildChalk(options);
|
|
489
|
+
const useColor = chalkInstance.level > 0;
|
|
490
|
+
const divider = "─".repeat(72);
|
|
491
|
+
const oldCount = countLines(ctx.occurrence.matched);
|
|
492
|
+
const newCount = countLines(ctx.occurrence.replacement);
|
|
493
|
+
const hunkHeader = `@@ -${ctx.occurrence.line},${oldCount} +${ctx.occurrence.line},${newCount} @@`;
|
|
494
|
+
const lines = [
|
|
495
|
+
useColor ? chalkInstance.gray(divider) : divider,
|
|
496
|
+
useColor
|
|
497
|
+
? chalkInstance.bold(
|
|
498
|
+
`Change ${ctx.changeNumber}/${ctx.totalChanges} · ${ctx.file}:${ctx.occurrence.line}:${ctx.occurrence.character}`,
|
|
499
|
+
)
|
|
500
|
+
: `Change ${ctx.changeNumber}/${ctx.totalChanges} · ${ctx.file}:${ctx.occurrence.line}:${ctx.occurrence.character}`,
|
|
501
|
+
useColor ? chalkInstance.cyan(hunkHeader) : hunkHeader,
|
|
502
|
+
...splitDiffLines(ctx.occurrence.matched).map((line) =>
|
|
503
|
+
useColor ? chalkInstance.red(`-${line}`) : `-${line}`,
|
|
504
|
+
),
|
|
505
|
+
...splitDiffLines(ctx.occurrence.replacement).map((line) =>
|
|
506
|
+
useColor ? chalkInstance.green(`+${line}`) : `+${line}`,
|
|
507
|
+
),
|
|
508
|
+
useColor
|
|
509
|
+
? chalkInstance.gray(
|
|
510
|
+
"Actions: [y] apply · [n] skip · [a] apply remaining · [q] quit",
|
|
511
|
+
)
|
|
512
|
+
: "Actions: [y] apply · [n] skip · [a] apply remaining · [q] quit",
|
|
513
|
+
];
|
|
514
|
+
|
|
515
|
+
return lines.join("\n");
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function parseInteractiveChoice(answer: string): InteractiveChoice | null {
|
|
519
|
+
const normalized = answer.trim().toLowerCase();
|
|
520
|
+
if (normalized.length === 0 || normalized === "n" || normalized === "no") {
|
|
521
|
+
return "no";
|
|
522
|
+
}
|
|
523
|
+
if (normalized === "y" || normalized === "yes") {
|
|
524
|
+
return "yes";
|
|
525
|
+
}
|
|
526
|
+
if (normalized === "a" || normalized === "all") {
|
|
527
|
+
return "all";
|
|
528
|
+
}
|
|
529
|
+
if (normalized === "q" || normalized === "quit") {
|
|
530
|
+
return "quit";
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return null;
|
|
534
|
+
}
|