@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
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export async function resolveInteractiveFilePath(file, options) {
|
|
4
|
+
if (path.isAbsolute(file)) {
|
|
5
|
+
const absolute = path.resolve(file);
|
|
6
|
+
if (isCandidateInScope(absolute, options)) {
|
|
7
|
+
return absolute;
|
|
8
|
+
}
|
|
9
|
+
throw new Error(`Resolved interactive file is outside selected scope: ${file}`);
|
|
10
|
+
}
|
|
11
|
+
const candidates = new Set();
|
|
12
|
+
if (options.scopeKind === "file") {
|
|
13
|
+
candidates.add(options.scope);
|
|
14
|
+
candidates.add(path.resolve(path.dirname(options.scope), file));
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
candidates.add(path.resolve(options.scope, file));
|
|
18
|
+
}
|
|
19
|
+
candidates.add(path.resolve(options.cwd, file));
|
|
20
|
+
const resolvedCandidates = [];
|
|
21
|
+
for (const candidate of candidates) {
|
|
22
|
+
if (!isCandidateInScope(candidate, options)) {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const candidateStats = await stat(candidate);
|
|
27
|
+
if (candidateStats.isFile()) {
|
|
28
|
+
resolvedCandidates.push(candidate);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// Try next candidate.
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (resolvedCandidates.length === 1) {
|
|
36
|
+
return resolvedCandidates[0];
|
|
37
|
+
}
|
|
38
|
+
if (resolvedCandidates.length > 1) {
|
|
39
|
+
throw new Error(`Ambiguous interactive patch target file: ${file}. Re-run spatch interactive with a narrower scope.`);
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`Unable to resolve interactive patch target file: ${file}. Re-run spatch interactive.`);
|
|
42
|
+
}
|
|
43
|
+
function isCandidateInScope(candidate, options) {
|
|
44
|
+
if (options.scopeKind === "file") {
|
|
45
|
+
return path.resolve(candidate) === options.scope;
|
|
46
|
+
}
|
|
47
|
+
const relative = path.relative(options.scope, candidate);
|
|
48
|
+
if (relative.length === 0) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
if (path.isAbsolute(relative)) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
return relative !== ".." && !relative.startsWith(`..${path.sep}`);
|
|
55
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
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.js";
|
|
5
|
+
import { applyReplacementSpans } from "../../replacement-spans.js";
|
|
6
|
+
import { patchProject } from "../../spatch.js";
|
|
7
|
+
import { resolveInteractiveFilePath } from "./path-resolution.js";
|
|
8
|
+
import { createTerminalInteractiveDecider } from "./terminal.js";
|
|
9
|
+
import { validateSelectedOccurrences } from "./validation.js";
|
|
10
|
+
export async function runInteractivePatchCommand(patchInput, options) {
|
|
11
|
+
const scope = options.scope ?? ".";
|
|
12
|
+
const cwd = options.cwd;
|
|
13
|
+
const resolvedCwd = path.resolve(cwd ?? process.cwd());
|
|
14
|
+
const resolvedScope = path.resolve(resolvedCwd, scope);
|
|
15
|
+
const resolvedScopeStats = await stat(resolvedScope);
|
|
16
|
+
const scopeKind = resolvedScopeStats.isFile()
|
|
17
|
+
? "file"
|
|
18
|
+
: resolvedScopeStats.isDirectory()
|
|
19
|
+
? "directory"
|
|
20
|
+
: (() => {
|
|
21
|
+
throw new Error(`Scope must resolve to a file or directory: ${resolvedScope}`);
|
|
22
|
+
})();
|
|
23
|
+
const encoding = options.encoding ?? "utf8";
|
|
24
|
+
const noColor = options.noColor;
|
|
25
|
+
const interactiveDecider = options.interactiveDecider;
|
|
26
|
+
if (!interactiveDecider && (!processStdin.isTTY || !processStdout.isTTY)) {
|
|
27
|
+
throw new Error("Interactive mode requires a TTY stdin/stdout.");
|
|
28
|
+
}
|
|
29
|
+
const startedAt = Date.now();
|
|
30
|
+
const dryResult = await patchProject(patchInput, {
|
|
31
|
+
concurrency: options.concurrency,
|
|
32
|
+
cwd,
|
|
33
|
+
dryRun: true,
|
|
34
|
+
encoding: options.encoding,
|
|
35
|
+
logger: options.logger,
|
|
36
|
+
scope,
|
|
37
|
+
verbose: options.verbose,
|
|
38
|
+
});
|
|
39
|
+
const totalChanges = dryResult.files.reduce((count, file) => count +
|
|
40
|
+
file.occurrences.filter((occurrence) => occurrence.matched !== occurrence.replacement).length, 0);
|
|
41
|
+
let interactivePrompt = null;
|
|
42
|
+
const decider = interactiveDecider ??
|
|
43
|
+
((interactivePrompt = await createTerminalInteractiveDecider(noColor)),
|
|
44
|
+
interactivePrompt.decider);
|
|
45
|
+
const selectedByFile = new Map();
|
|
46
|
+
let applyAll = false;
|
|
47
|
+
let stop = false;
|
|
48
|
+
let changeNumber = 0;
|
|
49
|
+
try {
|
|
50
|
+
for (const file of dryResult.files) {
|
|
51
|
+
const selected = [];
|
|
52
|
+
for (const occurrence of file.occurrences) {
|
|
53
|
+
if (occurrence.matched === occurrence.replacement) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
changeNumber += 1;
|
|
57
|
+
if (applyAll) {
|
|
58
|
+
selected.push(occurrence);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const choice = await decider({
|
|
62
|
+
file: file.file,
|
|
63
|
+
occurrence,
|
|
64
|
+
changeNumber,
|
|
65
|
+
totalChanges,
|
|
66
|
+
});
|
|
67
|
+
if (choice === "yes") {
|
|
68
|
+
selected.push(occurrence);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (choice === "all") {
|
|
72
|
+
applyAll = true;
|
|
73
|
+
selected.push(occurrence);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (choice === "quit") {
|
|
77
|
+
stop = true;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
selectedByFile.set(file.file, selected);
|
|
82
|
+
if (stop) {
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
interactivePrompt?.close();
|
|
89
|
+
}
|
|
90
|
+
const fileResults = [];
|
|
91
|
+
let filesChanged = 0;
|
|
92
|
+
let totalReplacements = 0;
|
|
93
|
+
const preparedByFile = new Map();
|
|
94
|
+
for (const file of dryResult.files) {
|
|
95
|
+
const selected = selectedByFile.get(file.file) ?? [];
|
|
96
|
+
if (selected.length === 0) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const absolutePath = await resolveInteractiveFilePath(file.file, {
|
|
100
|
+
cwd: resolvedCwd,
|
|
101
|
+
scope: resolvedScope,
|
|
102
|
+
scopeKind,
|
|
103
|
+
});
|
|
104
|
+
const originalText = await readFile(absolutePath, encoding);
|
|
105
|
+
validateSelectedOccurrences(file.file, originalText, selected);
|
|
106
|
+
const rewrittenText = applyReplacementSpans(originalText, selected);
|
|
107
|
+
const changed = rewrittenText !== originalText;
|
|
108
|
+
const replacementCount = selected.filter((occurrence) => occurrence.matched !== occurrence.replacement).length;
|
|
109
|
+
preparedByFile.set(file.file, {
|
|
110
|
+
absolutePath,
|
|
111
|
+
originalText,
|
|
112
|
+
rewrittenText,
|
|
113
|
+
changed,
|
|
114
|
+
replacementCount,
|
|
115
|
+
selected,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
for (const file of dryResult.files) {
|
|
119
|
+
const selected = selectedByFile.get(file.file) ?? [];
|
|
120
|
+
if (selected.length === 0) {
|
|
121
|
+
fileResults.push({
|
|
122
|
+
...file,
|
|
123
|
+
replacementCount: 0,
|
|
124
|
+
changed: false,
|
|
125
|
+
byteDelta: 0,
|
|
126
|
+
occurrences: [],
|
|
127
|
+
});
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const prepared = preparedByFile.get(file.file);
|
|
131
|
+
if (!prepared) {
|
|
132
|
+
throw new Error(`Missing prepared interactive rewrite state for ${file.file}`);
|
|
133
|
+
}
|
|
134
|
+
if (prepared.changed) {
|
|
135
|
+
await writeFileIfUnchangedAtomically({
|
|
136
|
+
filePath: prepared.absolutePath,
|
|
137
|
+
originalText: prepared.originalText,
|
|
138
|
+
rewrittenText: prepared.rewrittenText,
|
|
139
|
+
encoding,
|
|
140
|
+
operationName: "interactive patch apply",
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
fileResults.push({
|
|
144
|
+
...file,
|
|
145
|
+
replacementCount: prepared.replacementCount,
|
|
146
|
+
changed: prepared.changed,
|
|
147
|
+
byteDelta: prepared.changed
|
|
148
|
+
? Buffer.byteLength(prepared.rewrittenText, encoding) -
|
|
149
|
+
Buffer.byteLength(prepared.originalText, encoding)
|
|
150
|
+
: 0,
|
|
151
|
+
occurrences: prepared.selected,
|
|
152
|
+
});
|
|
153
|
+
totalReplacements += prepared.replacementCount;
|
|
154
|
+
if (prepared.changed) {
|
|
155
|
+
filesChanged += 1;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
...dryResult,
|
|
160
|
+
dryRun: false,
|
|
161
|
+
filesChanged,
|
|
162
|
+
totalReplacements,
|
|
163
|
+
elapsedMs: Date.now() - startedAt,
|
|
164
|
+
files: fileResults,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Readable, Writable } from "node:stream";
|
|
2
|
+
import { type FormatPatchOutputOptions } from "../output.ts";
|
|
3
|
+
import type { InteractiveChoice, InteractiveContext, InteractiveDecider } from "./types.ts";
|
|
4
|
+
type PromptInterface = {
|
|
5
|
+
question(prompt: string): Promise<string>;
|
|
6
|
+
close(): void;
|
|
7
|
+
};
|
|
8
|
+
type TerminalInteractiveDependencies = {
|
|
9
|
+
stdin: Readable & {
|
|
10
|
+
isTTY?: boolean;
|
|
11
|
+
};
|
|
12
|
+
stdout: Writable & {
|
|
13
|
+
isTTY?: boolean;
|
|
14
|
+
write(chunk: string): boolean;
|
|
15
|
+
};
|
|
16
|
+
createPrompt: (options: {
|
|
17
|
+
input: Readable & {
|
|
18
|
+
isTTY?: boolean;
|
|
19
|
+
};
|
|
20
|
+
output: Writable & {
|
|
21
|
+
isTTY?: boolean;
|
|
22
|
+
write(chunk: string): boolean;
|
|
23
|
+
};
|
|
24
|
+
}) => PromptInterface;
|
|
25
|
+
};
|
|
26
|
+
export declare function createTerminalInteractiveDecider(noColor: boolean, dependencies?: TerminalInteractiveDependencies): Promise<{
|
|
27
|
+
decider: InteractiveDecider;
|
|
28
|
+
close: () => void;
|
|
29
|
+
}>;
|
|
30
|
+
export declare function formatInteractiveChangeBlock(ctx: InteractiveContext, options?: FormatPatchOutputOptions): string;
|
|
31
|
+
export declare function parseInteractiveChoice(answer: string): InteractiveChoice | null;
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { stdin as processStdin, stdout as processStdout } from "node:process";
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
3
|
+
import { buildChalk, countLines, escapeTerminalText, splitDiffLines, } from "../output.js";
|
|
4
|
+
const defaultTerminalInteractiveDependencies = {
|
|
5
|
+
stdin: processStdin,
|
|
6
|
+
stdout: processStdout,
|
|
7
|
+
createPrompt: ({ input, output }) => createInterface({ input, output }),
|
|
8
|
+
};
|
|
9
|
+
export async function createTerminalInteractiveDecider(noColor, dependencies = defaultTerminalInteractiveDependencies) {
|
|
10
|
+
const chalkInstance = buildChalk({
|
|
11
|
+
color: Boolean(dependencies.stdout.isTTY) && !noColor,
|
|
12
|
+
});
|
|
13
|
+
const useColor = chalkInstance.level > 0;
|
|
14
|
+
const rl = dependencies.createPrompt({
|
|
15
|
+
input: dependencies.stdin,
|
|
16
|
+
output: dependencies.stdout,
|
|
17
|
+
});
|
|
18
|
+
return {
|
|
19
|
+
decider: async ({ file, occurrence, changeNumber, totalChanges }) => {
|
|
20
|
+
dependencies.stdout.write(`\n${formatInteractiveChangeBlock({ file, occurrence, changeNumber, totalChanges }, {
|
|
21
|
+
chalkInstance,
|
|
22
|
+
color: useColor,
|
|
23
|
+
})}\n`);
|
|
24
|
+
while (true) {
|
|
25
|
+
const answer = await rl.question(useColor
|
|
26
|
+
? chalkInstance.bold("Choice [y/n/a/q] (default: n): ")
|
|
27
|
+
: "Choice [y/n/a/q] (default: n): ");
|
|
28
|
+
const parsed = parseInteractiveChoice(answer);
|
|
29
|
+
if (parsed) {
|
|
30
|
+
return parsed;
|
|
31
|
+
}
|
|
32
|
+
dependencies.stdout.write(useColor
|
|
33
|
+
? `${chalkInstance.yellow("Invalid choice.")} Use y, n, a, or q.\n`
|
|
34
|
+
: "Invalid choice. Use y, n, a, or q.\n");
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
close: () => rl.close(),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function formatInteractiveChangeBlock(ctx, options = {}) {
|
|
41
|
+
const chalkInstance = buildChalk(options);
|
|
42
|
+
const useColor = chalkInstance.level > 0;
|
|
43
|
+
const safeFile = escapeTerminalText(ctx.file);
|
|
44
|
+
const divider = "─".repeat(72);
|
|
45
|
+
const oldCount = countLines(ctx.occurrence.matched);
|
|
46
|
+
const newCount = countLines(ctx.occurrence.replacement);
|
|
47
|
+
const hunkHeader = `@@ -${ctx.occurrence.line},${oldCount} +${ctx.occurrence.line},${newCount} @@`;
|
|
48
|
+
const lines = [
|
|
49
|
+
useColor ? chalkInstance.gray(divider) : divider,
|
|
50
|
+
useColor
|
|
51
|
+
? chalkInstance.bold(`Change ${ctx.changeNumber}/${ctx.totalChanges} · ${safeFile}:${ctx.occurrence.line}:${ctx.occurrence.character}`)
|
|
52
|
+
: `Change ${ctx.changeNumber}/${ctx.totalChanges} · ${safeFile}:${ctx.occurrence.line}:${ctx.occurrence.character}`,
|
|
53
|
+
useColor ? chalkInstance.cyan(hunkHeader) : hunkHeader,
|
|
54
|
+
...splitDiffLines(ctx.occurrence.matched).map((line) => useColor ? chalkInstance.red(`-${escapeTerminalText(line)}`) : `-${escapeTerminalText(line)}`),
|
|
55
|
+
...splitDiffLines(ctx.occurrence.replacement).map((line) => useColor
|
|
56
|
+
? chalkInstance.green(`+${escapeTerminalText(line)}`)
|
|
57
|
+
: `+${escapeTerminalText(line)}`),
|
|
58
|
+
useColor
|
|
59
|
+
? chalkInstance.gray("Actions: [y] apply · [n] skip · [a] apply remaining · [q] quit")
|
|
60
|
+
: "Actions: [y] apply · [n] skip · [a] apply remaining · [q] quit",
|
|
61
|
+
];
|
|
62
|
+
return lines.join("\n");
|
|
63
|
+
}
|
|
64
|
+
export function parseInteractiveChoice(answer) {
|
|
65
|
+
const normalized = answer.trim().toLowerCase();
|
|
66
|
+
if (normalized.length === 0 || normalized === "n" || normalized === "no") {
|
|
67
|
+
return "no";
|
|
68
|
+
}
|
|
69
|
+
if (normalized === "y" || normalized === "yes") {
|
|
70
|
+
return "yes";
|
|
71
|
+
}
|
|
72
|
+
if (normalized === "a" || normalized === "all") {
|
|
73
|
+
return "all";
|
|
74
|
+
}
|
|
75
|
+
if (normalized === "q" || normalized === "quit") {
|
|
76
|
+
return "quit";
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { SpatchOccurrence, SpatchOptions } from "../../types.ts";
|
|
2
|
+
export type InteractiveChoice = "yes" | "no" | "all" | "quit";
|
|
3
|
+
export type InteractiveContext = {
|
|
4
|
+
file: string;
|
|
5
|
+
occurrence: SpatchOccurrence;
|
|
6
|
+
changeNumber: number;
|
|
7
|
+
totalChanges: number;
|
|
8
|
+
};
|
|
9
|
+
export type InteractiveDecider = (ctx: InteractiveContext) => Promise<InteractiveChoice>;
|
|
10
|
+
export type RunInteractivePatchCommandOptions = Pick<SpatchOptions, "concurrency" | "cwd" | "encoding" | "logger" | "scope" | "verbose"> & {
|
|
11
|
+
noColor: boolean;
|
|
12
|
+
interactiveDecider?: InteractiveDecider;
|
|
13
|
+
};
|
|
File without changes
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function validateSelectedOccurrences(file, source, occurrences) {
|
|
2
|
+
const sorted = [...occurrences].sort((left, right) => left.start - right.start);
|
|
3
|
+
let cursor = 0;
|
|
4
|
+
for (const occurrence of sorted) {
|
|
5
|
+
if (occurrence.start < 0 ||
|
|
6
|
+
occurrence.end < occurrence.start ||
|
|
7
|
+
occurrence.end > source.length) {
|
|
8
|
+
throw new Error(`File changed during interactive patch selection: ${file}. Re-run spatch interactive to refresh match positions.`);
|
|
9
|
+
}
|
|
10
|
+
if (occurrence.start < cursor) {
|
|
11
|
+
throw new Error(`Invalid overlapping interactive occurrences for ${file}. Re-run spatch interactive.`);
|
|
12
|
+
}
|
|
13
|
+
const currentMatched = source.slice(occurrence.start, occurrence.end);
|
|
14
|
+
if (currentMatched !== occurrence.matched) {
|
|
15
|
+
throw new Error(`File changed during interactive patch selection: ${file}. Re-run spatch interactive to refresh match positions.`);
|
|
16
|
+
}
|
|
17
|
+
cursor = occurrence.end;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { runInteractivePatchCommand } from "./interactive/run.ts";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { runInteractivePatchCommand } from "./interactive/run.js";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type ChalkInstance } from "chalk";
|
|
2
|
+
import type { SpatchResult } from "../types.ts";
|
|
3
|
+
export type FormatPatchOutputOptions = {
|
|
4
|
+
color?: boolean;
|
|
5
|
+
chalkInstance?: ChalkInstance;
|
|
6
|
+
};
|
|
7
|
+
export declare function formatPatchOutput(result: SpatchResult, options?: FormatPatchOutputOptions): string;
|
|
8
|
+
export declare function buildChalk(options: FormatPatchOutputOptions): ChalkInstance;
|
|
9
|
+
export declare function splitDiffLines(text: string): string[];
|
|
10
|
+
export declare function countLines(text: string): number;
|
|
11
|
+
export declare function escapeTerminalText(text: string): string;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import chalk, { Chalk } from "chalk";
|
|
2
|
+
export function formatPatchOutput(result, options = {}) {
|
|
3
|
+
const chalkInstance = buildChalk(options);
|
|
4
|
+
const useColor = chalkInstance.level > 0;
|
|
5
|
+
const lines = [];
|
|
6
|
+
const changedFiles = result.files.filter((file) => file.replacementCount > 0);
|
|
7
|
+
for (const file of changedFiles) {
|
|
8
|
+
const safeFile = escapeTerminalText(file.file);
|
|
9
|
+
const headerPrefix = useColor ? chalkInstance.bold : (value) => value;
|
|
10
|
+
lines.push(headerPrefix(`diff --git a/${safeFile} b/${safeFile}`));
|
|
11
|
+
lines.push(useColor ? chalkInstance.gray(`--- a/${safeFile}`) : `--- a/${safeFile}`);
|
|
12
|
+
lines.push(useColor ? chalkInstance.gray(`+++ b/${safeFile}`) : `+++ b/${safeFile}`);
|
|
13
|
+
for (const occurrence of file.occurrences) {
|
|
14
|
+
if (occurrence.matched === occurrence.replacement) {
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
const oldCount = countLines(occurrence.matched);
|
|
18
|
+
const newCount = countLines(occurrence.replacement);
|
|
19
|
+
const hunkHeader = `@@ -${occurrence.line},${oldCount} +${occurrence.line},${newCount} @@`;
|
|
20
|
+
lines.push(useColor ? chalkInstance.cyan(hunkHeader) : hunkHeader);
|
|
21
|
+
for (const oldLine of splitDiffLines(occurrence.matched)) {
|
|
22
|
+
const line = `-${escapeTerminalText(oldLine)}`;
|
|
23
|
+
lines.push(useColor ? chalkInstance.red(line) : line);
|
|
24
|
+
}
|
|
25
|
+
for (const newLine of splitDiffLines(occurrence.replacement)) {
|
|
26
|
+
const line = `+${escapeTerminalText(newLine)}`;
|
|
27
|
+
lines.push(useColor ? chalkInstance.green(line) : line);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (changedFiles.length === 0) {
|
|
32
|
+
lines.push(useColor ? chalkInstance.gray("No changes.") : "No changes.");
|
|
33
|
+
}
|
|
34
|
+
const summary = [
|
|
35
|
+
`${result.filesChanged} ${pluralize("file", result.filesChanged)} changed`,
|
|
36
|
+
`${result.totalReplacements} ${pluralize("replacement", result.totalReplacements)}`,
|
|
37
|
+
result.dryRun ? "(dry-run)" : null,
|
|
38
|
+
]
|
|
39
|
+
.filter((part) => part !== null)
|
|
40
|
+
.join(", ");
|
|
41
|
+
lines.push(useColor ? chalkInstance.gray(summary) : summary);
|
|
42
|
+
return lines.join("\n");
|
|
43
|
+
}
|
|
44
|
+
export function buildChalk(options) {
|
|
45
|
+
if (options.chalkInstance) {
|
|
46
|
+
return options.chalkInstance;
|
|
47
|
+
}
|
|
48
|
+
const shouldColor = options.color ?? false;
|
|
49
|
+
if (!shouldColor) {
|
|
50
|
+
return new Chalk({ level: 0 });
|
|
51
|
+
}
|
|
52
|
+
const level = chalk.level > 0 ? chalk.level : 1;
|
|
53
|
+
return new Chalk({ level });
|
|
54
|
+
}
|
|
55
|
+
export function splitDiffLines(text) {
|
|
56
|
+
const normalized = text.replaceAll("\r\n", "\n");
|
|
57
|
+
if (normalized.length === 0) {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
if (normalized.endsWith("\n")) {
|
|
61
|
+
return normalized.slice(0, -1).split("\n");
|
|
62
|
+
}
|
|
63
|
+
return normalized.split("\n");
|
|
64
|
+
}
|
|
65
|
+
export function countLines(text) {
|
|
66
|
+
return splitDiffLines(text).length;
|
|
67
|
+
}
|
|
68
|
+
export function escapeTerminalText(text) {
|
|
69
|
+
let output = "";
|
|
70
|
+
for (const char of text) {
|
|
71
|
+
const code = char.charCodeAt(0);
|
|
72
|
+
if (code === 0x1b || code < 0x20 || code === 0x7f) {
|
|
73
|
+
output += `\\x${code.toString(16).padStart(2, "0")}`;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
output += char;
|
|
77
|
+
}
|
|
78
|
+
return output;
|
|
79
|
+
}
|
|
80
|
+
function pluralize(word, count) {
|
|
81
|
+
return count === 1 ? word : `${word}s`;
|
|
82
|
+
}
|
package/dist/command.d.ts
CHANGED
|
@@ -1,29 +1,31 @@
|
|
|
1
|
-
import { type
|
|
2
|
-
import type {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
1
|
+
import { type PatchCommandFlags } from "./command/flags.ts";
|
|
2
|
+
import type { InteractiveDecider } from "./command/interactive/types.ts";
|
|
3
|
+
import type { SpatchResult } from "./types.ts";
|
|
4
|
+
type RunPatchCommandOptions = {
|
|
5
|
+
interactiveDecider?: InteractiveDecider;
|
|
6
|
+
/**
|
|
7
|
+
* Text encoding used for reading/writing scoped source files.
|
|
8
|
+
* Defaults to "utf8".
|
|
9
|
+
*/
|
|
10
|
+
encoding?: BufferEncoding;
|
|
11
|
+
/**
|
|
12
|
+
* Optional logger override. Defaults to stderr when --verbose is enabled.
|
|
13
|
+
*/
|
|
14
|
+
logger?: (line: string) => void;
|
|
15
|
+
/**
|
|
16
|
+
* Used for testing / embedding. If omitted and patch input is "-", stdin will
|
|
17
|
+
* be read from the current process.
|
|
18
|
+
*/
|
|
19
|
+
readStdin?: () => Promise<string>;
|
|
20
|
+
/**
|
|
21
|
+
* Optional stream source for stdin patch input. Ignored when `readStdin` is
|
|
22
|
+
* provided. Defaults to process stdin.
|
|
23
|
+
*/
|
|
24
|
+
stdinStream?: ReadableTextStream;
|
|
11
25
|
};
|
|
12
|
-
type
|
|
13
|
-
|
|
14
|
-
file: string;
|
|
15
|
-
occurrence: SpatchOccurrence;
|
|
16
|
-
changeNumber: number;
|
|
17
|
-
totalChanges: number;
|
|
18
|
-
};
|
|
19
|
-
export type RunPatchCommandOptions = {
|
|
20
|
-
interactiveDecider?: (ctx: InteractiveContext) => Promise<InteractiveChoice>;
|
|
26
|
+
type ReadableTextStream = AsyncIterable<unknown> & {
|
|
27
|
+
setEncoding?(encoding: BufferEncoding): void;
|
|
21
28
|
};
|
|
22
29
|
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
30
|
export declare const patchCommand: import("@stricli/core").Command<import("@stricli/core").CommandContext>;
|
|
29
31
|
export {};
|