@claudiu.ceia/astkit 0.1.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/LICENSE +21 -0
- package/README.md +393 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/src/app.d.ts +1 -0
- package/dist/src/app.js +41 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +4 -0
- package/dist/src/code-rank/code-rank.d.ts +9 -0
- package/dist/src/code-rank/code-rank.js +71 -0
- package/dist/src/code-rank/index.d.ts +1 -0
- package/dist/src/code-rank/index.js +1 -0
- package/dist/src/code-rank/rank.d.ts +29 -0
- package/dist/src/code-rank/rank.js +185 -0
- package/dist/src/common/input.d.ts +5 -0
- package/dist/src/common/input.js +25 -0
- package/dist/src/nav/declarations.d.ts +19 -0
- package/dist/src/nav/declarations.js +106 -0
- package/dist/src/nav/definition.d.ts +14 -0
- package/dist/src/nav/definition.js +62 -0
- package/dist/src/nav/location.d.ts +6 -0
- package/dist/src/nav/location.js +35 -0
- package/dist/src/nav/references.d.ts +19 -0
- package/dist/src/nav/references.js +75 -0
- package/dist/src/patch/patch.d.ts +27 -0
- package/dist/src/patch/patch.js +345 -0
- package/dist/src/pattern/balance.d.ts +1 -0
- package/dist/src/pattern/balance.js +25 -0
- package/dist/src/pattern/index.d.ts +5 -0
- package/dist/src/pattern/index.js +4 -0
- package/dist/src/pattern/match.d.ts +2 -0
- package/dist/src/pattern/match.js +141 -0
- package/dist/src/pattern/render.d.ts +1 -0
- package/dist/src/pattern/render.js +32 -0
- package/dist/src/pattern/syntax.d.ts +3 -0
- package/dist/src/pattern/syntax.js +87 -0
- package/dist/src/pattern/types.d.ts +27 -0
- package/dist/src/pattern/types.js +1 -0
- package/dist/src/search/search.d.ts +16 -0
- package/dist/src/search/search.js +207 -0
- package/dist/src/service.d.ts +16 -0
- package/dist/src/service.js +72 -0
- package/dist/src/sgrep/index.d.ts +5 -0
- package/dist/src/sgrep/index.js +3 -0
- package/dist/src/sgrep/isomorphisms/expand.d.ts +2 -0
- package/dist/src/sgrep/isomorphisms/expand.js +51 -0
- package/dist/src/sgrep/isomorphisms/index.d.ts +3 -0
- package/dist/src/sgrep/isomorphisms/index.js +2 -0
- package/dist/src/sgrep/isomorphisms/registry.d.ts +2 -0
- package/dist/src/sgrep/isomorphisms/registry.js +8 -0
- package/dist/src/sgrep/isomorphisms/rules/commutative-binary.d.ts +2 -0
- package/dist/src/sgrep/isomorphisms/rules/commutative-binary.js +51 -0
- package/dist/src/sgrep/isomorphisms/rules/object-literal-property-order.d.ts +2 -0
- package/dist/src/sgrep/isomorphisms/rules/object-literal-property-order.js +82 -0
- package/dist/src/sgrep/isomorphisms/rules/redundant-parentheses.d.ts +2 -0
- package/dist/src/sgrep/isomorphisms/rules/redundant-parentheses.js +43 -0
- package/dist/src/sgrep/isomorphisms/template-ast.d.ts +2 -0
- package/dist/src/sgrep/isomorphisms/template-ast.js +59 -0
- package/dist/src/sgrep/isomorphisms/types.d.ts +15 -0
- package/dist/src/sgrep/isomorphisms/types.js +0 -0
- package/dist/src/sgrep/phases/output.d.ts +9 -0
- package/dist/src/sgrep/phases/output.js +11 -0
- package/dist/src/sgrep/phases/parse.d.ts +10 -0
- package/dist/src/sgrep/phases/parse.js +11 -0
- package/dist/src/sgrep/phases/search.d.ts +11 -0
- package/dist/src/sgrep/phases/search.js +111 -0
- package/dist/src/sgrep/sgrep.d.ts +3 -0
- package/dist/src/sgrep/sgrep.js +17 -0
- package/dist/src/sgrep/types.d.ts +32 -0
- package/dist/src/sgrep/types.js +3 -0
- package/dist/src/spatch/files.d.ts +7 -0
- package/dist/src/spatch/files.js +51 -0
- package/dist/src/spatch/index.d.ts +3 -0
- package/dist/src/spatch/index.js +2 -0
- package/dist/src/spatch/patch-document.d.ts +9 -0
- package/dist/src/spatch/patch-document.js +64 -0
- package/dist/src/spatch/phases/output.d.ts +9 -0
- package/dist/src/spatch/phases/output.js +15 -0
- package/dist/src/spatch/phases/parse.d.ts +11 -0
- package/dist/src/spatch/phases/parse.js +16 -0
- package/dist/src/spatch/phases/rewrite.d.ts +14 -0
- package/dist/src/spatch/phases/rewrite.js +111 -0
- package/dist/src/spatch/spatch.d.ts +3 -0
- package/dist/src/spatch/spatch.js +17 -0
- package/dist/src/spatch/template.d.ts +2 -0
- package/dist/src/spatch/template.js +1 -0
- package/dist/src/spatch/text.d.ts +5 -0
- package/dist/src/spatch/text.js +35 -0
- package/dist/src/spatch/types.d.ts +40 -0
- package/dist/src/spatch/types.js +20 -0
- package/package.json +66 -0
- package/skills/astkit-tooling/SKILL.md +101 -0
- package/skills/astkit-tooling/agents/openai.yaml +4 -0
- package/skills/astkit-tooling/references/cognitive-model.md +61 -0
- package/skills/astkit-tooling/references/non-goals.md +11 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { 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/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
|
+
cwd: patchCwd,
|
|
19
|
+
dryRun: flags["dry-run"] ?? false,
|
|
20
|
+
scope: patchScope,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
export function formatPatchOutput(result, options = {}) {
|
|
24
|
+
const chalkInstance = buildChalk(options);
|
|
25
|
+
const useColor = chalkInstance.level > 0;
|
|
26
|
+
const lines = [];
|
|
27
|
+
const changedFiles = result.files.filter((file) => file.replacementCount > 0);
|
|
28
|
+
for (const file of changedFiles) {
|
|
29
|
+
const headerPrefix = useColor ? chalkInstance.bold : (value) => value;
|
|
30
|
+
lines.push(headerPrefix(`diff --git a/${file.file} b/${file.file}`));
|
|
31
|
+
lines.push(useColor ? chalkInstance.gray(`--- a/${file.file}`) : `--- a/${file.file}`);
|
|
32
|
+
lines.push(useColor ? chalkInstance.gray(`+++ b/${file.file}`) : `+++ b/${file.file}`);
|
|
33
|
+
for (const occurrence of file.occurrences) {
|
|
34
|
+
if (occurrence.matched === occurrence.replacement) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const oldCount = countLines(occurrence.matched);
|
|
38
|
+
const newCount = countLines(occurrence.replacement);
|
|
39
|
+
const hunkHeader = `@@ -${occurrence.line},${oldCount} +${occurrence.line},${newCount} @@`;
|
|
40
|
+
lines.push(useColor ? chalkInstance.cyan(hunkHeader) : hunkHeader);
|
|
41
|
+
for (const oldLine of splitDiffLines(occurrence.matched)) {
|
|
42
|
+
const line = `-${oldLine}`;
|
|
43
|
+
lines.push(useColor ? chalkInstance.red(line) : line);
|
|
44
|
+
}
|
|
45
|
+
for (const newLine of splitDiffLines(occurrence.replacement)) {
|
|
46
|
+
const line = `+${newLine}`;
|
|
47
|
+
lines.push(useColor ? chalkInstance.green(line) : line);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (changedFiles.length === 0) {
|
|
52
|
+
lines.push(useColor ? chalkInstance.gray("No changes.") : "No changes.");
|
|
53
|
+
}
|
|
54
|
+
const summary = [
|
|
55
|
+
`${result.filesChanged} ${pluralize("file", result.filesChanged)} changed`,
|
|
56
|
+
`${result.totalReplacements} ${pluralize("replacement", result.totalReplacements)}`,
|
|
57
|
+
result.dryRun ? "(dry-run)" : null,
|
|
58
|
+
]
|
|
59
|
+
.filter((part) => part !== null)
|
|
60
|
+
.join(", ");
|
|
61
|
+
lines.push(useColor ? chalkInstance.gray(summary) : summary);
|
|
62
|
+
return lines.join("\n");
|
|
63
|
+
}
|
|
64
|
+
export const patchCommand = buildCommand({
|
|
65
|
+
async func(flags, patchInput, scope) {
|
|
66
|
+
const result = await runPatchCommand(patchInput, scope, flags);
|
|
67
|
+
if (flags.json ?? false) {
|
|
68
|
+
this.process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const output = formatPatchOutput(result, {
|
|
72
|
+
color: Boolean(processStdout.isTTY) && !(flags["no-color"] ?? false),
|
|
73
|
+
});
|
|
74
|
+
this.process.stdout.write(`${output}\n`);
|
|
75
|
+
},
|
|
76
|
+
parameters: {
|
|
77
|
+
flags: {
|
|
78
|
+
interactive: {
|
|
79
|
+
kind: "boolean",
|
|
80
|
+
optional: true,
|
|
81
|
+
brief: "Interactively select which matches to apply",
|
|
82
|
+
},
|
|
83
|
+
json: {
|
|
84
|
+
kind: "boolean",
|
|
85
|
+
optional: true,
|
|
86
|
+
brief: "Output structured JSON instead of compact diff-style text",
|
|
87
|
+
},
|
|
88
|
+
"no-color": {
|
|
89
|
+
kind: "boolean",
|
|
90
|
+
optional: true,
|
|
91
|
+
brief: "Disable colored output",
|
|
92
|
+
},
|
|
93
|
+
"dry-run": {
|
|
94
|
+
kind: "boolean",
|
|
95
|
+
optional: true,
|
|
96
|
+
brief: "Preview changes without writing files",
|
|
97
|
+
},
|
|
98
|
+
cwd: {
|
|
99
|
+
kind: "parsed",
|
|
100
|
+
optional: true,
|
|
101
|
+
brief: "Working directory for resolving patch file and scope",
|
|
102
|
+
placeholder: "path",
|
|
103
|
+
parse: (input) => input,
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
positional: {
|
|
107
|
+
kind: "tuple",
|
|
108
|
+
parameters: [
|
|
109
|
+
{
|
|
110
|
+
brief: "Patch document text or path to patch document file",
|
|
111
|
+
placeholder: "patch",
|
|
112
|
+
parse: (input) => input,
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
brief: "Scope file or directory (defaults to current directory)",
|
|
116
|
+
placeholder: "scope",
|
|
117
|
+
parse: (input) => input,
|
|
118
|
+
optional: true,
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
docs: {
|
|
124
|
+
brief: "Apply structural rewrite from a patch document",
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
function buildChalk(options) {
|
|
128
|
+
if (options.chalkInstance) {
|
|
129
|
+
return options.chalkInstance;
|
|
130
|
+
}
|
|
131
|
+
const shouldColor = options.color ?? false;
|
|
132
|
+
if (!shouldColor) {
|
|
133
|
+
return new Chalk({ level: 0 });
|
|
134
|
+
}
|
|
135
|
+
const level = chalk.level > 0 ? chalk.level : 1;
|
|
136
|
+
return new Chalk({ level });
|
|
137
|
+
}
|
|
138
|
+
function splitDiffLines(text) {
|
|
139
|
+
const normalized = text.replaceAll("\r\n", "\n");
|
|
140
|
+
if (normalized.length === 0) {
|
|
141
|
+
return [""];
|
|
142
|
+
}
|
|
143
|
+
return normalized.split("\n");
|
|
144
|
+
}
|
|
145
|
+
function countLines(text) {
|
|
146
|
+
const normalized = text.replaceAll("\r\n", "\n");
|
|
147
|
+
if (normalized.length === 0) {
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
return normalized.split("\n").length;
|
|
151
|
+
}
|
|
152
|
+
function pluralize(word, count) {
|
|
153
|
+
return count === 1 ? word : `${word}s`;
|
|
154
|
+
}
|
|
155
|
+
async function runInteractivePatchCommand(patchInput, scope, cwd, noColor, interactiveDecider) {
|
|
156
|
+
if (!interactiveDecider && (!processStdin.isTTY || !processStdout.isTTY)) {
|
|
157
|
+
throw new Error("Interactive mode requires a TTY stdin/stdout.");
|
|
158
|
+
}
|
|
159
|
+
const startedAt = Date.now();
|
|
160
|
+
const dryResult = await patchProject(patchInput, {
|
|
161
|
+
cwd,
|
|
162
|
+
dryRun: true,
|
|
163
|
+
scope,
|
|
164
|
+
});
|
|
165
|
+
const totalChanges = dryResult.files.reduce((count, file) => count +
|
|
166
|
+
file.occurrences.filter((occurrence) => occurrence.matched !== occurrence.replacement).length, 0);
|
|
167
|
+
let interactivePrompt = null;
|
|
168
|
+
const decider = interactiveDecider ??
|
|
169
|
+
((interactivePrompt = await createTerminalInteractiveDecider(noColor)),
|
|
170
|
+
interactivePrompt.decider);
|
|
171
|
+
const selectedByFile = new Map();
|
|
172
|
+
let applyAll = false;
|
|
173
|
+
let stop = false;
|
|
174
|
+
let changeNumber = 0;
|
|
175
|
+
try {
|
|
176
|
+
for (const file of dryResult.files) {
|
|
177
|
+
const selected = [];
|
|
178
|
+
for (const occurrence of file.occurrences) {
|
|
179
|
+
if (occurrence.matched === occurrence.replacement) {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
changeNumber += 1;
|
|
183
|
+
if (applyAll) {
|
|
184
|
+
selected.push(occurrence);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const choice = await decider({
|
|
188
|
+
file: file.file,
|
|
189
|
+
occurrence,
|
|
190
|
+
changeNumber,
|
|
191
|
+
totalChanges,
|
|
192
|
+
});
|
|
193
|
+
if (choice === "yes") {
|
|
194
|
+
selected.push(occurrence);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (choice === "all") {
|
|
198
|
+
applyAll = true;
|
|
199
|
+
selected.push(occurrence);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (choice === "quit") {
|
|
203
|
+
stop = true;
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
selectedByFile.set(file.file, selected);
|
|
208
|
+
if (stop) {
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
interactivePrompt?.close();
|
|
215
|
+
}
|
|
216
|
+
const fileResults = [];
|
|
217
|
+
let filesChanged = 0;
|
|
218
|
+
let totalReplacements = 0;
|
|
219
|
+
for (const file of dryResult.files) {
|
|
220
|
+
const selected = selectedByFile.get(file.file) ?? [];
|
|
221
|
+
if (selected.length === 0) {
|
|
222
|
+
fileResults.push({
|
|
223
|
+
...file,
|
|
224
|
+
replacementCount: 0,
|
|
225
|
+
changed: false,
|
|
226
|
+
byteDelta: 0,
|
|
227
|
+
occurrences: [],
|
|
228
|
+
});
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const absolutePath = path.resolve(cwd ?? process.cwd(), file.file);
|
|
232
|
+
const originalText = await readFile(absolutePath, "utf8");
|
|
233
|
+
const rewrittenText = applySelectedOccurrences(originalText, selected);
|
|
234
|
+
const changed = rewrittenText !== originalText;
|
|
235
|
+
if (changed) {
|
|
236
|
+
await writeFile(absolutePath, rewrittenText, "utf8");
|
|
237
|
+
}
|
|
238
|
+
const replacementCount = selected.filter((occurrence) => occurrence.matched !== occurrence.replacement).length;
|
|
239
|
+
totalReplacements += replacementCount;
|
|
240
|
+
if (changed) {
|
|
241
|
+
filesChanged += 1;
|
|
242
|
+
}
|
|
243
|
+
fileResults.push({
|
|
244
|
+
...file,
|
|
245
|
+
replacementCount,
|
|
246
|
+
changed,
|
|
247
|
+
byteDelta: changed
|
|
248
|
+
? Buffer.byteLength(rewrittenText, "utf8") -
|
|
249
|
+
Buffer.byteLength(originalText, "utf8")
|
|
250
|
+
: 0,
|
|
251
|
+
occurrences: selected,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
...dryResult,
|
|
256
|
+
dryRun: false,
|
|
257
|
+
filesChanged,
|
|
258
|
+
totalReplacements,
|
|
259
|
+
elapsedMs: Date.now() - startedAt,
|
|
260
|
+
files: fileResults,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
function applySelectedOccurrences(source, occurrences) {
|
|
264
|
+
if (occurrences.length === 0) {
|
|
265
|
+
return source;
|
|
266
|
+
}
|
|
267
|
+
const sorted = [...occurrences].sort((left, right) => left.start - right.start);
|
|
268
|
+
const parts = [];
|
|
269
|
+
let cursor = 0;
|
|
270
|
+
for (const occurrence of sorted) {
|
|
271
|
+
parts.push(source.slice(cursor, occurrence.start));
|
|
272
|
+
parts.push(occurrence.replacement);
|
|
273
|
+
cursor = occurrence.end;
|
|
274
|
+
}
|
|
275
|
+
parts.push(source.slice(cursor));
|
|
276
|
+
return parts.join("");
|
|
277
|
+
}
|
|
278
|
+
async function createTerminalInteractiveDecider(noColor) {
|
|
279
|
+
const chalkInstance = buildChalk({
|
|
280
|
+
color: processStdout.isTTY && !noColor,
|
|
281
|
+
});
|
|
282
|
+
const useColor = chalkInstance.level > 0;
|
|
283
|
+
const rl = createInterface({
|
|
284
|
+
input: processStdin,
|
|
285
|
+
output: processStdout,
|
|
286
|
+
});
|
|
287
|
+
return {
|
|
288
|
+
decider: async ({ file, occurrence, changeNumber, totalChanges }) => {
|
|
289
|
+
processStdout.write(`\n${formatInteractiveChangeBlock({ file, occurrence, changeNumber, totalChanges }, {
|
|
290
|
+
chalkInstance,
|
|
291
|
+
color: useColor,
|
|
292
|
+
})}\n`);
|
|
293
|
+
while (true) {
|
|
294
|
+
const answer = await rl.question(useColor
|
|
295
|
+
? chalkInstance.bold("Choice [y/n/a/q] (default: n): ")
|
|
296
|
+
: "Choice [y/n/a/q] (default: n): ");
|
|
297
|
+
const parsed = parseInteractiveChoice(answer);
|
|
298
|
+
if (parsed) {
|
|
299
|
+
return parsed;
|
|
300
|
+
}
|
|
301
|
+
processStdout.write(useColor
|
|
302
|
+
? `${chalkInstance.yellow("Invalid choice.")} Use y, n, a, or q.\n`
|
|
303
|
+
: "Invalid choice. Use y, n, a, or q.\n");
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
close: () => rl.close(),
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
function formatInteractiveChangeBlock(ctx, options = {}) {
|
|
310
|
+
const chalkInstance = buildChalk(options);
|
|
311
|
+
const useColor = chalkInstance.level > 0;
|
|
312
|
+
const divider = "─".repeat(72);
|
|
313
|
+
const oldCount = countLines(ctx.occurrence.matched);
|
|
314
|
+
const newCount = countLines(ctx.occurrence.replacement);
|
|
315
|
+
const hunkHeader = `@@ -${ctx.occurrence.line},${oldCount} +${ctx.occurrence.line},${newCount} @@`;
|
|
316
|
+
const lines = [
|
|
317
|
+
useColor ? chalkInstance.gray(divider) : divider,
|
|
318
|
+
useColor
|
|
319
|
+
? chalkInstance.bold(`Change ${ctx.changeNumber}/${ctx.totalChanges} · ${ctx.file}:${ctx.occurrence.line}:${ctx.occurrence.character}`)
|
|
320
|
+
: `Change ${ctx.changeNumber}/${ctx.totalChanges} · ${ctx.file}:${ctx.occurrence.line}:${ctx.occurrence.character}`,
|
|
321
|
+
useColor ? chalkInstance.cyan(hunkHeader) : hunkHeader,
|
|
322
|
+
...splitDiffLines(ctx.occurrence.matched).map((line) => useColor ? chalkInstance.red(`-${line}`) : `-${line}`),
|
|
323
|
+
...splitDiffLines(ctx.occurrence.replacement).map((line) => useColor ? chalkInstance.green(`+${line}`) : `+${line}`),
|
|
324
|
+
useColor
|
|
325
|
+
? chalkInstance.gray("Actions: [y] apply · [n] skip · [a] apply remaining · [q] quit")
|
|
326
|
+
: "Actions: [y] apply · [n] skip · [a] apply remaining · [q] quit",
|
|
327
|
+
];
|
|
328
|
+
return lines.join("\n");
|
|
329
|
+
}
|
|
330
|
+
function parseInteractiveChoice(answer) {
|
|
331
|
+
const normalized = answer.trim().toLowerCase();
|
|
332
|
+
if (normalized.length === 0 || normalized === "n" || normalized === "no") {
|
|
333
|
+
return "no";
|
|
334
|
+
}
|
|
335
|
+
if (normalized === "y" || normalized === "yes") {
|
|
336
|
+
return "yes";
|
|
337
|
+
}
|
|
338
|
+
if (normalized === "a" || normalized === "all") {
|
|
339
|
+
return "all";
|
|
340
|
+
}
|
|
341
|
+
if (normalized === "q" || normalized === "quit") {
|
|
342
|
+
return "quit";
|
|
343
|
+
}
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function isBalancedChunk(chunk: string): boolean;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { any, anyChar, createLanguage, eof, many, manyTill, map, minus, optional, regex as parseRegex, seq, str, } from "@claudiu-ceia/combine";
|
|
2
|
+
const escapedCharacterParser = map(seq(str("\\"), anyChar()), () => null);
|
|
3
|
+
const singleQuotedParser = map(seq(str("'"), many(any(escapedCharacterParser, map(parseRegex(/[^'\\]+/, "single quoted text"), () => null))), str("'")), () => null);
|
|
4
|
+
const doubleQuotedParser = map(seq(str('"'), many(any(escapedCharacterParser, map(parseRegex(/[^"\\]+/, "double quoted text"), () => null))), str('"')), () => null);
|
|
5
|
+
const templateQuotedParser = map(seq(str("`"), many(any(escapedCharacterParser, map(parseRegex(/[^`\\]+/, "template text"), () => null))), str("`")), () => null);
|
|
6
|
+
const lineCommentParser = map(seq(str("//"), many(minus(anyChar(), str("\n"))), optional(str("\n"))), () => null);
|
|
7
|
+
const blockCommentParser = map(seq(str("/*"), manyTill(anyChar(), str("*/"))), () => null);
|
|
8
|
+
const balancedChunkLanguage = createLanguage({
|
|
9
|
+
piece: (self) => any(self.paren, self.bracket, self.brace, self.singleQuote, self.doubleQuote, self.templateQuote, self.lineComment, self.blockComment, self.plain, self.slash),
|
|
10
|
+
paren: (self) => map(seq(str("("), many(self.piece), str(")")), () => null),
|
|
11
|
+
bracket: (self) => map(seq(str("["), many(self.piece), str("]")), () => null),
|
|
12
|
+
brace: (self) => map(seq(str("{"), many(self.piece), str("}")), () => null),
|
|
13
|
+
singleQuote: () => singleQuotedParser,
|
|
14
|
+
doubleQuote: () => doubleQuotedParser,
|
|
15
|
+
templateQuote: () => templateQuotedParser,
|
|
16
|
+
lineComment: () => lineCommentParser,
|
|
17
|
+
blockComment: () => blockCommentParser,
|
|
18
|
+
plain: () => map(parseRegex(/[^()[\]{}'"`/]+/, "plain text"), () => null),
|
|
19
|
+
slash: () => map(str("/"), () => null),
|
|
20
|
+
});
|
|
21
|
+
const balancedChunkParser = map(seq(many(balancedChunkLanguage.piece), eof()), () => true);
|
|
22
|
+
export function isBalancedChunk(chunk) {
|
|
23
|
+
const result = balancedChunkParser({ text: chunk, index: 0 });
|
|
24
|
+
return result.success;
|
|
25
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { compileTemplate, tokenizeTemplate } from "./syntax.ts";
|
|
2
|
+
export { findTemplateMatches } from "./match.ts";
|
|
3
|
+
export { renderTemplate } from "./render.ts";
|
|
4
|
+
export type { CompiledTemplate, TemplateMatch } from "./types.ts";
|
|
5
|
+
export { ELLIPSIS_CAPTURE_PREFIX } from "./types.ts";
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { isBalancedChunk } from "./balance.js";
|
|
2
|
+
import { ELLIPSIS_CAPTURE_PREFIX } from "./types.js";
|
|
3
|
+
export function findTemplateMatches(text, template) {
|
|
4
|
+
const matches = [];
|
|
5
|
+
const firstToken = template.tokens[0];
|
|
6
|
+
const anchor = firstToken?.kind === "text" ? firstToken.value : null;
|
|
7
|
+
let cursor = 0;
|
|
8
|
+
while (cursor <= text.length) {
|
|
9
|
+
const start = anchor ? text.indexOf(anchor, cursor) : cursor;
|
|
10
|
+
if (start < 0) {
|
|
11
|
+
break;
|
|
12
|
+
}
|
|
13
|
+
const result = matchTokens(text, template.tokens, 0, start, new Map());
|
|
14
|
+
if (!result || result.end <= start) {
|
|
15
|
+
cursor = start + 1;
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
matches.push({
|
|
19
|
+
start,
|
|
20
|
+
end: result.end,
|
|
21
|
+
text: text.slice(start, result.end),
|
|
22
|
+
captures: Object.fromEntries(result.captures),
|
|
23
|
+
});
|
|
24
|
+
cursor = result.end;
|
|
25
|
+
}
|
|
26
|
+
return matches;
|
|
27
|
+
}
|
|
28
|
+
function matchTokens(text, tokens, tokenIndex, cursor, captures) {
|
|
29
|
+
const token = tokens[tokenIndex];
|
|
30
|
+
if (!token) {
|
|
31
|
+
return { end: cursor, captures: new Map(captures) };
|
|
32
|
+
}
|
|
33
|
+
if (token.kind === "text") {
|
|
34
|
+
if (!text.startsWith(token.value, cursor)) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
return matchTokens(text, tokens, tokenIndex + 1, cursor + token.value.length, captures);
|
|
38
|
+
}
|
|
39
|
+
if (token.kind === "ellipsis") {
|
|
40
|
+
const nextLiteral = findNextLiteral(tokens, tokenIndex + 1);
|
|
41
|
+
if (!nextLiteral) {
|
|
42
|
+
const chunk = text.slice(cursor);
|
|
43
|
+
if (!isBalancedChunk(chunk)) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
const nextCaptures = captureEllipsis(captures, token.index, chunk);
|
|
47
|
+
return matchTokens(text, tokens, tokenIndex + 1, text.length, nextCaptures);
|
|
48
|
+
}
|
|
49
|
+
const nextIndexes = findLiteralIndexes(text, nextLiteral.value, cursor);
|
|
50
|
+
for (let index = nextIndexes.length - 1; index >= 0; index -= 1) {
|
|
51
|
+
const nextIndex = nextIndexes[index];
|
|
52
|
+
if (nextIndex === undefined) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const chunk = text.slice(cursor, nextIndex);
|
|
56
|
+
if (isBalancedChunk(chunk)) {
|
|
57
|
+
const nextCaptures = captureEllipsis(captures, token.index, chunk);
|
|
58
|
+
const nested = matchTokens(text, tokens, tokenIndex + 1, nextIndex, nextCaptures);
|
|
59
|
+
if (nested) {
|
|
60
|
+
return nested;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const nextLiteral = findNextLiteral(tokens, tokenIndex + 1);
|
|
67
|
+
if (!nextLiteral) {
|
|
68
|
+
const chunk = text.slice(cursor);
|
|
69
|
+
if (!isBalancedChunk(chunk)) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
const nextCaptures = captureHole(captures, token, chunk);
|
|
73
|
+
if (!nextCaptures) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
return matchTokens(text, tokens, tokenIndex + 1, text.length, nextCaptures);
|
|
77
|
+
}
|
|
78
|
+
let probe = cursor;
|
|
79
|
+
while (probe <= text.length) {
|
|
80
|
+
const nextIndex = text.indexOf(nextLiteral.value, probe);
|
|
81
|
+
if (nextIndex < 0) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
const chunk = text.slice(cursor, nextIndex);
|
|
85
|
+
if (isBalancedChunk(chunk)) {
|
|
86
|
+
const nextCaptures = captureHole(captures, token, chunk);
|
|
87
|
+
if (nextCaptures) {
|
|
88
|
+
const nested = matchTokens(text, tokens, tokenIndex + 1, nextIndex, nextCaptures);
|
|
89
|
+
if (nested) {
|
|
90
|
+
return nested;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
probe = nextIndex + 1;
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
function findNextLiteral(tokens, fromIndex) {
|
|
99
|
+
for (let index = fromIndex; index < tokens.length; index += 1) {
|
|
100
|
+
const token = tokens[index];
|
|
101
|
+
if (token && token.kind === "text") {
|
|
102
|
+
return token;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
function captureHole(captures, hole, value) {
|
|
108
|
+
if (hole.constraintRegex && !hole.constraintRegex.test(value)) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
const next = new Map(captures);
|
|
112
|
+
if (hole.anonymous) {
|
|
113
|
+
return next;
|
|
114
|
+
}
|
|
115
|
+
const current = next.get(hole.name);
|
|
116
|
+
if (current !== undefined && current !== value) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
if (current === undefined) {
|
|
120
|
+
next.set(hole.name, value);
|
|
121
|
+
}
|
|
122
|
+
return next;
|
|
123
|
+
}
|
|
124
|
+
function captureEllipsis(captures, index, value) {
|
|
125
|
+
const next = new Map(captures);
|
|
126
|
+
next.set(`${ELLIPSIS_CAPTURE_PREFIX}${index}`, value);
|
|
127
|
+
return next;
|
|
128
|
+
}
|
|
129
|
+
function findLiteralIndexes(text, literal, fromIndex) {
|
|
130
|
+
const indexes = [];
|
|
131
|
+
let probe = fromIndex;
|
|
132
|
+
while (probe <= text.length) {
|
|
133
|
+
const matchIndex = text.indexOf(literal, probe);
|
|
134
|
+
if (matchIndex < 0) {
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
indexes.push(matchIndex);
|
|
138
|
+
probe = matchIndex + 1;
|
|
139
|
+
}
|
|
140
|
+
return indexes;
|
|
141
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function renderTemplate(source: string, captures: Record<string, string>): string;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { tokenizeTemplate } from "./syntax.js";
|
|
2
|
+
import { ELLIPSIS_CAPTURE_PREFIX } from "./types.js";
|
|
3
|
+
export function renderTemplate(source, captures) {
|
|
4
|
+
if (source.length === 0) {
|
|
5
|
+
return "";
|
|
6
|
+
}
|
|
7
|
+
const tokens = tokenizeTemplate(source);
|
|
8
|
+
let rendered = "";
|
|
9
|
+
for (const token of tokens) {
|
|
10
|
+
if (token.kind === "text") {
|
|
11
|
+
rendered += token.value;
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
if (token.kind === "ellipsis") {
|
|
15
|
+
const value = captures[`${ELLIPSIS_CAPTURE_PREFIX}${token.index}`];
|
|
16
|
+
if (value === undefined) {
|
|
17
|
+
throw new Error(`Replacement uses ellipsis #${token.index + 1} but pattern did not capture it.`);
|
|
18
|
+
}
|
|
19
|
+
rendered += value;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (token.anonymous) {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const value = captures[token.name];
|
|
26
|
+
if (value === undefined) {
|
|
27
|
+
throw new Error(`Replacement uses unknown hole "${token.name}".`);
|
|
28
|
+
}
|
|
29
|
+
rendered += value;
|
|
30
|
+
}
|
|
31
|
+
return rendered;
|
|
32
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { any, anyChar, cut, eof, formatErrorCompact, many, many1, map, mapJoin, minus, optional, regex as parseRegex, seq, str, } from "@claudiu-ceia/combine";
|
|
2
|
+
const HOLE_INNER_NAME_PATTERN = /(?:[A-Za-z_][A-Za-z0-9_]*|_)/;
|
|
3
|
+
const holeNameParser = parseRegex(HOLE_INNER_NAME_PATTERN, "hole name");
|
|
4
|
+
const escapedConstraintCharacterParser = map(seq(str("\\"), anyChar()), ([slash, character]) => `${slash}${character}`);
|
|
5
|
+
const charClassCharacterParser = any(escapedConstraintCharacterParser, parseRegex(/[^\]]/, "character class character"));
|
|
6
|
+
const charClassParser = map(seq(str("["), mapJoin(many(charClassCharacterParser)), str("]")), ([open, body, close]) => `${open}${body}${close}`);
|
|
7
|
+
const regexConstraintCharacterParser = any(escapedConstraintCharacterParser, charClassParser, parseRegex(/[^\]]/, "regex constraint character"));
|
|
8
|
+
const regexConstraintParser = mapJoin(many1(regexConstraintCharacterParser));
|
|
9
|
+
const holeTokenParser = map(seq(str(":["), cut(holeNameParser, "hole name"), optional(seq(str("~"), cut(regexConstraintParser, "regex constraint"))), cut(str("]"), "closing ']'")), ([, name, constraint]) => {
|
|
10
|
+
const constraintSource = constraint ? constraint[1] : null;
|
|
11
|
+
return {
|
|
12
|
+
kind: "hole",
|
|
13
|
+
name,
|
|
14
|
+
anonymous: name === "_",
|
|
15
|
+
constraintSource,
|
|
16
|
+
};
|
|
17
|
+
});
|
|
18
|
+
const ellipsisTokenParser = map(str("..."), () => ({
|
|
19
|
+
kind: "ellipsis",
|
|
20
|
+
}));
|
|
21
|
+
const textTokenParser = map(mapJoin(many1(minus(anyChar(), any(str("..."), str(":["))))), (value) => ({ kind: "text", value }));
|
|
22
|
+
const templateTokensParser = map(seq(many(any(holeTokenParser, ellipsisTokenParser, textTokenParser)), eof()), ([tokens]) => tokens);
|
|
23
|
+
export function tokenizeTemplate(source) {
|
|
24
|
+
const parsed = templateTokensParser({ text: source, index: 0 });
|
|
25
|
+
if (!parsed.success) {
|
|
26
|
+
throw new Error(`Invalid template: ${formatErrorCompact(parsed)}`);
|
|
27
|
+
}
|
|
28
|
+
let ellipsisIndex = 0;
|
|
29
|
+
return parsed.value.map((token) => resolveRawToken(token, () => ellipsisIndex++));
|
|
30
|
+
}
|
|
31
|
+
export function compileTemplate(source) {
|
|
32
|
+
if (source.length === 0) {
|
|
33
|
+
throw new Error("Template cannot be empty.");
|
|
34
|
+
}
|
|
35
|
+
const tokens = tokenizeTemplate(source);
|
|
36
|
+
if (tokens.length === 0) {
|
|
37
|
+
throw new Error("Template did not produce any tokens.");
|
|
38
|
+
}
|
|
39
|
+
for (let index = 0; index < tokens.length - 1; index += 1) {
|
|
40
|
+
const current = tokens[index];
|
|
41
|
+
const next = tokens[index + 1];
|
|
42
|
+
if (current &&
|
|
43
|
+
next &&
|
|
44
|
+
current.kind !== "text" &&
|
|
45
|
+
next.kind !== "text") {
|
|
46
|
+
throw new Error("Adjacent holes are ambiguous. Add a literal delimiter between them.");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const literalLength = tokens.reduce((total, token) => total + (token.kind === "text" ? token.value.length : 0), 0);
|
|
50
|
+
if (literalLength === 0) {
|
|
51
|
+
throw new Error("Template must include at least one literal character to avoid empty matches.");
|
|
52
|
+
}
|
|
53
|
+
return { source, tokens };
|
|
54
|
+
}
|
|
55
|
+
function resolveRawToken(token, nextEllipsisIndex) {
|
|
56
|
+
if (token.kind === "text") {
|
|
57
|
+
return token;
|
|
58
|
+
}
|
|
59
|
+
if (token.kind === "ellipsis") {
|
|
60
|
+
return {
|
|
61
|
+
kind: "ellipsis",
|
|
62
|
+
index: nextEllipsisIndex(),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (token.constraintSource === null) {
|
|
66
|
+
return {
|
|
67
|
+
kind: "hole",
|
|
68
|
+
name: token.name,
|
|
69
|
+
anonymous: token.anonymous,
|
|
70
|
+
constraintSource: token.constraintSource,
|
|
71
|
+
constraintRegex: null,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
return {
|
|
76
|
+
kind: "hole",
|
|
77
|
+
name: token.name,
|
|
78
|
+
anonymous: token.anonymous,
|
|
79
|
+
constraintSource: token.constraintSource,
|
|
80
|
+
constraintRegex: new RegExp(`^(?:${token.constraintSource})$`, "s"),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
85
|
+
throw new Error(`Invalid regex constraint for hole "${token.name}": ${message}`);
|
|
86
|
+
}
|
|
87
|
+
}
|