@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,59 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import { tokenizeTemplate } from "../../pattern/index.js";
|
|
3
|
+
export function parseTemplateIsomorphismContext(source) {
|
|
4
|
+
let tokens;
|
|
5
|
+
try {
|
|
6
|
+
tokens = tokenizeTemplate(source);
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
if (tokens.some((token) => token.kind === "ellipsis")) {
|
|
12
|
+
// The template ellipsis token ("...") is a matcher wildcard, not JS spread syntax.
|
|
13
|
+
// Skip AST-based isomorphisms for now to avoid ambiguous rewrites.
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
const canonicalSource = tokens.map(renderTemplateToken).join("");
|
|
17
|
+
if (canonicalSource !== source) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const sanitizedSource = tokens
|
|
21
|
+
.map((token, index) => {
|
|
22
|
+
if (token.kind === "text") {
|
|
23
|
+
return token.value;
|
|
24
|
+
}
|
|
25
|
+
const rawToken = renderTemplateToken(token);
|
|
26
|
+
return buildIdentifierPlaceholder(rawToken.length, index);
|
|
27
|
+
})
|
|
28
|
+
.join("");
|
|
29
|
+
if (sanitizedSource.length !== source.length) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const ast = ts.createSourceFile("sgrep-pattern.ts", sanitizedSource, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
33
|
+
return { source, ast };
|
|
34
|
+
}
|
|
35
|
+
function renderTemplateToken(token) {
|
|
36
|
+
if (token.kind === "text") {
|
|
37
|
+
return token.value;
|
|
38
|
+
}
|
|
39
|
+
if (token.kind === "ellipsis") {
|
|
40
|
+
return "...";
|
|
41
|
+
}
|
|
42
|
+
if (token.constraintSource === null) {
|
|
43
|
+
return `:[${token.name}]`;
|
|
44
|
+
}
|
|
45
|
+
return `:[${token.name}~${token.constraintSource}]`;
|
|
46
|
+
}
|
|
47
|
+
function buildIdentifierPlaceholder(length, index) {
|
|
48
|
+
if (length <= 0) {
|
|
49
|
+
return "";
|
|
50
|
+
}
|
|
51
|
+
if (length === 1) {
|
|
52
|
+
return "_";
|
|
53
|
+
}
|
|
54
|
+
const base = `_${index.toString(36)}_`;
|
|
55
|
+
if (base.length >= length) {
|
|
56
|
+
return `_`.repeat(length);
|
|
57
|
+
}
|
|
58
|
+
return `${base}${"_".repeat(length - base.length)}`;
|
|
59
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type ts from "typescript";
|
|
2
|
+
export type IsomorphismContext = {
|
|
3
|
+
source: string;
|
|
4
|
+
ast: ts.SourceFile;
|
|
5
|
+
};
|
|
6
|
+
export type IsomorphismRule = {
|
|
7
|
+
id: string;
|
|
8
|
+
description: string;
|
|
9
|
+
apply: (context: IsomorphismContext) => string[];
|
|
10
|
+
};
|
|
11
|
+
export type ExpandIsomorphismsOptions = {
|
|
12
|
+
enabled?: boolean;
|
|
13
|
+
maxVariants?: number;
|
|
14
|
+
rules?: readonly IsomorphismRule[];
|
|
15
|
+
};
|
|
File without changes
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { SgrepResult } from "../types.ts";
|
|
2
|
+
import type { ParsedSearchSpec } from "./parse.ts";
|
|
3
|
+
import type { SearchPhaseResult } from "./search.ts";
|
|
4
|
+
export type OutputPhaseInput = {
|
|
5
|
+
search: ParsedSearchSpec;
|
|
6
|
+
phase: SearchPhaseResult;
|
|
7
|
+
elapsedMs: number;
|
|
8
|
+
};
|
|
9
|
+
export declare function buildSgrepResult(input: OutputPhaseInput): SgrepResult;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function buildSgrepResult(input) {
|
|
2
|
+
return {
|
|
3
|
+
scope: input.phase.scope,
|
|
4
|
+
pattern: input.search.pattern,
|
|
5
|
+
filesScanned: input.phase.filesScanned,
|
|
6
|
+
filesMatched: input.phase.filesMatched,
|
|
7
|
+
totalMatches: input.phase.totalMatches,
|
|
8
|
+
elapsedMs: input.elapsedMs,
|
|
9
|
+
files: input.phase.files,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { SgrepOptions } from "../types.ts";
|
|
2
|
+
export type ParsedSearchSpec = {
|
|
3
|
+
pattern: string;
|
|
4
|
+
};
|
|
5
|
+
export type ParsedSearchInvocation = {
|
|
6
|
+
search: ParsedSearchSpec;
|
|
7
|
+
options: SgrepOptions;
|
|
8
|
+
};
|
|
9
|
+
export declare function parseSearchSpec(pattern: string): ParsedSearchSpec;
|
|
10
|
+
export declare function parseSearchInvocation(patternInput: string, options?: SgrepOptions): Promise<ParsedSearchInvocation>;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { resolveTextInput } from "../../common/input.js";
|
|
2
|
+
export function parseSearchSpec(pattern) {
|
|
3
|
+
return { pattern };
|
|
4
|
+
}
|
|
5
|
+
export async function parseSearchInvocation(patternInput, options = {}) {
|
|
6
|
+
const pattern = await resolveTextInput(patternInput, options);
|
|
7
|
+
return {
|
|
8
|
+
search: parseSearchSpec(pattern),
|
|
9
|
+
options,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { SgrepFileResult, SgrepOptions } from "../types.ts";
|
|
2
|
+
import type { ParsedSearchSpec } from "./parse.ts";
|
|
3
|
+
export type SearchPhaseResult = {
|
|
4
|
+
cwd: string;
|
|
5
|
+
scope: string;
|
|
6
|
+
filesScanned: number;
|
|
7
|
+
filesMatched: number;
|
|
8
|
+
totalMatches: number;
|
|
9
|
+
files: SgrepFileResult[];
|
|
10
|
+
};
|
|
11
|
+
export declare function searchProjectFiles(search: ParsedSearchSpec, options: SgrepOptions): Promise<SearchPhaseResult>;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { compileTemplate, ELLIPSIS_CAPTURE_PREFIX, findTemplateMatches, } from "../../pattern/index.js";
|
|
4
|
+
import { collectPatchableFiles } from "../../spatch/files.js";
|
|
5
|
+
import { createLineStarts, toLineCharacter } from "../../spatch/text.js";
|
|
6
|
+
import { expandPatternIsomorphisms } from "../isomorphisms/index.js";
|
|
7
|
+
export async function searchProjectFiles(search, options) {
|
|
8
|
+
const cwd = path.resolve(options.cwd ?? process.cwd());
|
|
9
|
+
const scope = options.scope ?? ".";
|
|
10
|
+
const encoding = options.encoding ?? "utf8";
|
|
11
|
+
const resolvedScope = path.resolve(cwd, scope);
|
|
12
|
+
const compiledPatterns = compileSearchPatterns(search.pattern, options.isomorphisms ?? true);
|
|
13
|
+
const files = await collectPatchableFiles({
|
|
14
|
+
cwd,
|
|
15
|
+
scope,
|
|
16
|
+
extensions: options.extensions,
|
|
17
|
+
excludedDirectories: options.excludedDirectories,
|
|
18
|
+
});
|
|
19
|
+
let filesMatched = 0;
|
|
20
|
+
let totalMatches = 0;
|
|
21
|
+
const fileResults = [];
|
|
22
|
+
for (const filePath of files) {
|
|
23
|
+
const fileResult = await searchFile({
|
|
24
|
+
cwd,
|
|
25
|
+
filePath,
|
|
26
|
+
compiledPatterns,
|
|
27
|
+
encoding,
|
|
28
|
+
});
|
|
29
|
+
if (!fileResult) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
filesMatched += 1;
|
|
33
|
+
totalMatches += fileResult.matchCount;
|
|
34
|
+
fileResults.push(fileResult);
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
cwd,
|
|
38
|
+
scope: resolvedScope,
|
|
39
|
+
filesScanned: files.length,
|
|
40
|
+
filesMatched,
|
|
41
|
+
totalMatches,
|
|
42
|
+
files: fileResults,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
async function searchFile(input) {
|
|
46
|
+
const sourceText = await readFile(input.filePath, input.encoding);
|
|
47
|
+
const matches = findFileMatches(sourceText, input.compiledPatterns);
|
|
48
|
+
if (matches.length === 0) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const lineStarts = createLineStarts(sourceText);
|
|
52
|
+
const searchMatches = matches.map((match) => {
|
|
53
|
+
const { line, character } = toLineCharacter(lineStarts, match.start);
|
|
54
|
+
return {
|
|
55
|
+
start: match.start,
|
|
56
|
+
end: match.end,
|
|
57
|
+
line,
|
|
58
|
+
character,
|
|
59
|
+
matched: match.text,
|
|
60
|
+
captures: filterPublicCaptures(match.captures),
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
return {
|
|
64
|
+
file: path.relative(input.cwd, input.filePath) || path.basename(input.filePath),
|
|
65
|
+
matchCount: matches.length,
|
|
66
|
+
matches: searchMatches,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function filterPublicCaptures(captures) {
|
|
70
|
+
const entries = Object.entries(captures).filter(([name]) => !name.startsWith(ELLIPSIS_CAPTURE_PREFIX));
|
|
71
|
+
return Object.fromEntries(entries);
|
|
72
|
+
}
|
|
73
|
+
function compileSearchPatterns(pattern, withIsomorphisms) {
|
|
74
|
+
const variants = expandPatternIsomorphisms(pattern, {
|
|
75
|
+
enabled: withIsomorphisms,
|
|
76
|
+
});
|
|
77
|
+
const compiledPatterns = [];
|
|
78
|
+
const seen = new Set();
|
|
79
|
+
for (let index = 0; index < variants.length; index += 1) {
|
|
80
|
+
const variant = variants[index];
|
|
81
|
+
if (!variant || seen.has(variant)) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
seen.add(variant);
|
|
85
|
+
try {
|
|
86
|
+
compiledPatterns.push(compileTemplate(variant));
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (variant === pattern || index === 0) {
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (compiledPatterns.length === 0) {
|
|
95
|
+
throw new Error("Unable to compile search pattern.");
|
|
96
|
+
}
|
|
97
|
+
return compiledPatterns;
|
|
98
|
+
}
|
|
99
|
+
function findFileMatches(sourceText, compiledPatterns) {
|
|
100
|
+
const uniqueBySpan = new Map();
|
|
101
|
+
for (const compiledPattern of compiledPatterns) {
|
|
102
|
+
const matches = findTemplateMatches(sourceText, compiledPattern);
|
|
103
|
+
for (const match of matches) {
|
|
104
|
+
const key = `${match.start}:${match.end}`;
|
|
105
|
+
if (!uniqueBySpan.has(key)) {
|
|
106
|
+
uniqueBySpan.set(key, match);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return [...uniqueBySpan.values()].sort((left, right) => left.start - right.start || left.end - right.end);
|
|
111
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { buildSgrepResult } from "./phases/output.js";
|
|
2
|
+
import { parseSearchInvocation, } from "./phases/parse.js";
|
|
3
|
+
import { searchProjectFiles } from "./phases/search.js";
|
|
4
|
+
export async function searchProject(patternInput, options = {}) {
|
|
5
|
+
const invocation = await parseSearchInvocation(patternInput, options);
|
|
6
|
+
return runSearchPhases(invocation.search, invocation.options);
|
|
7
|
+
}
|
|
8
|
+
export const sgrep = searchProject;
|
|
9
|
+
async function runSearchPhases(search, options) {
|
|
10
|
+
const startedAt = Date.now();
|
|
11
|
+
const phase = await searchProjectFiles(search, options);
|
|
12
|
+
return buildSgrepResult({
|
|
13
|
+
search,
|
|
14
|
+
phase,
|
|
15
|
+
elapsedMs: Date.now() - startedAt,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export declare const DEFAULT_SEARCHABLE_EXTENSIONS: readonly [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
|
|
2
|
+
export declare const DEFAULT_SEARCH_EXCLUDED_DIRECTORIES: readonly [".git", "node_modules", ".next", ".turbo", "dist", "build", "coverage", "out"];
|
|
3
|
+
export type SgrepOptions = {
|
|
4
|
+
scope?: string;
|
|
5
|
+
cwd?: string;
|
|
6
|
+
extensions?: readonly string[];
|
|
7
|
+
excludedDirectories?: readonly string[];
|
|
8
|
+
encoding?: BufferEncoding;
|
|
9
|
+
isomorphisms?: boolean;
|
|
10
|
+
};
|
|
11
|
+
export type SgrepMatch = {
|
|
12
|
+
start: number;
|
|
13
|
+
end: number;
|
|
14
|
+
line: number;
|
|
15
|
+
character: number;
|
|
16
|
+
matched: string;
|
|
17
|
+
captures: Record<string, string>;
|
|
18
|
+
};
|
|
19
|
+
export type SgrepFileResult = {
|
|
20
|
+
file: string;
|
|
21
|
+
matchCount: number;
|
|
22
|
+
matches: SgrepMatch[];
|
|
23
|
+
};
|
|
24
|
+
export type SgrepResult = {
|
|
25
|
+
scope: string;
|
|
26
|
+
pattern: string;
|
|
27
|
+
filesScanned: number;
|
|
28
|
+
filesMatched: number;
|
|
29
|
+
totalMatches: number;
|
|
30
|
+
elapsedMs: number;
|
|
31
|
+
files: SgrepFileResult[];
|
|
32
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { opendir, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { DEFAULT_EXCLUDED_DIRECTORIES, DEFAULT_PATCHABLE_EXTENSIONS, } from "./types.js";
|
|
4
|
+
export async function collectPatchableFiles(options) {
|
|
5
|
+
const scopePath = path.resolve(options.cwd, options.scope);
|
|
6
|
+
const scopeStats = await stat(scopePath);
|
|
7
|
+
const extensionSet = new Set((options.extensions ?? DEFAULT_PATCHABLE_EXTENSIONS).map(normalizeExtension));
|
|
8
|
+
const excludedDirectorySet = new Set(options.excludedDirectories ?? DEFAULT_EXCLUDED_DIRECTORIES);
|
|
9
|
+
if (scopeStats.isFile()) {
|
|
10
|
+
return extensionSet.has(path.extname(scopePath).toLowerCase())
|
|
11
|
+
? [scopePath]
|
|
12
|
+
: [];
|
|
13
|
+
}
|
|
14
|
+
if (!scopeStats.isDirectory()) {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
const files = [];
|
|
18
|
+
await walkDirectory(scopePath, extensionSet, excludedDirectorySet, files);
|
|
19
|
+
return files;
|
|
20
|
+
}
|
|
21
|
+
async function walkDirectory(directory, extensions, excludedDirectories, files) {
|
|
22
|
+
const directoryHandle = await opendir(directory);
|
|
23
|
+
const entries = [];
|
|
24
|
+
for await (const entry of directoryHandle) {
|
|
25
|
+
entries.push(entry);
|
|
26
|
+
}
|
|
27
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
28
|
+
for (const entry of entries) {
|
|
29
|
+
const absolute = path.join(directory, entry.name);
|
|
30
|
+
if (entry.isDirectory()) {
|
|
31
|
+
if (excludedDirectories.has(entry.name)) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
await walkDirectory(absolute, extensions, excludedDirectories, files);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (!entry.isFile()) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (extensions.has(path.extname(entry.name).toLowerCase())) {
|
|
41
|
+
files.push(absolute);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function normalizeExtension(extension) {
|
|
46
|
+
const normalized = extension.trim().toLowerCase();
|
|
47
|
+
if (normalized.startsWith(".")) {
|
|
48
|
+
return normalized;
|
|
49
|
+
}
|
|
50
|
+
return `.${normalized}`;
|
|
51
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { any, eof, formatErrorCompact, many, map, optional, regex as parseRegex, seq, str, } from "@claudiu-ceia/combine";
|
|
2
|
+
const lineContentParser = parseRegex(/[^\n]*/, "line content");
|
|
3
|
+
const escapedMarkerLineParser = map(seq(str("\\"), any(str("+"), str("-")), lineContentParser), ([, marker, content]) => ({
|
|
4
|
+
kind: "context",
|
|
5
|
+
value: `${marker}${content}`,
|
|
6
|
+
}));
|
|
7
|
+
const additionLineParser = map(seq(str("+"), lineContentParser), ([, content]) => ({ kind: "addition", value: content }));
|
|
8
|
+
const deletionLineParser = map(seq(str("-"), lineContentParser), ([, content]) => ({ kind: "deletion", value: content }));
|
|
9
|
+
const contextLineParser = map(lineContentParser, (content) => ({ kind: "context", value: content }));
|
|
10
|
+
const patchLineParser = any(escapedMarkerLineParser, additionLineParser, deletionLineParser, contextLineParser);
|
|
11
|
+
const patchDocumentParser = map(seq(patchLineParser, many(map(seq(str("\n"), patchLineParser), ([, line]) => line)), optional(str("\n")), eof()), ([firstLine, remainingLines, trailingNewline]) => ({
|
|
12
|
+
lines: [firstLine, ...remainingLines],
|
|
13
|
+
trailingNewline: trailingNewline !== null,
|
|
14
|
+
}));
|
|
15
|
+
export function parsePatchDocument(source) {
|
|
16
|
+
if (source.length === 0) {
|
|
17
|
+
throw new Error("Patch document cannot be empty.");
|
|
18
|
+
}
|
|
19
|
+
const normalized = source.replaceAll("\r\n", "\n");
|
|
20
|
+
const trailingNewline = normalized.endsWith("\n");
|
|
21
|
+
const parsed = patchDocumentParser({ text: normalized, index: 0 });
|
|
22
|
+
if (!parsed.success) {
|
|
23
|
+
throw new Error(`Invalid patch document: ${formatErrorCompact(parsed)}`);
|
|
24
|
+
}
|
|
25
|
+
const lines = trailingNewline &&
|
|
26
|
+
parsed.value.lines.length > 0 &&
|
|
27
|
+
parsed.value.lines[parsed.value.lines.length - 1]?.kind === "context" &&
|
|
28
|
+
parsed.value.lines[parsed.value.lines.length - 1]?.value === ""
|
|
29
|
+
? parsed.value.lines.slice(0, -1)
|
|
30
|
+
: parsed.value.lines;
|
|
31
|
+
const patternLines = [];
|
|
32
|
+
const replacementLines = [];
|
|
33
|
+
let additions = 0;
|
|
34
|
+
let deletions = 0;
|
|
35
|
+
let contextLines = 0;
|
|
36
|
+
for (const line of lines) {
|
|
37
|
+
if (line.kind === "context") {
|
|
38
|
+
patternLines.push(line.value);
|
|
39
|
+
replacementLines.push(line.value);
|
|
40
|
+
contextLines += 1;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (line.kind === "addition") {
|
|
44
|
+
replacementLines.push(line.value);
|
|
45
|
+
additions += 1;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
patternLines.push(line.value);
|
|
49
|
+
deletions += 1;
|
|
50
|
+
}
|
|
51
|
+
if (additions === 0 && deletions === 0) {
|
|
52
|
+
throw new Error("Patch document must contain at least one '+' or '-' line.");
|
|
53
|
+
}
|
|
54
|
+
const pattern = patternLines.join("\n");
|
|
55
|
+
const replacement = replacementLines.join("\n");
|
|
56
|
+
return {
|
|
57
|
+
pattern: trailingNewline ? `${pattern}\n` : pattern,
|
|
58
|
+
replacement: trailingNewline ? `${replacement}\n` : replacement,
|
|
59
|
+
additions,
|
|
60
|
+
deletions,
|
|
61
|
+
contextLines,
|
|
62
|
+
trailingNewline,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { SpatchResult } from "../types.ts";
|
|
2
|
+
import type { ParsedPatchSpec } from "./parse.ts";
|
|
3
|
+
import type { RewritePhaseResult } from "./rewrite.ts";
|
|
4
|
+
export type OutputPhaseInput = {
|
|
5
|
+
patch: ParsedPatchSpec;
|
|
6
|
+
rewrite: RewritePhaseResult;
|
|
7
|
+
elapsedMs: number;
|
|
8
|
+
};
|
|
9
|
+
export declare function buildSpatchResult(input: OutputPhaseInput): SpatchResult;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function buildSpatchResult(input) {
|
|
2
|
+
return {
|
|
3
|
+
dryRun: input.rewrite.dryRun,
|
|
4
|
+
scope: input.rewrite.scope,
|
|
5
|
+
pattern: input.patch.pattern,
|
|
6
|
+
replacement: input.patch.replacement,
|
|
7
|
+
filesScanned: input.rewrite.filesScanned,
|
|
8
|
+
filesMatched: input.rewrite.filesMatched,
|
|
9
|
+
filesChanged: input.rewrite.filesChanged,
|
|
10
|
+
totalMatches: input.rewrite.totalMatches,
|
|
11
|
+
totalReplacements: input.rewrite.totalReplacements,
|
|
12
|
+
elapsedMs: input.elapsedMs,
|
|
13
|
+
files: input.rewrite.files,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { SpatchOptions } from "../types.ts";
|
|
2
|
+
export type ParsedPatchSpec = {
|
|
3
|
+
pattern: string;
|
|
4
|
+
replacement: string;
|
|
5
|
+
};
|
|
6
|
+
export type ParsedPatchInvocation = {
|
|
7
|
+
patch: ParsedPatchSpec;
|
|
8
|
+
options: SpatchOptions;
|
|
9
|
+
};
|
|
10
|
+
export declare function parsePatchSpec(patchDocument: string): ParsedPatchSpec;
|
|
11
|
+
export declare function parsePatchInvocation(patchInput: string, options?: SpatchOptions): Promise<ParsedPatchInvocation>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { resolveTextInput } from "../../common/input.js";
|
|
2
|
+
import { parsePatchDocument } from "../patch-document.js";
|
|
3
|
+
export function parsePatchSpec(patchDocument) {
|
|
4
|
+
const parsed = parsePatchDocument(patchDocument);
|
|
5
|
+
return {
|
|
6
|
+
pattern: parsed.pattern,
|
|
7
|
+
replacement: parsed.replacement,
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
export async function parsePatchInvocation(patchInput, options = {}) {
|
|
11
|
+
const patchDocument = await resolveTextInput(patchInput, options);
|
|
12
|
+
return {
|
|
13
|
+
patch: parsePatchSpec(patchDocument),
|
|
14
|
+
options,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { SpatchFileResult, SpatchOptions } from "../types.ts";
|
|
2
|
+
import type { ParsedPatchSpec } from "./parse.ts";
|
|
3
|
+
export type RewritePhaseResult = {
|
|
4
|
+
cwd: string;
|
|
5
|
+
scope: string;
|
|
6
|
+
dryRun: boolean;
|
|
7
|
+
filesScanned: number;
|
|
8
|
+
filesMatched: number;
|
|
9
|
+
filesChanged: number;
|
|
10
|
+
totalMatches: number;
|
|
11
|
+
totalReplacements: number;
|
|
12
|
+
files: SpatchFileResult[];
|
|
13
|
+
};
|
|
14
|
+
export declare function rewriteProject(patch: ParsedPatchSpec, options: SpatchOptions): Promise<RewritePhaseResult>;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { collectPatchableFiles } from "../files.js";
|
|
4
|
+
import { compileTemplate, ELLIPSIS_CAPTURE_PREFIX, findTemplateMatches, renderTemplate, } from "../../pattern/index.js";
|
|
5
|
+
import { createLineStarts, toLineCharacter } from "../text.js";
|
|
6
|
+
export async function rewriteProject(patch, options) {
|
|
7
|
+
const cwd = path.resolve(options.cwd ?? process.cwd());
|
|
8
|
+
const scope = options.scope ?? ".";
|
|
9
|
+
const dryRun = options.dryRun ?? false;
|
|
10
|
+
const encoding = options.encoding ?? "utf8";
|
|
11
|
+
const resolvedScope = path.resolve(cwd, scope);
|
|
12
|
+
const compiledPattern = compileTemplate(patch.pattern);
|
|
13
|
+
const files = await collectPatchableFiles({
|
|
14
|
+
cwd,
|
|
15
|
+
scope,
|
|
16
|
+
extensions: options.extensions,
|
|
17
|
+
excludedDirectories: options.excludedDirectories,
|
|
18
|
+
});
|
|
19
|
+
let filesMatched = 0;
|
|
20
|
+
let filesChanged = 0;
|
|
21
|
+
let totalMatches = 0;
|
|
22
|
+
let totalReplacements = 0;
|
|
23
|
+
const fileResults = [];
|
|
24
|
+
for (const filePath of files) {
|
|
25
|
+
const fileResult = await rewriteFile({
|
|
26
|
+
cwd,
|
|
27
|
+
filePath,
|
|
28
|
+
replacementTemplate: patch.replacement,
|
|
29
|
+
compiledPattern,
|
|
30
|
+
encoding,
|
|
31
|
+
dryRun,
|
|
32
|
+
});
|
|
33
|
+
if (!fileResult) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
filesMatched += 1;
|
|
37
|
+
totalMatches += fileResult.matchCount;
|
|
38
|
+
totalReplacements += fileResult.replacementCount;
|
|
39
|
+
if (fileResult.changed) {
|
|
40
|
+
filesChanged += 1;
|
|
41
|
+
}
|
|
42
|
+
fileResults.push(fileResult);
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
cwd,
|
|
46
|
+
scope: resolvedScope,
|
|
47
|
+
dryRun,
|
|
48
|
+
filesScanned: files.length,
|
|
49
|
+
filesMatched,
|
|
50
|
+
filesChanged,
|
|
51
|
+
totalMatches,
|
|
52
|
+
totalReplacements,
|
|
53
|
+
files: fileResults,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
async function rewriteFile(input) {
|
|
57
|
+
const originalText = await readFile(input.filePath, input.encoding);
|
|
58
|
+
const matches = findTemplateMatches(originalText, input.compiledPattern);
|
|
59
|
+
if (matches.length === 0) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
const lineStarts = createLineStarts(originalText);
|
|
63
|
+
const occurrences = matches.map((match) => {
|
|
64
|
+
const rendered = renderTemplate(input.replacementTemplate, match.captures);
|
|
65
|
+
const { line, character } = toLineCharacter(lineStarts, match.start);
|
|
66
|
+
return {
|
|
67
|
+
start: match.start,
|
|
68
|
+
end: match.end,
|
|
69
|
+
line,
|
|
70
|
+
character,
|
|
71
|
+
matched: match.text,
|
|
72
|
+
replacement: rendered,
|
|
73
|
+
captures: filterPublicCaptures(match.captures),
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
const replacementCount = occurrences.reduce((count, occurrence) => count + (occurrence.matched === occurrence.replacement ? 0 : 1), 0);
|
|
77
|
+
const rewrittenText = applyOccurrences(originalText, occurrences);
|
|
78
|
+
const changed = rewrittenText !== originalText;
|
|
79
|
+
if (changed && !input.dryRun) {
|
|
80
|
+
await writeFile(input.filePath, rewrittenText, input.encoding);
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
file: path.relative(input.cwd, input.filePath) || path.basename(input.filePath),
|
|
84
|
+
matchCount: matches.length,
|
|
85
|
+
replacementCount,
|
|
86
|
+
changed,
|
|
87
|
+
byteDelta: changed
|
|
88
|
+
? Buffer.byteLength(rewrittenText, input.encoding) -
|
|
89
|
+
Buffer.byteLength(originalText, input.encoding)
|
|
90
|
+
: 0,
|
|
91
|
+
occurrences,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function filterPublicCaptures(captures) {
|
|
95
|
+
const entries = Object.entries(captures).filter(([name]) => !name.startsWith(ELLIPSIS_CAPTURE_PREFIX));
|
|
96
|
+
return Object.fromEntries(entries);
|
|
97
|
+
}
|
|
98
|
+
function applyOccurrences(source, occurrences) {
|
|
99
|
+
if (occurrences.length === 0) {
|
|
100
|
+
return source;
|
|
101
|
+
}
|
|
102
|
+
const parts = [];
|
|
103
|
+
let cursor = 0;
|
|
104
|
+
for (const occurrence of occurrences) {
|
|
105
|
+
parts.push(source.slice(cursor, occurrence.start));
|
|
106
|
+
parts.push(occurrence.replacement);
|
|
107
|
+
cursor = occurrence.end;
|
|
108
|
+
}
|
|
109
|
+
parts.push(source.slice(cursor));
|
|
110
|
+
return parts.join("");
|
|
111
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { buildSpatchResult } from "./phases/output.js";
|
|
2
|
+
import { parsePatchInvocation, } from "./phases/parse.js";
|
|
3
|
+
import { rewriteProject } from "./phases/rewrite.js";
|
|
4
|
+
export async function patchProject(patchInput, options = {}) {
|
|
5
|
+
const invocation = await parsePatchInvocation(patchInput, options);
|
|
6
|
+
return runPatchPhases(invocation.patch, invocation.options);
|
|
7
|
+
}
|
|
8
|
+
export const spatch = patchProject;
|
|
9
|
+
async function runPatchPhases(patch, options) {
|
|
10
|
+
const startedAt = Date.now();
|
|
11
|
+
const rewrite = await rewriteProject(patch, options);
|
|
12
|
+
return buildSpatchResult({
|
|
13
|
+
patch,
|
|
14
|
+
rewrite,
|
|
15
|
+
elapsedMs: Date.now() - startedAt,
|
|
16
|
+
});
|
|
17
|
+
}
|