@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/src/index.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export {
|
|
2
|
+
patchProject,
|
|
3
|
+
spatch,
|
|
4
|
+
} from "./spatch.ts";
|
|
5
|
+
export type {
|
|
6
|
+
SpatchFileResult,
|
|
7
|
+
SpatchOccurrence,
|
|
8
|
+
SpatchOptions,
|
|
9
|
+
SpatchResult,
|
|
10
|
+
} from "./types.ts";
|
|
11
|
+
export {
|
|
12
|
+
DEFAULT_EXCLUDED_DIRECTORIES,
|
|
13
|
+
DEFAULT_PATCHABLE_EXTENSIONS,
|
|
14
|
+
} from "./types.ts";
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
formatPatchOutput,
|
|
18
|
+
patchCommand,
|
|
19
|
+
runPatchCommand,
|
|
20
|
+
} from "./command.ts";
|
|
21
|
+
export type { PatchCommandFlags } from "./command.ts";
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import {
|
|
2
|
+
any,
|
|
3
|
+
eof,
|
|
4
|
+
formatErrorCompact,
|
|
5
|
+
many,
|
|
6
|
+
map,
|
|
7
|
+
optional,
|
|
8
|
+
regex as parseRegex,
|
|
9
|
+
seq,
|
|
10
|
+
str,
|
|
11
|
+
} from "@claudiu-ceia/combine";
|
|
12
|
+
|
|
13
|
+
export type ParsedPatchDocument = {
|
|
14
|
+
pattern: string;
|
|
15
|
+
replacement: string;
|
|
16
|
+
additions: number;
|
|
17
|
+
deletions: number;
|
|
18
|
+
contextLines: number;
|
|
19
|
+
trailingNewline: boolean;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type ParsedPatchLine =
|
|
23
|
+
| { kind: "context"; value: string }
|
|
24
|
+
| { kind: "addition"; value: string }
|
|
25
|
+
| { kind: "deletion"; value: string };
|
|
26
|
+
|
|
27
|
+
const lineContentParser = parseRegex(/[^\n]*/, "line content");
|
|
28
|
+
|
|
29
|
+
const escapedMarkerLineParser = map(
|
|
30
|
+
seq(str("\\"), any(str("+"), str("-")), lineContentParser),
|
|
31
|
+
([, marker, content]) => ({
|
|
32
|
+
kind: "context",
|
|
33
|
+
value: `${marker}${content}`,
|
|
34
|
+
} satisfies ParsedPatchLine),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const additionLineParser = map(
|
|
38
|
+
seq(str("+"), lineContentParser),
|
|
39
|
+
([, content]) => ({ kind: "addition", value: content } satisfies ParsedPatchLine),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const deletionLineParser = map(
|
|
43
|
+
seq(str("-"), lineContentParser),
|
|
44
|
+
([, content]) => ({ kind: "deletion", value: content } satisfies ParsedPatchLine),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const contextLineParser = map(
|
|
48
|
+
lineContentParser,
|
|
49
|
+
(content) => ({ kind: "context", value: content } satisfies ParsedPatchLine),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const patchLineParser = any(
|
|
53
|
+
escapedMarkerLineParser,
|
|
54
|
+
additionLineParser,
|
|
55
|
+
deletionLineParser,
|
|
56
|
+
contextLineParser,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const patchDocumentParser = map(
|
|
60
|
+
seq(
|
|
61
|
+
patchLineParser,
|
|
62
|
+
many(map(seq(str("\n"), patchLineParser), ([, line]) => line)),
|
|
63
|
+
optional(str("\n")),
|
|
64
|
+
eof(),
|
|
65
|
+
),
|
|
66
|
+
([firstLine, remainingLines, trailingNewline]) => ({
|
|
67
|
+
lines: [firstLine, ...remainingLines],
|
|
68
|
+
trailingNewline: trailingNewline !== null,
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
export function parsePatchDocument(source: string): ParsedPatchDocument {
|
|
73
|
+
if (source.length === 0) {
|
|
74
|
+
throw new Error("Patch document cannot be empty.");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const normalized = source.replaceAll("\r\n", "\n");
|
|
78
|
+
const trailingNewline = normalized.endsWith("\n");
|
|
79
|
+
const parsed = patchDocumentParser({ text: normalized, index: 0 });
|
|
80
|
+
if (!parsed.success) {
|
|
81
|
+
throw new Error(`Invalid patch document: ${formatErrorCompact(parsed)}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const lines =
|
|
85
|
+
trailingNewline &&
|
|
86
|
+
parsed.value.lines.length > 0 &&
|
|
87
|
+
parsed.value.lines[parsed.value.lines.length - 1]?.kind === "context" &&
|
|
88
|
+
parsed.value.lines[parsed.value.lines.length - 1]?.value === ""
|
|
89
|
+
? parsed.value.lines.slice(0, -1)
|
|
90
|
+
: parsed.value.lines;
|
|
91
|
+
|
|
92
|
+
const patternLines: string[] = [];
|
|
93
|
+
const replacementLines: string[] = [];
|
|
94
|
+
let additions = 0;
|
|
95
|
+
let deletions = 0;
|
|
96
|
+
let contextLines = 0;
|
|
97
|
+
|
|
98
|
+
for (const line of lines) {
|
|
99
|
+
if (line.kind === "context") {
|
|
100
|
+
patternLines.push(line.value);
|
|
101
|
+
replacementLines.push(line.value);
|
|
102
|
+
contextLines += 1;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (line.kind === "addition") {
|
|
107
|
+
replacementLines.push(line.value);
|
|
108
|
+
additions += 1;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
patternLines.push(line.value);
|
|
113
|
+
deletions += 1;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (additions === 0 && deletions === 0) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
"Patch document must contain at least one '+' or '-' line.",
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const pattern = patternLines.join("\n");
|
|
123
|
+
const replacement = replacementLines.join("\n");
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
pattern: trailingNewline ? `${pattern}\n` : pattern,
|
|
127
|
+
replacement: trailingNewline ? `${replacement}\n` : replacement,
|
|
128
|
+
additions,
|
|
129
|
+
deletions,
|
|
130
|
+
contextLines,
|
|
131
|
+
trailingNewline,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { SpatchResult } from "../types.ts";
|
|
2
|
+
import type { ParsedPatchSpec } from "./parse.ts";
|
|
3
|
+
import type { RewritePhaseResult } from "./rewrite.ts";
|
|
4
|
+
|
|
5
|
+
export type OutputPhaseInput = {
|
|
6
|
+
patch: ParsedPatchSpec;
|
|
7
|
+
rewrite: RewritePhaseResult;
|
|
8
|
+
elapsedMs: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function buildSpatchResult(input: OutputPhaseInput): SpatchResult {
|
|
12
|
+
return {
|
|
13
|
+
dryRun: input.rewrite.dryRun,
|
|
14
|
+
scope: input.rewrite.scope,
|
|
15
|
+
pattern: input.patch.pattern,
|
|
16
|
+
replacement: input.patch.replacement,
|
|
17
|
+
filesScanned: input.rewrite.filesScanned,
|
|
18
|
+
filesMatched: input.rewrite.filesMatched,
|
|
19
|
+
filesChanged: input.rewrite.filesChanged,
|
|
20
|
+
totalMatches: input.rewrite.totalMatches,
|
|
21
|
+
totalReplacements: input.rewrite.totalReplacements,
|
|
22
|
+
elapsedMs: input.elapsedMs,
|
|
23
|
+
files: input.rewrite.files,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { resolveTextInput } from "@claudiu-ceia/astkit-core";
|
|
2
|
+
import { parsePatchDocument } from "../patch-document.ts";
|
|
3
|
+
import type { SpatchOptions } from "../types.ts";
|
|
4
|
+
|
|
5
|
+
export type ParsedPatchSpec = {
|
|
6
|
+
pattern: string;
|
|
7
|
+
replacement: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type ParsedPatchInvocation = {
|
|
11
|
+
patch: ParsedPatchSpec;
|
|
12
|
+
options: SpatchOptions;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function parsePatchSpec(patchDocument: string): ParsedPatchSpec {
|
|
16
|
+
const parsed = parsePatchDocument(patchDocument);
|
|
17
|
+
return {
|
|
18
|
+
pattern: parsed.pattern,
|
|
19
|
+
replacement: parsed.replacement,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function parsePatchInvocation(
|
|
24
|
+
patchInput: string,
|
|
25
|
+
options: SpatchOptions = {},
|
|
26
|
+
): Promise<ParsedPatchInvocation> {
|
|
27
|
+
const patchDocument = await resolveTextInput(patchInput, options);
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
patch: parsePatchSpec(patchDocument),
|
|
31
|
+
options,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
compileTemplate,
|
|
5
|
+
ELLIPSIS_CAPTURE_PREFIX,
|
|
6
|
+
findTemplateMatches,
|
|
7
|
+
renderTemplate,
|
|
8
|
+
collectPatchableFiles,
|
|
9
|
+
createLineStarts,
|
|
10
|
+
formatMs,
|
|
11
|
+
mapLimit,
|
|
12
|
+
nowNs,
|
|
13
|
+
nsToMs,
|
|
14
|
+
toLineCharacter,
|
|
15
|
+
} from "@claudiu-ceia/astkit-core";
|
|
16
|
+
import type { SpatchFileResult, SpatchOptions } from "../types.ts";
|
|
17
|
+
import type { ParsedPatchSpec } from "./parse.ts";
|
|
18
|
+
|
|
19
|
+
export type RewritePhaseResult = {
|
|
20
|
+
cwd: string;
|
|
21
|
+
scope: string;
|
|
22
|
+
dryRun: boolean;
|
|
23
|
+
filesScanned: number;
|
|
24
|
+
filesMatched: number;
|
|
25
|
+
filesChanged: number;
|
|
26
|
+
totalMatches: number;
|
|
27
|
+
totalReplacements: number;
|
|
28
|
+
files: SpatchFileResult[];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type RewritePerfStats = {
|
|
32
|
+
filesRead: number;
|
|
33
|
+
readNs: bigint;
|
|
34
|
+
matchNs: bigint;
|
|
35
|
+
renderNs: bigint;
|
|
36
|
+
applyNs: bigint;
|
|
37
|
+
writeNs: bigint;
|
|
38
|
+
matchedFiles: number;
|
|
39
|
+
totalMatches: number;
|
|
40
|
+
totalReplacements: number;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export async function rewriteProject(
|
|
44
|
+
patch: ParsedPatchSpec,
|
|
45
|
+
options: SpatchOptions,
|
|
46
|
+
): Promise<RewritePhaseResult> {
|
|
47
|
+
const verbose = options.verbose ?? 0;
|
|
48
|
+
const log = options.logger ?? (() => {});
|
|
49
|
+
const cwd = path.resolve(options.cwd ?? process.cwd());
|
|
50
|
+
const scope = options.scope ?? ".";
|
|
51
|
+
const dryRun = options.dryRun ?? false;
|
|
52
|
+
const encoding = options.encoding ?? "utf8";
|
|
53
|
+
const concurrency = options.concurrency ?? 8;
|
|
54
|
+
const resolvedScope = path.resolve(cwd, scope);
|
|
55
|
+
const compileStarted = verbose > 0 ? nowNs() : 0n;
|
|
56
|
+
const compiledPattern = compileTemplate(patch.pattern);
|
|
57
|
+
if (verbose > 0) {
|
|
58
|
+
log(
|
|
59
|
+
`[spatch] compilePattern ${formatMs(nsToMs(nowNs() - compileStarted))}`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const collectStarted = verbose > 0 ? nowNs() : 0n;
|
|
64
|
+
const files = await collectPatchableFiles({
|
|
65
|
+
cwd,
|
|
66
|
+
scope,
|
|
67
|
+
extensions: options.extensions,
|
|
68
|
+
excludedDirectories: options.excludedDirectories,
|
|
69
|
+
});
|
|
70
|
+
if (verbose > 0) {
|
|
71
|
+
log(
|
|
72
|
+
`[spatch] collectFiles ${formatMs(nsToMs(nowNs() - collectStarted))} files=${files.length}`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const slowFiles: Array<{ file: string; ms: number; matches: number; replacements: number }> =
|
|
77
|
+
[];
|
|
78
|
+
const stats: RewritePerfStats = {
|
|
79
|
+
filesRead: 0,
|
|
80
|
+
readNs: 0n,
|
|
81
|
+
matchNs: 0n,
|
|
82
|
+
renderNs: 0n,
|
|
83
|
+
applyNs: 0n,
|
|
84
|
+
writeNs: 0n,
|
|
85
|
+
matchedFiles: 0,
|
|
86
|
+
totalMatches: 0,
|
|
87
|
+
totalReplacements: 0,
|
|
88
|
+
};
|
|
89
|
+
const rewriteStarted = verbose > 0 ? nowNs() : 0n;
|
|
90
|
+
const results = await mapLimit(
|
|
91
|
+
files,
|
|
92
|
+
async (filePath) => {
|
|
93
|
+
const perFileStarted = verbose >= 2 ? nowNs() : 0n;
|
|
94
|
+
const fileResult = await rewriteFile({
|
|
95
|
+
cwd,
|
|
96
|
+
filePath,
|
|
97
|
+
replacementTemplate: patch.replacement,
|
|
98
|
+
compiledPattern,
|
|
99
|
+
encoding,
|
|
100
|
+
dryRun,
|
|
101
|
+
stats: verbose > 0 ? stats : undefined,
|
|
102
|
+
});
|
|
103
|
+
if (verbose >= 2 && fileResult) {
|
|
104
|
+
slowFiles.push({
|
|
105
|
+
file: fileResult.file,
|
|
106
|
+
ms: nsToMs(nowNs() - perFileStarted),
|
|
107
|
+
matches: fileResult.matchCount,
|
|
108
|
+
replacements: fileResult.replacementCount,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return fileResult;
|
|
112
|
+
},
|
|
113
|
+
{ concurrency },
|
|
114
|
+
);
|
|
115
|
+
if (verbose > 0) {
|
|
116
|
+
log(
|
|
117
|
+
`[spatch] rewriteFiles ${formatMs(nsToMs(nowNs() - rewriteStarted))} concurrency=${concurrency} dryRun=${dryRun}`,
|
|
118
|
+
);
|
|
119
|
+
log(
|
|
120
|
+
`[spatch] breakdown read=${formatMs(nsToMs(stats.readNs))} match=${formatMs(nsToMs(stats.matchNs))} render=${formatMs(nsToMs(stats.renderNs))} apply=${formatMs(nsToMs(stats.applyNs))} write=${formatMs(nsToMs(stats.writeNs))}`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let filesMatched = 0;
|
|
125
|
+
let filesChanged = 0;
|
|
126
|
+
let totalMatches = 0;
|
|
127
|
+
let totalReplacements = 0;
|
|
128
|
+
const fileResults: SpatchFileResult[] = [];
|
|
129
|
+
for (const fileResult of results) {
|
|
130
|
+
if (!fileResult) {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
filesMatched += 1;
|
|
135
|
+
totalMatches += fileResult.matchCount;
|
|
136
|
+
totalReplacements += fileResult.replacementCount;
|
|
137
|
+
if (fileResult.changed) {
|
|
138
|
+
filesChanged += 1;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
fileResults.push(fileResult);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (verbose >= 2 && slowFiles.length > 0) {
|
|
145
|
+
slowFiles.sort((a, b) => b.ms - a.ms);
|
|
146
|
+
for (const entry of slowFiles.slice(0, 10)) {
|
|
147
|
+
log(
|
|
148
|
+
`[spatch] slowFile ${formatMs(entry.ms)} file=${entry.file} matches=${entry.matches} replacements=${entry.replacements}`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (verbose > 0) {
|
|
154
|
+
log(
|
|
155
|
+
`[spatch] summary filesScanned=${files.length} filesMatched=${filesMatched} filesChanged=${filesChanged} totalMatches=${totalMatches} totalReplacements=${totalReplacements}`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
cwd,
|
|
161
|
+
scope: resolvedScope,
|
|
162
|
+
dryRun,
|
|
163
|
+
filesScanned: files.length,
|
|
164
|
+
filesMatched,
|
|
165
|
+
filesChanged,
|
|
166
|
+
totalMatches,
|
|
167
|
+
totalReplacements,
|
|
168
|
+
files: fileResults,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
type RewriteFileInput = {
|
|
173
|
+
cwd: string;
|
|
174
|
+
filePath: string;
|
|
175
|
+
replacementTemplate: string;
|
|
176
|
+
compiledPattern: ReturnType<typeof compileTemplate>;
|
|
177
|
+
encoding: BufferEncoding;
|
|
178
|
+
dryRun: boolean;
|
|
179
|
+
stats?: RewritePerfStats;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
async function rewriteFile(
|
|
183
|
+
input: RewriteFileInput,
|
|
184
|
+
): Promise<SpatchFileResult | null> {
|
|
185
|
+
const readStarted = input.stats ? nowNs() : 0n;
|
|
186
|
+
const originalText = await readFile(input.filePath, input.encoding);
|
|
187
|
+
if (input.stats) {
|
|
188
|
+
input.stats.filesRead += 1;
|
|
189
|
+
input.stats.readNs += nowNs() - readStarted;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const matchStarted = input.stats ? nowNs() : 0n;
|
|
193
|
+
const matches = findTemplateMatches(originalText, input.compiledPattern);
|
|
194
|
+
if (input.stats) {
|
|
195
|
+
input.stats.matchNs += nowNs() - matchStarted;
|
|
196
|
+
}
|
|
197
|
+
if (matches.length === 0) {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const lineStarts = createLineStarts(originalText);
|
|
202
|
+
const renderStarted = input.stats ? nowNs() : 0n;
|
|
203
|
+
const occurrences = matches.map((match) => {
|
|
204
|
+
const rendered = renderTemplate(input.replacementTemplate, match.captures);
|
|
205
|
+
const { line, character } = toLineCharacter(lineStarts, match.start);
|
|
206
|
+
return {
|
|
207
|
+
start: match.start,
|
|
208
|
+
end: match.end,
|
|
209
|
+
line,
|
|
210
|
+
character,
|
|
211
|
+
matched: match.text,
|
|
212
|
+
replacement: rendered,
|
|
213
|
+
captures: filterPublicCaptures(match.captures),
|
|
214
|
+
};
|
|
215
|
+
});
|
|
216
|
+
if (input.stats) {
|
|
217
|
+
input.stats.renderNs += nowNs() - renderStarted;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const replacementCount = occurrences.reduce(
|
|
221
|
+
(count, occurrence) =>
|
|
222
|
+
count + (occurrence.matched === occurrence.replacement ? 0 : 1),
|
|
223
|
+
0,
|
|
224
|
+
);
|
|
225
|
+
const applyStarted = input.stats ? nowNs() : 0n;
|
|
226
|
+
const rewrittenText = applyOccurrences(originalText, occurrences);
|
|
227
|
+
if (input.stats) {
|
|
228
|
+
input.stats.applyNs += nowNs() - applyStarted;
|
|
229
|
+
}
|
|
230
|
+
const changed = rewrittenText !== originalText;
|
|
231
|
+
|
|
232
|
+
if (changed && !input.dryRun) {
|
|
233
|
+
const writeStarted = input.stats ? nowNs() : 0n;
|
|
234
|
+
await writeFile(input.filePath, rewrittenText, input.encoding);
|
|
235
|
+
if (input.stats) {
|
|
236
|
+
input.stats.writeNs += nowNs() - writeStarted;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (input.stats) {
|
|
241
|
+
input.stats.matchedFiles += 1;
|
|
242
|
+
input.stats.totalMatches += matches.length;
|
|
243
|
+
input.stats.totalReplacements += replacementCount;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
file: path.relative(input.cwd, input.filePath) || path.basename(input.filePath),
|
|
248
|
+
matchCount: matches.length,
|
|
249
|
+
replacementCount,
|
|
250
|
+
changed,
|
|
251
|
+
byteDelta: changed
|
|
252
|
+
? Buffer.byteLength(rewrittenText, input.encoding) -
|
|
253
|
+
Buffer.byteLength(originalText, input.encoding)
|
|
254
|
+
: 0,
|
|
255
|
+
occurrences,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function filterPublicCaptures(
|
|
260
|
+
captures: Record<string, string>,
|
|
261
|
+
): Record<string, string> {
|
|
262
|
+
const entries = Object.entries(captures).filter(
|
|
263
|
+
([name]) => !name.startsWith(ELLIPSIS_CAPTURE_PREFIX),
|
|
264
|
+
);
|
|
265
|
+
return Object.fromEntries(entries);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function applyOccurrences(
|
|
269
|
+
source: string,
|
|
270
|
+
occurrences: ReadonlyArray<{ start: number; end: number; replacement: string }>,
|
|
271
|
+
): string {
|
|
272
|
+
if (occurrences.length === 0) {
|
|
273
|
+
return source;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const parts: string[] = [];
|
|
277
|
+
let cursor = 0;
|
|
278
|
+
|
|
279
|
+
for (const occurrence of occurrences) {
|
|
280
|
+
parts.push(source.slice(cursor, occurrence.start));
|
|
281
|
+
parts.push(occurrence.replacement);
|
|
282
|
+
cursor = occurrence.end;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
parts.push(source.slice(cursor));
|
|
286
|
+
return parts.join("");
|
|
287
|
+
}
|
package/src/spatch.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { buildSpatchResult } from "./phases/output.ts";
|
|
2
|
+
import {
|
|
3
|
+
parsePatchInvocation,
|
|
4
|
+
type ParsedPatchSpec,
|
|
5
|
+
} from "./phases/parse.ts";
|
|
6
|
+
import { rewriteProject } from "./phases/rewrite.ts";
|
|
7
|
+
import type { SpatchOptions, SpatchResult } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
export async function patchProject(
|
|
10
|
+
patchInput: string,
|
|
11
|
+
options: SpatchOptions = {},
|
|
12
|
+
): Promise<SpatchResult> {
|
|
13
|
+
const invocation = await parsePatchInvocation(patchInput, options);
|
|
14
|
+
|
|
15
|
+
return runPatchPhases(invocation.patch, invocation.options);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const spatch = patchProject;
|
|
19
|
+
|
|
20
|
+
async function runPatchPhases(
|
|
21
|
+
patch: ParsedPatchSpec,
|
|
22
|
+
options: SpatchOptions,
|
|
23
|
+
): Promise<SpatchResult> {
|
|
24
|
+
const startedAt = Date.now();
|
|
25
|
+
|
|
26
|
+
const rewrite = await rewriteProject(patch, options);
|
|
27
|
+
return buildSpatchResult({
|
|
28
|
+
patch,
|
|
29
|
+
rewrite,
|
|
30
|
+
elapsedMs: Date.now() - startedAt,
|
|
31
|
+
});
|
|
32
|
+
}
|
package/src/template.ts
ADDED
package/src/types.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export {
|
|
2
|
+
DEFAULT_EXCLUDED_DIRECTORIES,
|
|
3
|
+
DEFAULT_SOURCE_EXTENSIONS as DEFAULT_PATCHABLE_EXTENSIONS,
|
|
4
|
+
} from "@claudiu-ceia/astkit-core";
|
|
5
|
+
|
|
6
|
+
export type SpatchOptions = {
|
|
7
|
+
scope?: string;
|
|
8
|
+
cwd?: string;
|
|
9
|
+
dryRun?: boolean;
|
|
10
|
+
extensions?: readonly string[];
|
|
11
|
+
excludedDirectories?: readonly string[];
|
|
12
|
+
encoding?: BufferEncoding;
|
|
13
|
+
concurrency?: number;
|
|
14
|
+
verbose?: number;
|
|
15
|
+
logger?: (line: string) => void;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type SpatchOccurrence = {
|
|
19
|
+
start: number;
|
|
20
|
+
end: number;
|
|
21
|
+
line: number;
|
|
22
|
+
character: number;
|
|
23
|
+
matched: string;
|
|
24
|
+
replacement: string;
|
|
25
|
+
captures: Record<string, string>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type SpatchFileResult = {
|
|
29
|
+
file: string;
|
|
30
|
+
matchCount: number;
|
|
31
|
+
replacementCount: number;
|
|
32
|
+
changed: boolean;
|
|
33
|
+
byteDelta: number;
|
|
34
|
+
occurrences: SpatchOccurrence[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type SpatchResult = {
|
|
38
|
+
dryRun: boolean;
|
|
39
|
+
scope: string;
|
|
40
|
+
pattern: string;
|
|
41
|
+
replacement: string;
|
|
42
|
+
filesScanned: number;
|
|
43
|
+
filesMatched: number;
|
|
44
|
+
filesChanged: number;
|
|
45
|
+
totalMatches: number;
|
|
46
|
+
totalReplacements: number;
|
|
47
|
+
elapsedMs: number;
|
|
48
|
+
files: SpatchFileResult[];
|
|
49
|
+
};
|