@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/README.md
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# spatch
|
|
2
|
+
|
|
3
|
+
`spatch` applies structural rewrites using a patch document.
|
|
4
|
+
|
|
5
|
+
A patch document is a text block where:
|
|
6
|
+
- `-` lines define what to match
|
|
7
|
+
- `+` lines define what to insert
|
|
8
|
+
- other lines are context shared by both sides
|
|
9
|
+
|
|
10
|
+
You can pass the patch document either:
|
|
11
|
+
- inline as a string
|
|
12
|
+
- as a file path
|
|
13
|
+
|
|
14
|
+
## CLI
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
spatch <patch-input> [scope] [--cwd <path>] [--dry-run] [--json] [--no-color] [--interactive]
|
|
18
|
+
# or:
|
|
19
|
+
astkit patch <patch-input> [scope] [--cwd <path>] [--dry-run] [--json] [--no-color] [--interactive]
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Examples:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# patch document from file
|
|
26
|
+
spatch rules/const-to-let.spatch src --cwd /repo
|
|
27
|
+
|
|
28
|
+
# inline patch document
|
|
29
|
+
spatch $'-const :[name] = :[value];\n+let :[name] = :[value];' src
|
|
30
|
+
|
|
31
|
+
# preview only
|
|
32
|
+
spatch rules/const-to-let.spatch src --dry-run
|
|
33
|
+
|
|
34
|
+
# structured JSON output
|
|
35
|
+
spatch rules/const-to-let.spatch src --json
|
|
36
|
+
|
|
37
|
+
# interactive apply mode
|
|
38
|
+
spatch rules/const-to-let.spatch src --interactive
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## API
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import { patchProject } from "@claudiu-ceia/spatch";
|
|
45
|
+
|
|
46
|
+
await patchProject(patchInput, {
|
|
47
|
+
cwd: "/repo", // optional, default process.cwd()
|
|
48
|
+
scope: "src", // file or directory, default "."
|
|
49
|
+
dryRun: false, // default false
|
|
50
|
+
encoding: "utf8", // default utf8
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`patchInput` can be:
|
|
55
|
+
- a patch document string
|
|
56
|
+
- a path to a patch file (resolved from `cwd`)
|
|
57
|
+
|
|
58
|
+
## Patch Document Grammar
|
|
59
|
+
|
|
60
|
+
### Line kinds
|
|
61
|
+
|
|
62
|
+
- `-...`: deletion line (belongs to match pattern only)
|
|
63
|
+
- `+...`: addition line (belongs to replacement only)
|
|
64
|
+
- ` ...`: context line (belongs to both pattern and replacement)
|
|
65
|
+
- `\-...` and `\+...`: escaped marker lines, treated as literal context starting with `-` or `+`
|
|
66
|
+
|
|
67
|
+
### Minimum change rule
|
|
68
|
+
|
|
69
|
+
A patch document must contain at least one `-` line or one `+` line.
|
|
70
|
+
|
|
71
|
+
### Newline behavior
|
|
72
|
+
|
|
73
|
+
If the patch document ends with a trailing newline, both generated `pattern` and `replacement` preserve it.
|
|
74
|
+
|
|
75
|
+
## Metavariables
|
|
76
|
+
|
|
77
|
+
Inside pattern/replacement text, holes use this syntax:
|
|
78
|
+
|
|
79
|
+
- `:[name]`
|
|
80
|
+
- `:[_]` (anonymous hole, not captured)
|
|
81
|
+
- `:[name~regex]` (capture must satisfy regex)
|
|
82
|
+
- `...` (variadic wildcard; captured and reusable in replacement)
|
|
83
|
+
|
|
84
|
+
Examples:
|
|
85
|
+
|
|
86
|
+
```text
|
|
87
|
+
-const :[name] = :[value];
|
|
88
|
+
+let :[name] = :[value];
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
```text
|
|
92
|
+
-const :[name~[a-z]+] = :[value~\d+];
|
|
93
|
+
+let :[name] = Number(:[value]);
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Repeated holes enforce equality:
|
|
97
|
+
|
|
98
|
+
```text
|
|
99
|
+
-:[x] + :[x];
|
|
100
|
+
+double(:[x]);
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`foo + foo` matches, `foo + bar` does not.
|
|
104
|
+
|
|
105
|
+
Variadic example:
|
|
106
|
+
|
|
107
|
+
```text
|
|
108
|
+
-foo(:[x], ...);
|
|
109
|
+
+bar(:[x], ...);
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Rewrites the callee and preserves remaining arguments.
|
|
113
|
+
|
|
114
|
+
## How It Works
|
|
115
|
+
|
|
116
|
+
### 1) Parse phase
|
|
117
|
+
|
|
118
|
+
`patchProject` resolves `patchInput` into a patch document and parses it into:
|
|
119
|
+
- `pattern`
|
|
120
|
+
- `replacement`
|
|
121
|
+
|
|
122
|
+
### 2) Rewrite phase
|
|
123
|
+
|
|
124
|
+
For each scoped file:
|
|
125
|
+
- compile template tokens from `pattern`
|
|
126
|
+
- find all structural matches
|
|
127
|
+
- render `replacement` with captures
|
|
128
|
+
- apply replacements
|
|
129
|
+
- optionally write file (skipped in `dryRun`)
|
|
130
|
+
|
|
131
|
+
### 3) Output phase
|
|
132
|
+
|
|
133
|
+
Return an aggregate result:
|
|
134
|
+
- files scanned/matched/changed
|
|
135
|
+
- match and replacement counts
|
|
136
|
+
- elapsed time
|
|
137
|
+
- per-file occurrences with spans and captures
|
|
138
|
+
|
|
139
|
+
## Structural Balancing
|
|
140
|
+
|
|
141
|
+
Hole captures are checked for structural balance to avoid malformed partial captures.
|
|
142
|
+
|
|
143
|
+
Balanced constructs supported in capture chunks:
|
|
144
|
+
- parentheses `(...)`
|
|
145
|
+
- brackets `[...]`
|
|
146
|
+
- braces `{...}`
|
|
147
|
+
- single/double/template strings
|
|
148
|
+
- line and block comments
|
|
149
|
+
|
|
150
|
+
## End-to-End Example
|
|
151
|
+
|
|
152
|
+
Patch document:
|
|
153
|
+
|
|
154
|
+
```text
|
|
155
|
+
function wrap() {
|
|
156
|
+
- const value = :[value];
|
|
157
|
+
+ let value = :[value];
|
|
158
|
+
return value;
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Call:
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
await patchProject("rules/wrap.spatch", { cwd: "/repo", scope: "src" });
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Flow Diagram
|
|
169
|
+
|
|
170
|
+
```text
|
|
171
|
+
patchProject(patchInput, options)
|
|
172
|
+
-> parsePatchInvocation
|
|
173
|
+
-> resolve patch text (inline or file)
|
|
174
|
+
-> parsePatchDocument (+/-/context)
|
|
175
|
+
-> rewriteProject
|
|
176
|
+
-> compileTemplate(pattern)
|
|
177
|
+
-> collect files
|
|
178
|
+
-> for each file: match -> render -> apply -> (write)
|
|
179
|
+
-> buildSpatchResult
|
|
180
|
+
```
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { formatPatchOutput, runPatchCommand } from "./command.js";
|
|
3
|
+
function parsePositiveInteger(name, raw) {
|
|
4
|
+
const value = Number(raw);
|
|
5
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
6
|
+
throw new Error(`${name} must be a positive number`);
|
|
7
|
+
}
|
|
8
|
+
return Math.floor(value);
|
|
9
|
+
}
|
|
10
|
+
function readFlagValue(argv, index) {
|
|
11
|
+
const token = argv[index];
|
|
12
|
+
if (!token) {
|
|
13
|
+
throw new Error("Missing flag token");
|
|
14
|
+
}
|
|
15
|
+
const eqIndex = token.indexOf("=");
|
|
16
|
+
if (eqIndex >= 0) {
|
|
17
|
+
const value = token.slice(eqIndex + 1);
|
|
18
|
+
if (value.length === 0) {
|
|
19
|
+
throw new Error(`Missing value for ${token.slice(0, eqIndex)}`);
|
|
20
|
+
}
|
|
21
|
+
return { value, consumed: 1 };
|
|
22
|
+
}
|
|
23
|
+
const next = argv[index + 1];
|
|
24
|
+
if (!next) {
|
|
25
|
+
throw new Error(`Missing value for ${token}`);
|
|
26
|
+
}
|
|
27
|
+
return { value: next, consumed: 2 };
|
|
28
|
+
}
|
|
29
|
+
function printHelp() {
|
|
30
|
+
process.stdout.write([
|
|
31
|
+
"spatch - structural patch for TS/JS",
|
|
32
|
+
"",
|
|
33
|
+
"Usage:",
|
|
34
|
+
" spatch [--dry-run] [--interactive] [--json] [--no-color] [--cwd <path>] <patch-input> [scope]",
|
|
35
|
+
"",
|
|
36
|
+
"Flags:",
|
|
37
|
+
" --dry-run Preview changes without writing files",
|
|
38
|
+
" --interactive Interactively select which matches to apply",
|
|
39
|
+
" --json Output structured JSON",
|
|
40
|
+
" --no-color Disable colored output",
|
|
41
|
+
" --cwd <path> Working directory for resolving patch and scope",
|
|
42
|
+
" --concurrency <n> Max files processed concurrently (default: 8)",
|
|
43
|
+
" --verbose <level> Perf tracing to stderr (1=summary, 2=slow files)",
|
|
44
|
+
"",
|
|
45
|
+
].join("\n"));
|
|
46
|
+
}
|
|
47
|
+
const argv = process.argv.slice(2);
|
|
48
|
+
if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) {
|
|
49
|
+
printHelp();
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
const flags = {};
|
|
53
|
+
const positional = [];
|
|
54
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
55
|
+
const token = argv[i];
|
|
56
|
+
if (!token)
|
|
57
|
+
continue;
|
|
58
|
+
if (!token.startsWith("-")) {
|
|
59
|
+
positional.push(token);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (token === "--dry-run") {
|
|
63
|
+
flags["dry-run"] = true;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (token === "--interactive") {
|
|
67
|
+
flags.interactive = true;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (token === "--json") {
|
|
71
|
+
flags.json = true;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (token === "--no-color") {
|
|
75
|
+
flags["no-color"] = true;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (token === "--cwd" || token.startsWith("--cwd=")) {
|
|
79
|
+
const { value, consumed } = readFlagValue(argv, i);
|
|
80
|
+
flags.cwd = value;
|
|
81
|
+
i += consumed - 1;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (token === "--concurrency" || token.startsWith("--concurrency=")) {
|
|
85
|
+
const { value, consumed } = readFlagValue(argv, i);
|
|
86
|
+
flags.concurrency = parsePositiveInteger("--concurrency", value);
|
|
87
|
+
i += consumed - 1;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (token === "--verbose" || token.startsWith("--verbose=")) {
|
|
91
|
+
const { value, consumed } = readFlagValue(argv, i);
|
|
92
|
+
const level = Number(value);
|
|
93
|
+
if (!Number.isFinite(level) || level < 0) {
|
|
94
|
+
throw new Error("--verbose must be a non-negative number");
|
|
95
|
+
}
|
|
96
|
+
flags.verbose = Math.floor(level);
|
|
97
|
+
i += consumed - 1;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
throw new Error(`Unknown flag: ${token}`);
|
|
101
|
+
}
|
|
102
|
+
const patchInput = positional[0];
|
|
103
|
+
const scope = positional[1];
|
|
104
|
+
if (!patchInput) {
|
|
105
|
+
printHelp();
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
if (positional.length > 2) {
|
|
109
|
+
throw new Error("Too many positional arguments.");
|
|
110
|
+
}
|
|
111
|
+
const result = await runPatchCommand(patchInput, scope, flags);
|
|
112
|
+
if (flags.json ?? false) {
|
|
113
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
const output = formatPatchOutput(result, {
|
|
117
|
+
color: Boolean(process.stdout.isTTY) && !(flags["no-color"] ?? false),
|
|
118
|
+
});
|
|
119
|
+
process.stdout.write(`${output}\n`);
|
|
120
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type ChalkInstance } from "chalk";
|
|
2
|
+
import type { SpatchOccurrence, SpatchResult } from "./types.ts";
|
|
3
|
+
export type PatchCommandFlags = {
|
|
4
|
+
"dry-run"?: boolean;
|
|
5
|
+
interactive?: boolean;
|
|
6
|
+
json?: boolean;
|
|
7
|
+
"no-color"?: boolean;
|
|
8
|
+
cwd?: string;
|
|
9
|
+
concurrency?: number;
|
|
10
|
+
verbose?: number;
|
|
11
|
+
};
|
|
12
|
+
type InteractiveChoice = "yes" | "no" | "all" | "quit";
|
|
13
|
+
export type InteractiveContext = {
|
|
14
|
+
file: string;
|
|
15
|
+
occurrence: SpatchOccurrence;
|
|
16
|
+
changeNumber: number;
|
|
17
|
+
totalChanges: number;
|
|
18
|
+
};
|
|
19
|
+
export type RunPatchCommandOptions = {
|
|
20
|
+
interactiveDecider?: (ctx: InteractiveContext) => Promise<InteractiveChoice>;
|
|
21
|
+
};
|
|
22
|
+
export declare function runPatchCommand(patchInput: string, scope: string | undefined, flags: PatchCommandFlags, options?: RunPatchCommandOptions): Promise<SpatchResult>;
|
|
23
|
+
type FormatPatchOutputOptions = {
|
|
24
|
+
color?: boolean;
|
|
25
|
+
chalkInstance?: ChalkInstance;
|
|
26
|
+
};
|
|
27
|
+
export declare function formatPatchOutput(result: SpatchResult, options?: FormatPatchOutputOptions): string;
|
|
28
|
+
export declare const patchCommand: import("@stricli/core").Command<import("@stricli/core").CommandContext>;
|
|
29
|
+
export {};
|
package/dist/command.js
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
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";
|
|
5
|
+
import { buildCommand } from "@stricli/core";
|
|
6
|
+
import chalk, { Chalk } from "chalk";
|
|
7
|
+
import { patchProject } from "./spatch.js";
|
|
8
|
+
export async function runPatchCommand(patchInput, scope, flags, options = {}) {
|
|
9
|
+
const patchScope = scope ?? ".";
|
|
10
|
+
const patchCwd = flags.cwd;
|
|
11
|
+
if (flags.interactive ?? false) {
|
|
12
|
+
if (flags["dry-run"] ?? false) {
|
|
13
|
+
throw new Error("Cannot combine --interactive with --dry-run.");
|
|
14
|
+
}
|
|
15
|
+
return runInteractivePatchCommand(patchInput, patchScope, patchCwd, flags["no-color"] ?? false, options.interactiveDecider);
|
|
16
|
+
}
|
|
17
|
+
return patchProject(patchInput, {
|
|
18
|
+
concurrency: flags.concurrency,
|
|
19
|
+
cwd: patchCwd,
|
|
20
|
+
dryRun: flags["dry-run"] ?? false,
|
|
21
|
+
scope: patchScope,
|
|
22
|
+
verbose: flags.verbose,
|
|
23
|
+
logger: flags.verbose ? (line) => processStderr.write(`${line}\n`) : undefined,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
export function formatPatchOutput(result, options = {}) {
|
|
27
|
+
const chalkInstance = buildChalk(options);
|
|
28
|
+
const useColor = chalkInstance.level > 0;
|
|
29
|
+
const lines = [];
|
|
30
|
+
const changedFiles = result.files.filter((file) => file.replacementCount > 0);
|
|
31
|
+
for (const file of changedFiles) {
|
|
32
|
+
const headerPrefix = useColor ? chalkInstance.bold : (value) => value;
|
|
33
|
+
lines.push(headerPrefix(`diff --git a/${file.file} b/${file.file}`));
|
|
34
|
+
lines.push(useColor ? chalkInstance.gray(`--- a/${file.file}`) : `--- a/${file.file}`);
|
|
35
|
+
lines.push(useColor ? chalkInstance.gray(`+++ b/${file.file}`) : `+++ b/${file.file}`);
|
|
36
|
+
for (const occurrence of file.occurrences) {
|
|
37
|
+
if (occurrence.matched === occurrence.replacement) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const oldCount = countLines(occurrence.matched);
|
|
41
|
+
const newCount = countLines(occurrence.replacement);
|
|
42
|
+
const hunkHeader = `@@ -${occurrence.line},${oldCount} +${occurrence.line},${newCount} @@`;
|
|
43
|
+
lines.push(useColor ? chalkInstance.cyan(hunkHeader) : hunkHeader);
|
|
44
|
+
for (const oldLine of splitDiffLines(occurrence.matched)) {
|
|
45
|
+
const line = `-${oldLine}`;
|
|
46
|
+
lines.push(useColor ? chalkInstance.red(line) : line);
|
|
47
|
+
}
|
|
48
|
+
for (const newLine of splitDiffLines(occurrence.replacement)) {
|
|
49
|
+
const line = `+${newLine}`;
|
|
50
|
+
lines.push(useColor ? chalkInstance.green(line) : line);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (changedFiles.length === 0) {
|
|
55
|
+
lines.push(useColor ? chalkInstance.gray("No changes.") : "No changes.");
|
|
56
|
+
}
|
|
57
|
+
const summary = [
|
|
58
|
+
`${result.filesChanged} ${pluralize("file", result.filesChanged)} changed`,
|
|
59
|
+
`${result.totalReplacements} ${pluralize("replacement", result.totalReplacements)}`,
|
|
60
|
+
result.dryRun ? "(dry-run)" : null,
|
|
61
|
+
]
|
|
62
|
+
.filter((part) => part !== null)
|
|
63
|
+
.join(", ");
|
|
64
|
+
lines.push(useColor ? chalkInstance.gray(summary) : summary);
|
|
65
|
+
return lines.join("\n");
|
|
66
|
+
}
|
|
67
|
+
export const patchCommand = buildCommand({
|
|
68
|
+
async func(flags, patchInput, scope) {
|
|
69
|
+
const result = await runPatchCommand(patchInput, scope, flags);
|
|
70
|
+
if (flags.json ?? false) {
|
|
71
|
+
this.process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const output = formatPatchOutput(result, {
|
|
75
|
+
color: Boolean(processStdout.isTTY) && !(flags["no-color"] ?? false),
|
|
76
|
+
});
|
|
77
|
+
this.process.stdout.write(`${output}\n`);
|
|
78
|
+
},
|
|
79
|
+
parameters: {
|
|
80
|
+
flags: {
|
|
81
|
+
concurrency: {
|
|
82
|
+
kind: "parsed",
|
|
83
|
+
optional: true,
|
|
84
|
+
brief: "Max files processed concurrently (default: 8)",
|
|
85
|
+
placeholder: "n",
|
|
86
|
+
parse: (input) => {
|
|
87
|
+
const value = Number(input);
|
|
88
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
89
|
+
throw new Error("--concurrency must be a positive number");
|
|
90
|
+
}
|
|
91
|
+
return Math.floor(value);
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
verbose: {
|
|
95
|
+
kind: "parsed",
|
|
96
|
+
optional: true,
|
|
97
|
+
brief: "Print perf tracing (1=summary, 2=includes slow files)",
|
|
98
|
+
placeholder: "level",
|
|
99
|
+
parse: (input) => {
|
|
100
|
+
const value = Number(input);
|
|
101
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
102
|
+
throw new Error("--verbose must be a non-negative number");
|
|
103
|
+
}
|
|
104
|
+
return Math.floor(value);
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
interactive: {
|
|
108
|
+
kind: "boolean",
|
|
109
|
+
optional: true,
|
|
110
|
+
brief: "Interactively select which matches to apply",
|
|
111
|
+
},
|
|
112
|
+
json: {
|
|
113
|
+
kind: "boolean",
|
|
114
|
+
optional: true,
|
|
115
|
+
brief: "Output structured JSON instead of compact diff-style text",
|
|
116
|
+
},
|
|
117
|
+
"no-color": {
|
|
118
|
+
kind: "boolean",
|
|
119
|
+
optional: true,
|
|
120
|
+
brief: "Disable colored output",
|
|
121
|
+
},
|
|
122
|
+
"dry-run": {
|
|
123
|
+
kind: "boolean",
|
|
124
|
+
optional: true,
|
|
125
|
+
brief: "Preview changes without writing files",
|
|
126
|
+
},
|
|
127
|
+
cwd: {
|
|
128
|
+
kind: "parsed",
|
|
129
|
+
optional: true,
|
|
130
|
+
brief: "Working directory for resolving patch file and scope",
|
|
131
|
+
placeholder: "path",
|
|
132
|
+
parse: (input) => input,
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
positional: {
|
|
136
|
+
kind: "tuple",
|
|
137
|
+
parameters: [
|
|
138
|
+
{
|
|
139
|
+
brief: "Patch document text or path to patch document file",
|
|
140
|
+
placeholder: "patch",
|
|
141
|
+
parse: (input) => input,
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
brief: "Scope file or directory (defaults to current directory)",
|
|
145
|
+
placeholder: "scope",
|
|
146
|
+
parse: (input) => input,
|
|
147
|
+
optional: true,
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
docs: {
|
|
153
|
+
brief: "Apply structural rewrite from a patch document",
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
function buildChalk(options) {
|
|
157
|
+
if (options.chalkInstance) {
|
|
158
|
+
return options.chalkInstance;
|
|
159
|
+
}
|
|
160
|
+
const shouldColor = options.color ?? false;
|
|
161
|
+
if (!shouldColor) {
|
|
162
|
+
return new Chalk({ level: 0 });
|
|
163
|
+
}
|
|
164
|
+
const level = chalk.level > 0 ? chalk.level : 1;
|
|
165
|
+
return new Chalk({ level });
|
|
166
|
+
}
|
|
167
|
+
function splitDiffLines(text) {
|
|
168
|
+
const normalized = text.replaceAll("\r\n", "\n");
|
|
169
|
+
if (normalized.length === 0) {
|
|
170
|
+
return [""];
|
|
171
|
+
}
|
|
172
|
+
return normalized.split("\n");
|
|
173
|
+
}
|
|
174
|
+
function countLines(text) {
|
|
175
|
+
const normalized = text.replaceAll("\r\n", "\n");
|
|
176
|
+
if (normalized.length === 0) {
|
|
177
|
+
return 0;
|
|
178
|
+
}
|
|
179
|
+
return normalized.split("\n").length;
|
|
180
|
+
}
|
|
181
|
+
function pluralize(word, count) {
|
|
182
|
+
return count === 1 ? word : `${word}s`;
|
|
183
|
+
}
|
|
184
|
+
async function runInteractivePatchCommand(patchInput, scope, cwd, noColor, interactiveDecider) {
|
|
185
|
+
if (!interactiveDecider && (!processStdin.isTTY || !processStdout.isTTY)) {
|
|
186
|
+
throw new Error("Interactive mode requires a TTY stdin/stdout.");
|
|
187
|
+
}
|
|
188
|
+
const startedAt = Date.now();
|
|
189
|
+
const dryResult = await patchProject(patchInput, {
|
|
190
|
+
cwd,
|
|
191
|
+
dryRun: true,
|
|
192
|
+
scope,
|
|
193
|
+
});
|
|
194
|
+
const totalChanges = dryResult.files.reduce((count, file) => count +
|
|
195
|
+
file.occurrences.filter((occurrence) => occurrence.matched !== occurrence.replacement).length, 0);
|
|
196
|
+
let interactivePrompt = null;
|
|
197
|
+
const decider = interactiveDecider ??
|
|
198
|
+
((interactivePrompt = await createTerminalInteractiveDecider(noColor)),
|
|
199
|
+
interactivePrompt.decider);
|
|
200
|
+
const selectedByFile = new Map();
|
|
201
|
+
let applyAll = false;
|
|
202
|
+
let stop = false;
|
|
203
|
+
let changeNumber = 0;
|
|
204
|
+
try {
|
|
205
|
+
for (const file of dryResult.files) {
|
|
206
|
+
const selected = [];
|
|
207
|
+
for (const occurrence of file.occurrences) {
|
|
208
|
+
if (occurrence.matched === occurrence.replacement) {
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
changeNumber += 1;
|
|
212
|
+
if (applyAll) {
|
|
213
|
+
selected.push(occurrence);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
const choice = await decider({
|
|
217
|
+
file: file.file,
|
|
218
|
+
occurrence,
|
|
219
|
+
changeNumber,
|
|
220
|
+
totalChanges,
|
|
221
|
+
});
|
|
222
|
+
if (choice === "yes") {
|
|
223
|
+
selected.push(occurrence);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (choice === "all") {
|
|
227
|
+
applyAll = true;
|
|
228
|
+
selected.push(occurrence);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (choice === "quit") {
|
|
232
|
+
stop = true;
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
selectedByFile.set(file.file, selected);
|
|
237
|
+
if (stop) {
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
finally {
|
|
243
|
+
interactivePrompt?.close();
|
|
244
|
+
}
|
|
245
|
+
const fileResults = [];
|
|
246
|
+
let filesChanged = 0;
|
|
247
|
+
let totalReplacements = 0;
|
|
248
|
+
for (const file of dryResult.files) {
|
|
249
|
+
const selected = selectedByFile.get(file.file) ?? [];
|
|
250
|
+
if (selected.length === 0) {
|
|
251
|
+
fileResults.push({
|
|
252
|
+
...file,
|
|
253
|
+
replacementCount: 0,
|
|
254
|
+
changed: false,
|
|
255
|
+
byteDelta: 0,
|
|
256
|
+
occurrences: [],
|
|
257
|
+
});
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
const absolutePath = path.resolve(cwd ?? process.cwd(), file.file);
|
|
261
|
+
const originalText = await readFile(absolutePath, "utf8");
|
|
262
|
+
const rewrittenText = applySelectedOccurrences(originalText, selected);
|
|
263
|
+
const changed = rewrittenText !== originalText;
|
|
264
|
+
if (changed) {
|
|
265
|
+
await writeFile(absolutePath, rewrittenText, "utf8");
|
|
266
|
+
}
|
|
267
|
+
const replacementCount = selected.filter((occurrence) => occurrence.matched !== occurrence.replacement).length;
|
|
268
|
+
totalReplacements += replacementCount;
|
|
269
|
+
if (changed) {
|
|
270
|
+
filesChanged += 1;
|
|
271
|
+
}
|
|
272
|
+
fileResults.push({
|
|
273
|
+
...file,
|
|
274
|
+
replacementCount,
|
|
275
|
+
changed,
|
|
276
|
+
byteDelta: changed
|
|
277
|
+
? Buffer.byteLength(rewrittenText, "utf8") -
|
|
278
|
+
Buffer.byteLength(originalText, "utf8")
|
|
279
|
+
: 0,
|
|
280
|
+
occurrences: selected,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
...dryResult,
|
|
285
|
+
dryRun: false,
|
|
286
|
+
filesChanged,
|
|
287
|
+
totalReplacements,
|
|
288
|
+
elapsedMs: Date.now() - startedAt,
|
|
289
|
+
files: fileResults,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
function applySelectedOccurrences(source, occurrences) {
|
|
293
|
+
if (occurrences.length === 0) {
|
|
294
|
+
return source;
|
|
295
|
+
}
|
|
296
|
+
const sorted = [...occurrences].sort((left, right) => left.start - right.start);
|
|
297
|
+
const parts = [];
|
|
298
|
+
let cursor = 0;
|
|
299
|
+
for (const occurrence of sorted) {
|
|
300
|
+
parts.push(source.slice(cursor, occurrence.start));
|
|
301
|
+
parts.push(occurrence.replacement);
|
|
302
|
+
cursor = occurrence.end;
|
|
303
|
+
}
|
|
304
|
+
parts.push(source.slice(cursor));
|
|
305
|
+
return parts.join("");
|
|
306
|
+
}
|
|
307
|
+
async function createTerminalInteractiveDecider(noColor) {
|
|
308
|
+
const chalkInstance = buildChalk({
|
|
309
|
+
color: processStdout.isTTY && !noColor,
|
|
310
|
+
});
|
|
311
|
+
const useColor = chalkInstance.level > 0;
|
|
312
|
+
const rl = createInterface({
|
|
313
|
+
input: processStdin,
|
|
314
|
+
output: processStdout,
|
|
315
|
+
});
|
|
316
|
+
return {
|
|
317
|
+
decider: async ({ file, occurrence, changeNumber, totalChanges }) => {
|
|
318
|
+
processStdout.write(`\n${formatInteractiveChangeBlock({ file, occurrence, changeNumber, totalChanges }, {
|
|
319
|
+
chalkInstance,
|
|
320
|
+
color: useColor,
|
|
321
|
+
})}\n`);
|
|
322
|
+
while (true) {
|
|
323
|
+
const answer = await rl.question(useColor
|
|
324
|
+
? chalkInstance.bold("Choice [y/n/a/q] (default: n): ")
|
|
325
|
+
: "Choice [y/n/a/q] (default: n): ");
|
|
326
|
+
const parsed = parseInteractiveChoice(answer);
|
|
327
|
+
if (parsed) {
|
|
328
|
+
return parsed;
|
|
329
|
+
}
|
|
330
|
+
processStdout.write(useColor
|
|
331
|
+
? `${chalkInstance.yellow("Invalid choice.")} Use y, n, a, or q.\n`
|
|
332
|
+
: "Invalid choice. Use y, n, a, or q.\n");
|
|
333
|
+
}
|
|
334
|
+
},
|
|
335
|
+
close: () => rl.close(),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
function formatInteractiveChangeBlock(ctx, options = {}) {
|
|
339
|
+
const chalkInstance = buildChalk(options);
|
|
340
|
+
const useColor = chalkInstance.level > 0;
|
|
341
|
+
const divider = "─".repeat(72);
|
|
342
|
+
const oldCount = countLines(ctx.occurrence.matched);
|
|
343
|
+
const newCount = countLines(ctx.occurrence.replacement);
|
|
344
|
+
const hunkHeader = `@@ -${ctx.occurrence.line},${oldCount} +${ctx.occurrence.line},${newCount} @@`;
|
|
345
|
+
const lines = [
|
|
346
|
+
useColor ? chalkInstance.gray(divider) : divider,
|
|
347
|
+
useColor
|
|
348
|
+
? chalkInstance.bold(`Change ${ctx.changeNumber}/${ctx.totalChanges} · ${ctx.file}:${ctx.occurrence.line}:${ctx.occurrence.character}`)
|
|
349
|
+
: `Change ${ctx.changeNumber}/${ctx.totalChanges} · ${ctx.file}:${ctx.occurrence.line}:${ctx.occurrence.character}`,
|
|
350
|
+
useColor ? chalkInstance.cyan(hunkHeader) : hunkHeader,
|
|
351
|
+
...splitDiffLines(ctx.occurrence.matched).map((line) => useColor ? chalkInstance.red(`-${line}`) : `-${line}`),
|
|
352
|
+
...splitDiffLines(ctx.occurrence.replacement).map((line) => useColor ? chalkInstance.green(`+${line}`) : `+${line}`),
|
|
353
|
+
useColor
|
|
354
|
+
? chalkInstance.gray("Actions: [y] apply · [n] skip · [a] apply remaining · [q] quit")
|
|
355
|
+
: "Actions: [y] apply · [n] skip · [a] apply remaining · [q] quit",
|
|
356
|
+
];
|
|
357
|
+
return lines.join("\n");
|
|
358
|
+
}
|
|
359
|
+
function parseInteractiveChoice(answer) {
|
|
360
|
+
const normalized = answer.trim().toLowerCase();
|
|
361
|
+
if (normalized.length === 0 || normalized === "n" || normalized === "no") {
|
|
362
|
+
return "no";
|
|
363
|
+
}
|
|
364
|
+
if (normalized === "y" || normalized === "yes") {
|
|
365
|
+
return "yes";
|
|
366
|
+
}
|
|
367
|
+
if (normalized === "a" || normalized === "all") {
|
|
368
|
+
return "all";
|
|
369
|
+
}
|
|
370
|
+
if (normalized === "q" || normalized === "quit") {
|
|
371
|
+
return "quit";
|
|
372
|
+
}
|
|
373
|
+
return null;
|
|
374
|
+
}
|