@claudiu-ceia/spatch 0.2.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 +188 -112
- 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 +26 -24
- package/dist/command.js +52 -322
- 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 +99 -458
- 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/src/cli.ts
CHANGED
|
@@ -1,145 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
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
|
-
}
|
|
2
|
+
import { run } from "@stricli/core";
|
|
3
|
+
import { app } from "./app.ts";
|
|
145
4
|
|
|
5
|
+
await run(app, process.argv.slice(2), { process });
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
export type PatchCommandFlags = {
|
|
2
|
+
"dry-run"?: boolean;
|
|
3
|
+
check?: boolean;
|
|
4
|
+
interactive?: boolean;
|
|
5
|
+
json?: boolean;
|
|
6
|
+
"no-color"?: boolean;
|
|
7
|
+
cwd?: string;
|
|
8
|
+
concurrency?: number;
|
|
9
|
+
verbose?: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const patchCommandFlagParameters = {
|
|
13
|
+
concurrency: {
|
|
14
|
+
kind: "parsed" as const,
|
|
15
|
+
optional: true,
|
|
16
|
+
brief: "Max files processed concurrently (default: 8)",
|
|
17
|
+
placeholder: "n",
|
|
18
|
+
parse: (input: string) => {
|
|
19
|
+
const value = Number(input);
|
|
20
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
21
|
+
throw new Error("--concurrency must be a positive number");
|
|
22
|
+
}
|
|
23
|
+
return Math.floor(value);
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
verbose: {
|
|
27
|
+
kind: "parsed" as const,
|
|
28
|
+
optional: true,
|
|
29
|
+
brief: "Print perf tracing (1=summary, 2=includes slow files)",
|
|
30
|
+
placeholder: "level",
|
|
31
|
+
parse: (input: string) => {
|
|
32
|
+
const value = Number(input);
|
|
33
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
34
|
+
throw new Error("--verbose must be a non-negative number");
|
|
35
|
+
}
|
|
36
|
+
return Math.floor(value);
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
interactive: {
|
|
40
|
+
kind: "boolean" as const,
|
|
41
|
+
optional: true,
|
|
42
|
+
withNegated: false,
|
|
43
|
+
brief: "Interactively select which matches to apply",
|
|
44
|
+
},
|
|
45
|
+
json: {
|
|
46
|
+
kind: "boolean" as const,
|
|
47
|
+
optional: true,
|
|
48
|
+
withNegated: false,
|
|
49
|
+
brief: "Output structured JSON instead of compact diff-style text",
|
|
50
|
+
},
|
|
51
|
+
"no-color": {
|
|
52
|
+
kind: "boolean" as const,
|
|
53
|
+
optional: true,
|
|
54
|
+
withNegated: false,
|
|
55
|
+
brief: "Disable colored output",
|
|
56
|
+
},
|
|
57
|
+
"dry-run": {
|
|
58
|
+
kind: "boolean" as const,
|
|
59
|
+
optional: true,
|
|
60
|
+
withNegated: false,
|
|
61
|
+
brief: "Preview changes without writing files",
|
|
62
|
+
},
|
|
63
|
+
check: {
|
|
64
|
+
kind: "boolean" as const,
|
|
65
|
+
optional: true,
|
|
66
|
+
withNegated: false,
|
|
67
|
+
brief: "Exit non-zero if changes would be made (implies --dry-run)",
|
|
68
|
+
},
|
|
69
|
+
cwd: {
|
|
70
|
+
kind: "parsed" as const,
|
|
71
|
+
optional: true,
|
|
72
|
+
brief: "Working directory for resolving patch file and scope",
|
|
73
|
+
placeholder: "path",
|
|
74
|
+
parse: (input: string) => input,
|
|
75
|
+
},
|
|
76
|
+
} as const;
|
|
77
|
+
|
|
78
|
+
export function validatePatchCommandFlags(flags: PatchCommandFlags): void {
|
|
79
|
+
if ((flags.interactive ?? false) && (flags["dry-run"] ?? false)) {
|
|
80
|
+
throw new Error("Cannot combine --interactive with --dry-run.");
|
|
81
|
+
}
|
|
82
|
+
if ((flags.interactive ?? false) && (flags.check ?? false)) {
|
|
83
|
+
throw new Error("Cannot combine --interactive with --check.");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export async function resolveInteractiveFilePath(
|
|
5
|
+
file: string,
|
|
6
|
+
options: { cwd: string; scope: string; scopeKind: "file" | "directory" },
|
|
7
|
+
): Promise<string> {
|
|
8
|
+
if (path.isAbsolute(file)) {
|
|
9
|
+
const absolute = path.resolve(file);
|
|
10
|
+
if (isCandidateInScope(absolute, options)) {
|
|
11
|
+
return absolute;
|
|
12
|
+
}
|
|
13
|
+
throw new Error(`Resolved interactive file is outside selected scope: ${file}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const candidates = new Set<string>();
|
|
17
|
+
if (options.scopeKind === "file") {
|
|
18
|
+
candidates.add(options.scope);
|
|
19
|
+
candidates.add(path.resolve(path.dirname(options.scope), file));
|
|
20
|
+
} else {
|
|
21
|
+
candidates.add(path.resolve(options.scope, file));
|
|
22
|
+
}
|
|
23
|
+
candidates.add(path.resolve(options.cwd, file));
|
|
24
|
+
|
|
25
|
+
const resolvedCandidates: string[] = [];
|
|
26
|
+
|
|
27
|
+
for (const candidate of candidates) {
|
|
28
|
+
if (!isCandidateInScope(candidate, options)) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const candidateStats = await stat(candidate);
|
|
34
|
+
if (candidateStats.isFile()) {
|
|
35
|
+
resolvedCandidates.push(candidate);
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
// Try next candidate.
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (resolvedCandidates.length === 1) {
|
|
43
|
+
return resolvedCandidates[0]!;
|
|
44
|
+
}
|
|
45
|
+
if (resolvedCandidates.length > 1) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`Ambiguous interactive patch target file: ${file}. Re-run spatch interactive with a narrower scope.`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
throw new Error(
|
|
52
|
+
`Unable to resolve interactive patch target file: ${file}. Re-run spatch interactive.`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isCandidateInScope(
|
|
57
|
+
candidate: string,
|
|
58
|
+
options: { scope: string; scopeKind: "file" | "directory" },
|
|
59
|
+
): boolean {
|
|
60
|
+
if (options.scopeKind === "file") {
|
|
61
|
+
return path.resolve(candidate) === options.scope;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const relative = path.relative(options.scope, candidate);
|
|
65
|
+
if (relative.length === 0) {
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
if (path.isAbsolute(relative)) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
return relative !== ".." && !relative.startsWith(`..${path.sep}`);
|
|
72
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { stdin as processStdin, stdout as processStdout } from "node:process";
|
|
4
|
+
import { writeFileIfUnchangedAtomically } from "../../file-write.ts";
|
|
5
|
+
import { applyReplacementSpans } from "../../replacement-spans.ts";
|
|
6
|
+
import { patchProject } from "../../spatch.ts";
|
|
7
|
+
import type { SpatchFileResult, SpatchOccurrence, SpatchResult } from "../../types.ts";
|
|
8
|
+
import { resolveInteractiveFilePath } from "./path-resolution.ts";
|
|
9
|
+
import { createTerminalInteractiveDecider } from "./terminal.ts";
|
|
10
|
+
import type { RunInteractivePatchCommandOptions } from "./types.ts";
|
|
11
|
+
import { validateSelectedOccurrences } from "./validation.ts";
|
|
12
|
+
|
|
13
|
+
type PreparedInteractiveFile = {
|
|
14
|
+
absolutePath: string;
|
|
15
|
+
originalText: string;
|
|
16
|
+
rewrittenText: string;
|
|
17
|
+
changed: boolean;
|
|
18
|
+
replacementCount: number;
|
|
19
|
+
selected: SpatchOccurrence[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export async function runInteractivePatchCommand(
|
|
23
|
+
patchInput: string,
|
|
24
|
+
options: RunInteractivePatchCommandOptions,
|
|
25
|
+
): Promise<SpatchResult> {
|
|
26
|
+
const scope = options.scope ?? ".";
|
|
27
|
+
const cwd = options.cwd;
|
|
28
|
+
const resolvedCwd = path.resolve(cwd ?? process.cwd());
|
|
29
|
+
const resolvedScope = path.resolve(resolvedCwd, scope);
|
|
30
|
+
const resolvedScopeStats = await stat(resolvedScope);
|
|
31
|
+
const scopeKind: "file" | "directory" = resolvedScopeStats.isFile()
|
|
32
|
+
? "file"
|
|
33
|
+
: resolvedScopeStats.isDirectory()
|
|
34
|
+
? "directory"
|
|
35
|
+
: (() => {
|
|
36
|
+
throw new Error(`Scope must resolve to a file or directory: ${resolvedScope}`);
|
|
37
|
+
})();
|
|
38
|
+
const encoding = options.encoding ?? "utf8";
|
|
39
|
+
const noColor = options.noColor;
|
|
40
|
+
const interactiveDecider = options.interactiveDecider;
|
|
41
|
+
|
|
42
|
+
if (!interactiveDecider && (!processStdin.isTTY || !processStdout.isTTY)) {
|
|
43
|
+
throw new Error("Interactive mode requires a TTY stdin/stdout.");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const startedAt = Date.now();
|
|
47
|
+
const dryResult = await patchProject(patchInput, {
|
|
48
|
+
concurrency: options.concurrency,
|
|
49
|
+
cwd,
|
|
50
|
+
dryRun: true,
|
|
51
|
+
encoding: options.encoding,
|
|
52
|
+
logger: options.logger,
|
|
53
|
+
scope,
|
|
54
|
+
verbose: options.verbose,
|
|
55
|
+
});
|
|
56
|
+
const totalChanges = dryResult.files.reduce(
|
|
57
|
+
(count, file) =>
|
|
58
|
+
count +
|
|
59
|
+
file.occurrences.filter((occurrence) => occurrence.matched !== occurrence.replacement).length,
|
|
60
|
+
0,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
let interactivePrompt: Awaited<ReturnType<typeof createTerminalInteractiveDecider>> | null = null;
|
|
64
|
+
const decider =
|
|
65
|
+
interactiveDecider ??
|
|
66
|
+
((interactivePrompt = await createTerminalInteractiveDecider(noColor)),
|
|
67
|
+
interactivePrompt.decider);
|
|
68
|
+
const selectedByFile = new Map<string, SpatchOccurrence[]>();
|
|
69
|
+
let applyAll = false;
|
|
70
|
+
let stop = false;
|
|
71
|
+
let changeNumber = 0;
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
for (const file of dryResult.files) {
|
|
75
|
+
const selected: SpatchOccurrence[] = [];
|
|
76
|
+
|
|
77
|
+
for (const occurrence of file.occurrences) {
|
|
78
|
+
if (occurrence.matched === occurrence.replacement) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
changeNumber += 1;
|
|
82
|
+
|
|
83
|
+
if (applyAll) {
|
|
84
|
+
selected.push(occurrence);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const choice = await decider({
|
|
89
|
+
file: file.file,
|
|
90
|
+
occurrence,
|
|
91
|
+
changeNumber,
|
|
92
|
+
totalChanges,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
if (choice === "yes") {
|
|
96
|
+
selected.push(occurrence);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (choice === "all") {
|
|
101
|
+
applyAll = true;
|
|
102
|
+
selected.push(occurrence);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (choice === "quit") {
|
|
107
|
+
stop = true;
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
selectedByFile.set(file.file, selected);
|
|
113
|
+
if (stop) {
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
} finally {
|
|
118
|
+
interactivePrompt?.close();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const fileResults: SpatchFileResult[] = [];
|
|
122
|
+
let filesChanged = 0;
|
|
123
|
+
let totalReplacements = 0;
|
|
124
|
+
const preparedByFile = new Map<string, PreparedInteractiveFile>();
|
|
125
|
+
|
|
126
|
+
for (const file of dryResult.files) {
|
|
127
|
+
const selected = selectedByFile.get(file.file) ?? [];
|
|
128
|
+
if (selected.length === 0) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const absolutePath = await resolveInteractiveFilePath(file.file, {
|
|
133
|
+
cwd: resolvedCwd,
|
|
134
|
+
scope: resolvedScope,
|
|
135
|
+
scopeKind,
|
|
136
|
+
});
|
|
137
|
+
const originalText = await readFile(absolutePath, encoding);
|
|
138
|
+
validateSelectedOccurrences(file.file, originalText, selected);
|
|
139
|
+
const rewrittenText = applyReplacementSpans(originalText, selected);
|
|
140
|
+
const changed = rewrittenText !== originalText;
|
|
141
|
+
const replacementCount = selected.filter(
|
|
142
|
+
(occurrence) => occurrence.matched !== occurrence.replacement,
|
|
143
|
+
).length;
|
|
144
|
+
preparedByFile.set(file.file, {
|
|
145
|
+
absolutePath,
|
|
146
|
+
originalText,
|
|
147
|
+
rewrittenText,
|
|
148
|
+
changed,
|
|
149
|
+
replacementCount,
|
|
150
|
+
selected,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
for (const file of dryResult.files) {
|
|
155
|
+
const selected = selectedByFile.get(file.file) ?? [];
|
|
156
|
+
if (selected.length === 0) {
|
|
157
|
+
fileResults.push({
|
|
158
|
+
...file,
|
|
159
|
+
replacementCount: 0,
|
|
160
|
+
changed: false,
|
|
161
|
+
byteDelta: 0,
|
|
162
|
+
occurrences: [],
|
|
163
|
+
});
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const prepared = preparedByFile.get(file.file);
|
|
168
|
+
if (!prepared) {
|
|
169
|
+
throw new Error(`Missing prepared interactive rewrite state for ${file.file}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (prepared.changed) {
|
|
173
|
+
await writeFileIfUnchangedAtomically({
|
|
174
|
+
filePath: prepared.absolutePath,
|
|
175
|
+
originalText: prepared.originalText,
|
|
176
|
+
rewrittenText: prepared.rewrittenText,
|
|
177
|
+
encoding,
|
|
178
|
+
operationName: "interactive patch apply",
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
fileResults.push({
|
|
183
|
+
...file,
|
|
184
|
+
replacementCount: prepared.replacementCount,
|
|
185
|
+
changed: prepared.changed,
|
|
186
|
+
byteDelta: prepared.changed
|
|
187
|
+
? Buffer.byteLength(prepared.rewrittenText, encoding) -
|
|
188
|
+
Buffer.byteLength(prepared.originalText, encoding)
|
|
189
|
+
: 0,
|
|
190
|
+
occurrences: prepared.selected,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
totalReplacements += prepared.replacementCount;
|
|
194
|
+
if (prepared.changed) {
|
|
195
|
+
filesChanged += 1;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
...dryResult,
|
|
201
|
+
dryRun: false,
|
|
202
|
+
filesChanged,
|
|
203
|
+
totalReplacements,
|
|
204
|
+
elapsedMs: Date.now() - startedAt,
|
|
205
|
+
files: fileResults,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { stdin as processStdin, stdout as processStdout } from "node:process";
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
3
|
+
import type { Readable, Writable } from "node:stream";
|
|
4
|
+
import {
|
|
5
|
+
buildChalk,
|
|
6
|
+
countLines,
|
|
7
|
+
escapeTerminalText,
|
|
8
|
+
splitDiffLines,
|
|
9
|
+
type FormatPatchOutputOptions,
|
|
10
|
+
} from "../output.ts";
|
|
11
|
+
import type { InteractiveChoice, InteractiveContext, InteractiveDecider } from "./types.ts";
|
|
12
|
+
|
|
13
|
+
type PromptInterface = {
|
|
14
|
+
question(prompt: string): Promise<string>;
|
|
15
|
+
close(): void;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type TerminalInteractiveDependencies = {
|
|
19
|
+
stdin: Readable & { isTTY?: boolean };
|
|
20
|
+
stdout: Writable & { isTTY?: boolean; write(chunk: string): boolean };
|
|
21
|
+
createPrompt: (options: {
|
|
22
|
+
input: Readable & { isTTY?: boolean };
|
|
23
|
+
output: Writable & { isTTY?: boolean; write(chunk: string): boolean };
|
|
24
|
+
}) => PromptInterface;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const defaultTerminalInteractiveDependencies: TerminalInteractiveDependencies = {
|
|
28
|
+
stdin: processStdin,
|
|
29
|
+
stdout: processStdout,
|
|
30
|
+
createPrompt: ({ input, output }) => createInterface({ input, output }),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export async function createTerminalInteractiveDecider(
|
|
34
|
+
noColor: boolean,
|
|
35
|
+
dependencies: TerminalInteractiveDependencies = defaultTerminalInteractiveDependencies,
|
|
36
|
+
): Promise<{
|
|
37
|
+
decider: InteractiveDecider;
|
|
38
|
+
close: () => void;
|
|
39
|
+
}> {
|
|
40
|
+
const chalkInstance = buildChalk({
|
|
41
|
+
color: Boolean(dependencies.stdout.isTTY) && !noColor,
|
|
42
|
+
});
|
|
43
|
+
const useColor = chalkInstance.level > 0;
|
|
44
|
+
const rl = dependencies.createPrompt({
|
|
45
|
+
input: dependencies.stdin,
|
|
46
|
+
output: dependencies.stdout,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
decider: async ({ file, occurrence, changeNumber, totalChanges }) => {
|
|
51
|
+
dependencies.stdout.write(
|
|
52
|
+
`\n${formatInteractiveChangeBlock(
|
|
53
|
+
{ file, occurrence, changeNumber, totalChanges },
|
|
54
|
+
{
|
|
55
|
+
chalkInstance,
|
|
56
|
+
color: useColor,
|
|
57
|
+
},
|
|
58
|
+
)}\n`,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
while (true) {
|
|
62
|
+
const answer = await rl.question(
|
|
63
|
+
useColor
|
|
64
|
+
? chalkInstance.bold("Choice [y/n/a/q] (default: n): ")
|
|
65
|
+
: "Choice [y/n/a/q] (default: n): ",
|
|
66
|
+
);
|
|
67
|
+
const parsed = parseInteractiveChoice(answer);
|
|
68
|
+
if (parsed) {
|
|
69
|
+
return parsed;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
dependencies.stdout.write(
|
|
73
|
+
useColor
|
|
74
|
+
? `${chalkInstance.yellow("Invalid choice.")} Use y, n, a, or q.\n`
|
|
75
|
+
: "Invalid choice. Use y, n, a, or q.\n",
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
close: () => rl.close(),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function formatInteractiveChangeBlock(
|
|
84
|
+
ctx: InteractiveContext,
|
|
85
|
+
options: FormatPatchOutputOptions = {},
|
|
86
|
+
): string {
|
|
87
|
+
const chalkInstance = buildChalk(options);
|
|
88
|
+
const useColor = chalkInstance.level > 0;
|
|
89
|
+
const safeFile = escapeTerminalText(ctx.file);
|
|
90
|
+
const divider = "─".repeat(72);
|
|
91
|
+
const oldCount = countLines(ctx.occurrence.matched);
|
|
92
|
+
const newCount = countLines(ctx.occurrence.replacement);
|
|
93
|
+
const hunkHeader = `@@ -${ctx.occurrence.line},${oldCount} +${ctx.occurrence.line},${newCount} @@`;
|
|
94
|
+
const lines = [
|
|
95
|
+
useColor ? chalkInstance.gray(divider) : divider,
|
|
96
|
+
useColor
|
|
97
|
+
? chalkInstance.bold(
|
|
98
|
+
`Change ${ctx.changeNumber}/${ctx.totalChanges} · ${safeFile}:${ctx.occurrence.line}:${ctx.occurrence.character}`,
|
|
99
|
+
)
|
|
100
|
+
: `Change ${ctx.changeNumber}/${ctx.totalChanges} · ${safeFile}:${ctx.occurrence.line}:${ctx.occurrence.character}`,
|
|
101
|
+
useColor ? chalkInstance.cyan(hunkHeader) : hunkHeader,
|
|
102
|
+
...splitDiffLines(ctx.occurrence.matched).map((line) =>
|
|
103
|
+
useColor ? chalkInstance.red(`-${escapeTerminalText(line)}`) : `-${escapeTerminalText(line)}`,
|
|
104
|
+
),
|
|
105
|
+
...splitDiffLines(ctx.occurrence.replacement).map((line) =>
|
|
106
|
+
useColor
|
|
107
|
+
? chalkInstance.green(`+${escapeTerminalText(line)}`)
|
|
108
|
+
: `+${escapeTerminalText(line)}`,
|
|
109
|
+
),
|
|
110
|
+
useColor
|
|
111
|
+
? chalkInstance.gray("Actions: [y] apply · [n] skip · [a] apply remaining · [q] quit")
|
|
112
|
+
: "Actions: [y] apply · [n] skip · [a] apply remaining · [q] quit",
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
return lines.join("\n");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function parseInteractiveChoice(answer: string): InteractiveChoice | null {
|
|
119
|
+
const normalized = answer.trim().toLowerCase();
|
|
120
|
+
if (normalized.length === 0 || normalized === "n" || normalized === "no") {
|
|
121
|
+
return "no";
|
|
122
|
+
}
|
|
123
|
+
if (normalized === "y" || normalized === "yes") {
|
|
124
|
+
return "yes";
|
|
125
|
+
}
|
|
126
|
+
if (normalized === "a" || normalized === "all") {
|
|
127
|
+
return "all";
|
|
128
|
+
}
|
|
129
|
+
if (normalized === "q" || normalized === "quit") {
|
|
130
|
+
return "quit";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { SpatchOccurrence, SpatchOptions } from "../../types.ts";
|
|
2
|
+
|
|
3
|
+
export type InteractiveChoice = "yes" | "no" | "all" | "quit";
|
|
4
|
+
|
|
5
|
+
export type InteractiveContext = {
|
|
6
|
+
file: string;
|
|
7
|
+
occurrence: SpatchOccurrence;
|
|
8
|
+
changeNumber: number;
|
|
9
|
+
totalChanges: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type InteractiveDecider = (ctx: InteractiveContext) => Promise<InteractiveChoice>;
|
|
13
|
+
|
|
14
|
+
export type RunInteractivePatchCommandOptions = Pick<
|
|
15
|
+
SpatchOptions,
|
|
16
|
+
"concurrency" | "cwd" | "encoding" | "logger" | "scope" | "verbose"
|
|
17
|
+
> & {
|
|
18
|
+
noColor: boolean;
|
|
19
|
+
interactiveDecider?: InteractiveDecider;
|
|
20
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { SpatchOccurrence } from "../../types.ts";
|
|
2
|
+
|
|
3
|
+
export function validateSelectedOccurrences(
|
|
4
|
+
file: string,
|
|
5
|
+
source: string,
|
|
6
|
+
occurrences: readonly SpatchOccurrence[],
|
|
7
|
+
): void {
|
|
8
|
+
const sorted = [...occurrences].sort((left, right) => left.start - right.start);
|
|
9
|
+
let cursor = 0;
|
|
10
|
+
|
|
11
|
+
for (const occurrence of sorted) {
|
|
12
|
+
if (
|
|
13
|
+
occurrence.start < 0 ||
|
|
14
|
+
occurrence.end < occurrence.start ||
|
|
15
|
+
occurrence.end > source.length
|
|
16
|
+
) {
|
|
17
|
+
throw new Error(
|
|
18
|
+
`File changed during interactive patch selection: ${file}. Re-run spatch interactive to refresh match positions.`,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
if (occurrence.start < cursor) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
`Invalid overlapping interactive occurrences for ${file}. Re-run spatch interactive.`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const currentMatched = source.slice(occurrence.start, occurrence.end);
|
|
28
|
+
if (currentMatched !== occurrence.matched) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`File changed during interactive patch selection: ${file}. Re-run spatch interactive to refresh match positions.`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
cursor = occurrence.end;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { runInteractivePatchCommand } from "./interactive/run.ts";
|