pi-shorthand 0.7.1 → 0.8.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 +1 -1
- package/linux-observer.c +11 -2
- package/move-analysis.ts +191 -0
- package/move-imports.ts +106 -136
- package/package.json +3 -3
- package/placement.ts +35 -12
- package/prelude.ts +22 -55
- package/program-lint.ts +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|

|
|
4
4
|
|
|
5
|
-
A [Pi](https://github.com/earendil-works/pi) tool for editing repositories with Bun programs.
|
|
5
|
+
A [Pi](https://github.com/earendil-works/pi) tool for editing repositories with Bun programs, with first-class support for TypeScript and JavaScript.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
package/linux-observer.c
CHANGED
|
@@ -451,6 +451,7 @@ static void syscall_entry(pid_t pid, struct ptrace_syscall_info *info) {
|
|
|
451
451
|
uint64_t flags = a[0];
|
|
452
452
|
if (nr == SYS_clone3) {
|
|
453
453
|
errno = 0; flags = ptrace(PTRACE_PEEKDATA, pid, (void *)(uintptr_t)a[0], 0);
|
|
454
|
+
if (errno == ESRCH) return;
|
|
454
455
|
if (errno) die("clone flags unavailable");
|
|
455
456
|
}
|
|
456
457
|
if (flags & CLONE_UNTRACED) die("CLONE_UNTRACED is unsupported");
|
|
@@ -473,6 +474,7 @@ static void syscall_entry(pid_t pid, struct ptrace_syscall_info *info) {
|
|
|
473
474
|
if (a[3] < 24) die("unsupported openat2 argument size");
|
|
474
475
|
errno = 0;
|
|
475
476
|
unsigned long resolve = ptrace(PTRACE_PEEKDATA, pid, (void *)(uintptr_t)(a[2] + 16), 0);
|
|
477
|
+
if (errno == ESRCH) return;
|
|
476
478
|
if (errno || resolve) die("openat2 resolve modes are unsupported");
|
|
477
479
|
path_access(pid, a[0], a[1], 1, OBSERVE_CONTENTS); break;
|
|
478
480
|
}
|
|
@@ -661,18 +663,25 @@ int main(int argc, char **argv) {
|
|
|
661
663
|
}
|
|
662
664
|
if (!WIFSTOPPED(status)) die("unexpected process state");
|
|
663
665
|
int event = (unsigned)status >> 16, signal = WSTOPSIG(status);
|
|
666
|
+
/* A stopped thread that SIGKILL or a sibling's exec retires reports ESRCH: its syscall never runs, and
|
|
667
|
+
* wait reports its death next, so there is nothing to observe and nothing to resume. */
|
|
664
668
|
if (event == PTRACE_EVENT_SECCOMP) {
|
|
665
669
|
struct ptrace_syscall_info info;
|
|
666
|
-
if (ptrace(PTRACE_GET_SYSCALL_INFO, pid, sizeof(info), &info) < 0
|
|
670
|
+
if (ptrace(PTRACE_GET_SYSCALL_INFO, pid, sizeof(info), &info) < 0) {
|
|
671
|
+
if (errno == ESRCH) continue;
|
|
667
672
|
die("syscall information unavailable");
|
|
673
|
+
}
|
|
674
|
+
if (info.op != PTRACE_SYSCALL_INFO_SECCOMP) die("syscall information unavailable");
|
|
668
675
|
unresolved_external_permission = 0;
|
|
669
676
|
syscall_entry(pid, &info);
|
|
670
677
|
if (unresolved_external_permission) task(pid)->checking_permission = 1;
|
|
671
678
|
signal = 0;
|
|
672
679
|
} else if (signal == (SIGTRAP|0x80) && task(pid)->checking_permission) {
|
|
673
680
|
struct ptrace_syscall_info info;
|
|
674
|
-
if (ptrace(PTRACE_GET_SYSCALL_INFO, pid, sizeof(info), &info) < 0)
|
|
681
|
+
if (ptrace(PTRACE_GET_SYSCALL_INFO, pid, sizeof(info), &info) < 0) {
|
|
682
|
+
if (errno == ESRCH) continue;
|
|
675
683
|
die("unresolved external path result unavailable");
|
|
684
|
+
}
|
|
676
685
|
if (info.op == PTRACE_SYSCALL_INFO_EXIT) {
|
|
677
686
|
if (info.exit.rval != -EACCES && info.exit.rval != -EPERM)
|
|
678
687
|
die("unresolved external path was accessed");
|
package/move-analysis.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What moving a top-level declaration affects, as TypeScript 7's checker sees it: the names the declaration
|
|
3
|
+
* depends on, whether the rest of its file still uses it, and which files refer to it. move-imports.ts turns
|
|
4
|
+
* this into edits. The checker comes from TypeScript's unstable API, so the typescript version is pinned.
|
|
5
|
+
*/
|
|
6
|
+
import { realpathSync } from "node:fs";
|
|
7
|
+
import type { SgNode } from "@ast-grep/napi";
|
|
8
|
+
import { API, SymbolFlags, type Project, type Symbol } from "typescript/unstable/async";
|
|
9
|
+
import { getTouchingPropertyName, SyntaxKind, type Node, type SourceFile } from "typescript/unstable/ast";
|
|
10
|
+
|
|
11
|
+
export interface MoveAnalysis {
|
|
12
|
+
/** Names the declaration refers to that are bound outside it: imports, the file's other declarations and globals. */
|
|
13
|
+
dependencies: Set<string>;
|
|
14
|
+
/** Local names of the source file's imports that nothing but the declaration uses, so they leave with it. */
|
|
15
|
+
importsOnlyItUses: Set<string>;
|
|
16
|
+
/** Code left in the source file still refers to the declaration. */
|
|
17
|
+
usedInSource: boolean;
|
|
18
|
+
/** Other files that refer to the declaration, by importing or re-exporting it. */
|
|
19
|
+
referencing: string[];
|
|
20
|
+
/** Files that use the declaration as a property of the module object: `ns.name`, `ns["name"]`, `{ name } = ns`. */
|
|
21
|
+
usedThroughModule: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface MoveAnalysisInput {
|
|
25
|
+
root: string;
|
|
26
|
+
file: string;
|
|
27
|
+
/** The top-level statement being moved. */
|
|
28
|
+
node: SgNode;
|
|
29
|
+
/** The names it declares. */
|
|
30
|
+
names: string[];
|
|
31
|
+
/** Every JS/TS file in the repository, so that each lands in a TypeScript project. */
|
|
32
|
+
files: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const REFERENCES = ["identifier", "type_identifier", "shorthand_property_identifier"];
|
|
36
|
+
/** Where a declaration names what it declares, including `{ name }` in a destructuring declaration. */
|
|
37
|
+
const DECLARED = ["identifier", "type_identifier", "shorthand_property_identifier_pattern"];
|
|
38
|
+
|
|
39
|
+
const real = (file: string) => {
|
|
40
|
+
try {
|
|
41
|
+
return realpathSync(file);
|
|
42
|
+
} catch {
|
|
43
|
+
return file;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export async function analyzeMove({ root, file, node, names, files }: MoveAnalysisInput): Promise<MoveAnalysis> {
|
|
48
|
+
const api = new API({ cwd: root });
|
|
49
|
+
try {
|
|
50
|
+
const snapshot = await api.updateSnapshot({ openFiles: [...new Set([file, ...files])] });
|
|
51
|
+
const home = await snapshot.getDefaultProjectForFile(file);
|
|
52
|
+
const sourceFile = await home?.program.getSourceFile(file);
|
|
53
|
+
if (!home || !sourceFile) throw new Error(`refactor.move: TypeScript has no project containing ${file}`);
|
|
54
|
+
// A file in another project, such as another workspace package, imports the source through its own program.
|
|
55
|
+
const projects: Project[] = [];
|
|
56
|
+
for (const project of snapshot.getProjects())
|
|
57
|
+
if (await project.program.getSourceFile(file).catch(() => undefined)) projects.push(project);
|
|
58
|
+
return {
|
|
59
|
+
...(await dependencies(home, sourceFile, node)),
|
|
60
|
+
...(await references(projects, sourceFile, node, names)),
|
|
61
|
+
};
|
|
62
|
+
} finally {
|
|
63
|
+
await api.close();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Names in `node` whose symbol is declared outside it, or that resolve to nothing; and of those, the source
|
|
69
|
+
* file's imports that nothing outside `node` uses.
|
|
70
|
+
*/
|
|
71
|
+
async function dependencies(
|
|
72
|
+
project: Project,
|
|
73
|
+
sourceFile: SourceFile,
|
|
74
|
+
node: SgNode,
|
|
75
|
+
): Promise<Pick<MoveAnalysis, "dependencies" | "importsOnlyItUses">> {
|
|
76
|
+
const { start, end } = node.range();
|
|
77
|
+
const inside = (reference: Node) => reference.getStart(sourceFile) >= start.index && reference.end <= end.index;
|
|
78
|
+
const onlyUsedInside = async (symbol: Symbol) => {
|
|
79
|
+
for (const handle of await project.checker.getReferencesToSymbolInFile(sourceFile.fileName, symbol)) {
|
|
80
|
+
const reference = await handle.resolve(project);
|
|
81
|
+
if (reference && !inside(reference) && !withinImport(reference)) return false;
|
|
82
|
+
}
|
|
83
|
+
return true;
|
|
84
|
+
};
|
|
85
|
+
const within = async (symbol: Symbol) => {
|
|
86
|
+
if (!symbol.declarations.length) return false;
|
|
87
|
+
for (const declaration of symbol.declarations) {
|
|
88
|
+
if (declaration.path !== sourceFile.path) return false;
|
|
89
|
+
const resolved = await declaration.resolve(project);
|
|
90
|
+
if (!resolved || resolved.getStart(sourceFile) < start.index || resolved.end > end.index) return false;
|
|
91
|
+
}
|
|
92
|
+
return true;
|
|
93
|
+
};
|
|
94
|
+
const identifiers = node.findAll({ rule: { any: REFERENCES.map((kind) => ({ kind })) } });
|
|
95
|
+
const symbols = await project.checker.getSymbolAtPosition(
|
|
96
|
+
sourceFile.fileName,
|
|
97
|
+
identifiers.map((identifier) => identifier.range().start.index),
|
|
98
|
+
);
|
|
99
|
+
const names = new Set<string>();
|
|
100
|
+
const importsOnlyItUses = new Set<string>();
|
|
101
|
+
for (const [index, identifier] of identifiers.entries()) {
|
|
102
|
+
const name = identifier.text();
|
|
103
|
+
if (names.has(name)) continue;
|
|
104
|
+
// `{ name }` declares a property; the value it reads is whatever `name` means there.
|
|
105
|
+
const symbol =
|
|
106
|
+
identifier.kind() === "shorthand_property_identifier"
|
|
107
|
+
? await project.checker.resolveName(
|
|
108
|
+
name,
|
|
109
|
+
SymbolFlags.Value,
|
|
110
|
+
getTouchingPropertyName(sourceFile, identifier.range().start.index),
|
|
111
|
+
)
|
|
112
|
+
: symbols[index];
|
|
113
|
+
if (symbol && (await within(symbol))) continue;
|
|
114
|
+
names.add(name);
|
|
115
|
+
const imported =
|
|
116
|
+
symbol &&
|
|
117
|
+
symbol.flags & SymbolFlags.Alias &&
|
|
118
|
+
symbol.declarations.every((declaration) => declaration.path === sourceFile.path);
|
|
119
|
+
if (imported && (await onlyUsedInside(symbol))) importsOnlyItUses.add(name);
|
|
120
|
+
}
|
|
121
|
+
return { dependencies: names, importsOnlyItUses };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A node inside an import declaration, such as the binding an import introduces. */
|
|
125
|
+
function withinImport(node: Node): boolean {
|
|
126
|
+
for (let current: Node | undefined = node; current; current = current.parent)
|
|
127
|
+
if (current.kind === SyntaxKind.ImportDeclaration || current.kind === SyntaxKind.ImportEqualsDeclaration)
|
|
128
|
+
return true;
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function references(
|
|
133
|
+
projects: Project[],
|
|
134
|
+
sourceFile: SourceFile,
|
|
135
|
+
node: SgNode,
|
|
136
|
+
names: string[],
|
|
137
|
+
): Promise<Pick<MoveAnalysis, "usedInSource" | "referencing" | "usedThroughModule">> {
|
|
138
|
+
const { start, end } = node.range();
|
|
139
|
+
const source = real(sourceFile.fileName);
|
|
140
|
+
const seen = new Set<string>();
|
|
141
|
+
const referencing = new Set<string>();
|
|
142
|
+
const usedThroughModule = new Set<string>();
|
|
143
|
+
let usedInSource = false;
|
|
144
|
+
for (const project of projects) {
|
|
145
|
+
const own = (await project.program.getSourceFile(sourceFile.fileName))!;
|
|
146
|
+
for (const name of names) {
|
|
147
|
+
// The first mention of a declared name in its declaration is where it is declared.
|
|
148
|
+
const declared = node.find({
|
|
149
|
+
rule: { any: DECLARED.map((kind) => ({ kind, regex: `^${name.replaceAll("$", "\\$")}$` })) },
|
|
150
|
+
});
|
|
151
|
+
if (!declared) continue;
|
|
152
|
+
const position = declared.range().start.index;
|
|
153
|
+
const entries = await project.checker.getReferencedSymbolsForNode(
|
|
154
|
+
getTouchingPropertyName(own, position),
|
|
155
|
+
position,
|
|
156
|
+
);
|
|
157
|
+
for (const handle of entries.flatMap((entry) => entry.references)) {
|
|
158
|
+
const key = `${handle.path}:${handle.index}`;
|
|
159
|
+
if (seen.has(key)) continue;
|
|
160
|
+
seen.add(key);
|
|
161
|
+
const reference = await handle.resolve(project);
|
|
162
|
+
if (!reference) continue;
|
|
163
|
+
const file = real(reference.getSourceFile().fileName);
|
|
164
|
+
if (file === source) {
|
|
165
|
+
if (reference.end <= start.index || reference.getStart() >= end.index) usedInSource = true;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
referencing.add(file);
|
|
169
|
+
if (throughModule(reference)) usedThroughModule.add(file);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return { usedInSource, referencing: [...referencing], usedThroughModule: [...usedThroughModule] };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** A reference that reads the declaration off a module object rather than binding it by name. */
|
|
177
|
+
function throughModule(reference: Node): boolean {
|
|
178
|
+
const parent = reference.parent as Node & Record<string, unknown>;
|
|
179
|
+
switch (parent.kind) {
|
|
180
|
+
case SyntaxKind.PropertyAccessExpression:
|
|
181
|
+
return parent.name === reference;
|
|
182
|
+
case SyntaxKind.ElementAccessExpression:
|
|
183
|
+
return parent.argumentExpression === reference;
|
|
184
|
+
case SyntaxKind.QualifiedName:
|
|
185
|
+
return parent.right === reference;
|
|
186
|
+
case SyntaxKind.BindingElement:
|
|
187
|
+
return true;
|
|
188
|
+
default:
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
}
|
package/move-imports.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Import and export updates for moving a top-level JS/TS declaration to another file.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Import and export updates for moving a top-level JS/TS declaration to another file. Which names the
|
|
3
|
+
* declaration uses and which files refer to it come from TypeScript's checker (move-analysis.ts); the
|
|
4
|
+
* statements to change are found and rewritten from syntax. Cases that cannot be updated soundly are refused
|
|
5
|
+
* before anything is written.
|
|
5
6
|
*/
|
|
6
7
|
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
7
8
|
import { dirname, extname, relative, resolve } from "node:path";
|
|
8
9
|
import { parse, type SgNode } from "@ast-grep/napi";
|
|
10
|
+
import type { MoveAnalysis } from "./move-analysis.ts";
|
|
9
11
|
import { scriptLanguage } from "./placement.ts";
|
|
10
12
|
|
|
11
13
|
export interface TextEdit {
|
|
@@ -34,8 +36,9 @@ export interface MoveInput {
|
|
|
34
36
|
node: SgNode;
|
|
35
37
|
targetFile: string;
|
|
36
38
|
targetRoot: SgNode;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
+
analysis: MoveAnalysis;
|
|
40
|
+
/** Files that may re-export a module wholesale, with `export *`. */
|
|
41
|
+
filesReexportingAll: () => string[];
|
|
39
42
|
/** Files that may load modules dynamically, with import() or require(). */
|
|
40
43
|
filesLoadingModules: () => string[];
|
|
41
44
|
}
|
|
@@ -54,7 +57,6 @@ const DECLARATIONS = new Set([
|
|
|
54
57
|
const TYPE_DECLARATIONS = new Set(["interface_declaration", "type_alias_declaration"]);
|
|
55
58
|
/** Statements that declare a name without being a movable declaration: overloads and ambient or merged declarations. */
|
|
56
59
|
const OTHER_DECLARATIONS = new Set(["function_signature", "ambient_declaration", "module", "internal_module"]);
|
|
57
|
-
const REFERENCES = new Set(["identifier", "type_identifier", "shorthand_property_identifier"]);
|
|
58
60
|
|
|
59
61
|
class MoveError extends Error {
|
|
60
62
|
constructor(message: string) {
|
|
@@ -92,6 +94,11 @@ function declaration(statement: SgNode): Declaration | null {
|
|
|
92
94
|
return { names, types, exported };
|
|
93
95
|
}
|
|
94
96
|
|
|
97
|
+
/** The names a top-level statement declares, for analysing it before a move. */
|
|
98
|
+
export function declaredNames(statement: SgNode): string[] {
|
|
99
|
+
return declaration(statement)?.names ?? [];
|
|
100
|
+
}
|
|
101
|
+
|
|
95
102
|
/** The one top-level statement of a file that declares `symbol`, for moving it by name. */
|
|
96
103
|
export function topLevelDeclaration(root: SgNode, symbol: string, file: string): SgNode {
|
|
97
104
|
const found = root.children().filter((statement) => {
|
|
@@ -117,91 +124,6 @@ function patternNames(pattern: SgNode | null): string[] {
|
|
|
117
124
|
return pattern.namedChildren().flatMap((child) => patternNames(child));
|
|
118
125
|
}
|
|
119
126
|
|
|
120
|
-
const FUNCTIONS = new Set([
|
|
121
|
-
"function_declaration",
|
|
122
|
-
"function_expression",
|
|
123
|
-
"generator_function_declaration",
|
|
124
|
-
"generator_function",
|
|
125
|
-
"arrow_function",
|
|
126
|
-
"method_definition",
|
|
127
|
-
]);
|
|
128
|
-
const DECLARED_BY_NAME = new Set([
|
|
129
|
-
"function_declaration",
|
|
130
|
-
"generator_function_declaration",
|
|
131
|
-
"class_declaration",
|
|
132
|
-
"abstract_class_declaration",
|
|
133
|
-
"interface_declaration",
|
|
134
|
-
"type_alias_declaration",
|
|
135
|
-
"enum_declaration",
|
|
136
|
-
]);
|
|
137
|
-
|
|
138
|
-
/** Names a scope node declares for itself: parameters, type parameters, and declarations directly in a block. */
|
|
139
|
-
function scopeNames(scope: SgNode): Set<string> {
|
|
140
|
-
const names = new Set<string>();
|
|
141
|
-
const kind = String(scope.kind());
|
|
142
|
-
if (FUNCTIONS.has(kind) || kind === "class" || kind === "class_declaration" || DECLARED_BY_NAME.has(kind)) {
|
|
143
|
-
const parameters = scope.field("parameters");
|
|
144
|
-
for (const parameter of parameters?.namedChildren() ?? [])
|
|
145
|
-
for (const name of patternNames(parameter.field("pattern") ?? parameter.namedChildren()[0] ?? null))
|
|
146
|
-
names.add(name);
|
|
147
|
-
if (scope.field("parameter")) for (const name of patternNames(scope.field("parameter"))) names.add(name);
|
|
148
|
-
for (const parameter of scope.field("type_parameters")?.namedChildren() ?? [])
|
|
149
|
-
if (parameter.field("name")) names.add(parameter.field("name")!.text());
|
|
150
|
-
// A function or class expression can refer to its own name.
|
|
151
|
-
if ((kind === "function_expression" || kind === "class") && scope.field("name"))
|
|
152
|
-
names.add(scope.field("name")!.text());
|
|
153
|
-
}
|
|
154
|
-
if (kind === "catch_clause") for (const name of patternNames(scope.field("parameter"))) names.add(name);
|
|
155
|
-
if (kind === "for_in_statement") for (const name of patternNames(scope.field("left"))) names.add(name);
|
|
156
|
-
const statements =
|
|
157
|
-
kind === "statement_block" || kind === "class_body"
|
|
158
|
-
? scope.namedChildren()
|
|
159
|
-
: kind === "for_statement"
|
|
160
|
-
? [scope.field("initializer")]
|
|
161
|
-
: [];
|
|
162
|
-
for (const statement of statements) {
|
|
163
|
-
if (!statement) continue;
|
|
164
|
-
const inner = statement.kind() === "export_statement" ? statement.field("declaration") : statement;
|
|
165
|
-
if (!inner) continue;
|
|
166
|
-
if (inner.kind() === "lexical_declaration" || inner.kind() === "variable_declaration")
|
|
167
|
-
for (const declarator of inner.namedChildren())
|
|
168
|
-
if (declarator.kind() === "variable_declarator")
|
|
169
|
-
for (const name of patternNames(declarator.field("name"))) names.add(name);
|
|
170
|
-
if (DECLARED_BY_NAME.has(String(inner.kind())) && inner.field("name")) names.add(inner.field("name")!.text());
|
|
171
|
-
}
|
|
172
|
-
return names;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* Names a node refers to that no enclosing scope inside it declares: its dependencies, and globals. Each
|
|
177
|
-
* reference is checked against its own enclosing scopes, so a nested local that shares a name with an
|
|
178
|
-
* import does not hide the import's other uses.
|
|
179
|
-
*/
|
|
180
|
-
function freeNames(node: SgNode): string[] {
|
|
181
|
-
const scopes = new Map<string, Set<string>>();
|
|
182
|
-
const declaredBy = (scope: SgNode) => {
|
|
183
|
-
const key = `${scope.kind()}:${scope.range().start.index}:${scope.range().end.index}`;
|
|
184
|
-
if (!scopes.has(key)) scopes.set(key, scopeNames(scope));
|
|
185
|
-
return scopes.get(key)!;
|
|
186
|
-
};
|
|
187
|
-
const { start, end } = node.range();
|
|
188
|
-
const names = new Set<string>();
|
|
189
|
-
for (const reference of node.findAll({ rule: { any: [...REFERENCES].map((kind) => ({ kind })) } })) {
|
|
190
|
-
const name = reference.text();
|
|
191
|
-
let local = false;
|
|
192
|
-
for (const ancestor of reference.ancestors()) {
|
|
193
|
-
const range = ancestor.range();
|
|
194
|
-
if (range.start.index < start.index || range.end.index > end.index) break;
|
|
195
|
-
if (declaredBy(ancestor).has(name)) {
|
|
196
|
-
local = true;
|
|
197
|
-
break;
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
if (!local) names.add(name);
|
|
201
|
-
}
|
|
202
|
-
return [...names];
|
|
203
|
-
}
|
|
204
|
-
|
|
205
127
|
interface Binding {
|
|
206
128
|
local: string;
|
|
207
129
|
/** The exported name the binding refers to; empty for default and namespace imports. */
|
|
@@ -219,6 +141,12 @@ function moduleName(statement: SgNode): string | undefined {
|
|
|
219
141
|
return source?.text().slice(1, -1);
|
|
220
142
|
}
|
|
221
143
|
|
|
144
|
+
/** A statement's import attributes, such as ` with { type: "json" }`, which must survive any rewrite of it. */
|
|
145
|
+
function attributesOf(statement: SgNode): string {
|
|
146
|
+
const attributes = statement.children().find((child) => child.kind() === "import_attribute");
|
|
147
|
+
return attributes ? ` ${attributes.text()}` : "";
|
|
148
|
+
}
|
|
149
|
+
|
|
222
150
|
function quoteOf(statement: SgNode): string {
|
|
223
151
|
return statement.field("source")?.text()[0] ?? '"';
|
|
224
152
|
}
|
|
@@ -359,17 +287,27 @@ function fileStyle(root: SgNode): Style {
|
|
|
359
287
|
|
|
360
288
|
const JS_FOR_SOURCE: Record<string, string> = { ".ts": ".js", ".tsx": ".js", ".mts": ".mjs", ".cts": ".cjs" };
|
|
361
289
|
|
|
290
|
+
/**
|
|
291
|
+
* A file's path with symlinks resolved, including a file that does not exist yet, through its nearest
|
|
292
|
+
* existing directory. Resolved imports are real paths, so a path through a symlink, such as macOS's /var for
|
|
293
|
+
* /private/var, must be resolved the same way before relative specifiers are computed between them.
|
|
294
|
+
*/
|
|
295
|
+
function canonical(file: string): string {
|
|
296
|
+
if (existsSync(file)) return realpathSync(file);
|
|
297
|
+
const parent = dirname(file);
|
|
298
|
+
return parent === file ? file : resolve(canonical(parent), file.slice(parent.length + 1));
|
|
299
|
+
}
|
|
300
|
+
|
|
362
301
|
function specifierFor(from: string, to: string, style: Style): string {
|
|
363
|
-
let path = relative(dirname(from), to).replaceAll("\\", "/");
|
|
302
|
+
let path = relative(dirname(canonical(from)), canonical(to)).replaceAll("\\", "/");
|
|
364
303
|
if (!path.startsWith(".")) path = `./${path}`;
|
|
365
304
|
const extension = extname(to);
|
|
366
|
-
|
|
305
|
+
// Only scripts resolve without their extension; JSON, stylesheets and other assets keep it.
|
|
306
|
+
if (style === "ts" || !EXTENSIONS.includes(extension)) return path;
|
|
367
307
|
const stem = path.slice(0, -extension.length);
|
|
368
308
|
return style === "js" ? stem + (JS_FOR_SOURCE[extension] ?? extension) : stem;
|
|
369
309
|
}
|
|
370
310
|
|
|
371
|
-
const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
372
|
-
|
|
373
311
|
const stem = (file: string) => file.slice(0, file.length - extname(file).length);
|
|
374
312
|
|
|
375
313
|
/**
|
|
@@ -424,17 +362,21 @@ function importStatement(
|
|
|
424
362
|
specifiers: { text: string; typeOnly: boolean }[],
|
|
425
363
|
module: string,
|
|
426
364
|
quote: string,
|
|
365
|
+
attributes = "",
|
|
427
366
|
): string {
|
|
428
367
|
const allTypes = specifiers.every((specifier) => specifier.typeOnly);
|
|
429
368
|
const texts = specifiers.map((specifier) => {
|
|
430
369
|
const bare = specifier.text.replace(/^type\s+/, "");
|
|
431
370
|
return !allTypes && specifier.typeOnly ? `type ${bare}` : bare;
|
|
432
371
|
});
|
|
433
|
-
return `${keyword} ${namedClause(texts, allTypes)} from ${quote}${module}${quote};`;
|
|
372
|
+
return `${keyword} ${namedClause(texts, allTypes)} from ${quote}${module}${quote}${attributes};`;
|
|
434
373
|
}
|
|
435
374
|
|
|
436
|
-
/**
|
|
437
|
-
|
|
375
|
+
/**
|
|
376
|
+
* Rebuild an import or re-export without the bindings `drop` picks by imported and local name (a default or
|
|
377
|
+
* namespace import has no imported name); empty when nothing is left.
|
|
378
|
+
*/
|
|
379
|
+
function withoutSpecifiers(statement: SgNode, drop: (imported: string, local: string) => boolean): string {
|
|
438
380
|
const keyword = statement.kind() === "import_statement" ? "import" : "export";
|
|
439
381
|
const allTypes = typeOnlyStatement(statement);
|
|
440
382
|
const clause = statement
|
|
@@ -443,17 +385,20 @@ function withoutSpecifiers(statement: SgNode, remove: Set<string>): string {
|
|
|
443
385
|
const parts: string[] = [];
|
|
444
386
|
const named: string[] = [];
|
|
445
387
|
const collect = (list: SgNode) => {
|
|
446
|
-
for (const specifier of list.namedChildren())
|
|
447
|
-
|
|
388
|
+
for (const specifier of list.namedChildren()) {
|
|
389
|
+
const imported = specifier.field("name")!.text();
|
|
390
|
+
if (!drop(imported, (specifier.field("alias") ?? specifier.field("name"))!.text())) named.push(specifier.text());
|
|
391
|
+
}
|
|
448
392
|
};
|
|
449
393
|
if (clause?.kind() === "export_clause") collect(clause);
|
|
450
394
|
for (const part of clause?.kind() === "import_clause" ? clause.namedChildren() : []) {
|
|
451
395
|
if (part.kind() === "named_imports") collect(part);
|
|
452
|
-
else
|
|
396
|
+
else if (!drop("", part.kind() === "namespace_import" ? part.namedChildren().at(-1)!.text() : part.text()))
|
|
397
|
+
parts.push(part.text());
|
|
453
398
|
}
|
|
454
399
|
if (named.length) parts.push(`{ ${named.join(", ")} }`);
|
|
455
400
|
if (!parts.length) return "";
|
|
456
|
-
return `${keyword} ${allTypes ? "type " : ""}${parts.join(", ")} from ${statement.field("source")!.text()};`;
|
|
401
|
+
return `${keyword} ${allTypes ? "type " : ""}${parts.join(", ")} from ${statement.field("source")!.text()}${attributesOf(statement)};`;
|
|
457
402
|
}
|
|
458
403
|
|
|
459
404
|
/** Where new imports go: after the last import, or at the start after a shebang and directives. */
|
|
@@ -500,11 +445,21 @@ export function planImports(input: MoveInput): ImportPlan | null {
|
|
|
500
445
|
const target = topLevel(targetRoot);
|
|
501
446
|
const sourceEdits: TextEdit[] = [];
|
|
502
447
|
const targetEdits: TextEdit[] = [];
|
|
503
|
-
|
|
504
|
-
const
|
|
505
|
-
|
|
448
|
+
// Keyed by module and attributes: the same module imported with and without attributes are distinct imports.
|
|
449
|
+
const targetImports = new Map<
|
|
450
|
+
string,
|
|
451
|
+
{ module: string; attributes: string; quote: string; specifiers: { text: string; typeOnly: boolean }[] }
|
|
452
|
+
>();
|
|
453
|
+
const addTargetImport = (
|
|
454
|
+
module: string,
|
|
455
|
+
quote: string,
|
|
456
|
+
specifier: { text: string; typeOnly: boolean },
|
|
457
|
+
attributes = "",
|
|
458
|
+
) => {
|
|
459
|
+
const key = `${module}\0${attributes}`;
|
|
460
|
+
const entry = targetImports.get(key) ?? { module, attributes, quote, specifiers: [] };
|
|
506
461
|
entry.specifiers.push(specifier);
|
|
507
|
-
targetImports.set(
|
|
462
|
+
targetImports.set(key, entry);
|
|
508
463
|
};
|
|
509
464
|
const targetLines: string[] = [];
|
|
510
465
|
const style = (root: SgNode, fallback: SgNode) => {
|
|
@@ -529,12 +484,16 @@ export function planImports(input: MoveInput): ImportPlan | null {
|
|
|
529
484
|
}
|
|
530
485
|
for (const [statement, removed] of nowLocal) {
|
|
531
486
|
const { start, end } = statement.range();
|
|
532
|
-
targetEdits.push({
|
|
487
|
+
targetEdits.push({
|
|
488
|
+
start: start.index,
|
|
489
|
+
end: end.index,
|
|
490
|
+
text: withoutSpecifiers(statement, (imported) => removed.has(imported)),
|
|
491
|
+
});
|
|
533
492
|
}
|
|
534
493
|
|
|
535
494
|
// Dependencies: copy the source's imports, and import (exporting if needed) the source's own declarations.
|
|
536
495
|
const fromSource: { text: string; typeOnly: boolean }[] = [];
|
|
537
|
-
const free =
|
|
496
|
+
const free = input.analysis.dependencies;
|
|
538
497
|
const order = [...sourceBindings.map((binding) => binding.local), ...source.declarations.keys()];
|
|
539
498
|
const ordered = [...order.filter((name) => free.has(name)), ...[...free].filter((name) => !order.includes(name))];
|
|
540
499
|
for (const name of new Set(ordered)) {
|
|
@@ -560,8 +519,13 @@ export function planImports(input: MoveInput): ImportPlan | null {
|
|
|
560
519
|
? specifierFor(targetFile, resolved, styleOf(binding.module))
|
|
561
520
|
: binding.module;
|
|
562
521
|
const quote = quoteOf(binding.statement);
|
|
563
|
-
|
|
564
|
-
|
|
522
|
+
const attributes = attributesOf(binding.statement);
|
|
523
|
+
if (binding.kind === "named")
|
|
524
|
+
addTargetImport(module, quote, { text: binding.text, typeOnly: binding.typeOnly }, attributes);
|
|
525
|
+
else
|
|
526
|
+
targetLines.push(
|
|
527
|
+
`import ${binding.typeOnly ? "type " : ""}${binding.text} from ${quote}${module}${quote}${attributes};`,
|
|
528
|
+
);
|
|
565
529
|
} else if (local) {
|
|
566
530
|
if (existing || target.declarations.has(name)) {
|
|
567
531
|
if (existing?.kind === "named" && existing.imported === name && importedFromSource(existing)) continue;
|
|
@@ -581,29 +545,37 @@ export function planImports(input: MoveInput): ImportPlan | null {
|
|
|
581
545
|
const module = specifierFor(targetFile, sourceFile, style(targetRoot, sourceRoot));
|
|
582
546
|
for (const specifier of fromSource) addTargetImport(module, '"', specifier);
|
|
583
547
|
}
|
|
584
|
-
for (const
|
|
585
|
-
targetLines.push(importStatement("import", specifiers, module, quote));
|
|
548
|
+
for (const { module, attributes, quote, specifiers } of targetImports.values())
|
|
549
|
+
targetLines.push(importStatement("import", specifiers, module, quote, attributes));
|
|
586
550
|
if (targetLines.length) targetEdits.push(importInsertion(targetRoot, targetText, targetLines));
|
|
587
551
|
|
|
552
|
+
// Imports only the declaration used leave with it; a statement left with no bindings goes with its line.
|
|
553
|
+
const sourceNewline = sourceText.includes("\r\n") ? "\r\n" : "\n";
|
|
554
|
+
const unused = input.analysis.importsOnlyItUses;
|
|
555
|
+
for (const statement of new Set(
|
|
556
|
+
sourceBindings.filter((binding) => unused.has(binding.local)).map((binding) => binding.statement),
|
|
557
|
+
)) {
|
|
558
|
+
const { start, end } = statement.range();
|
|
559
|
+
const text = withoutSpecifiers(statement, (_, local) => unused.has(local));
|
|
560
|
+
const lineEnd = sourceText.startsWith(sourceNewline, end.index) ? end.index + sourceNewline.length : end.index;
|
|
561
|
+
sourceEdits.push({ start: start.index, end: text ? end.index : lineEnd, text });
|
|
562
|
+
}
|
|
563
|
+
|
|
588
564
|
// The source imports the declaration back if code left there still uses it.
|
|
589
|
-
const
|
|
590
|
-
const stillUsed = sourceRoot
|
|
591
|
-
.findAll({ rule: { any: [...REFERENCES].map((kind) => ({ kind })) } })
|
|
592
|
-
.some((reference) => {
|
|
593
|
-
const { start } = reference.range();
|
|
594
|
-
if (start.index >= movedStart.index && start.index < movedEnd.index) return false;
|
|
595
|
-
if (!names.has(reference.text())) return false;
|
|
596
|
-
const statement = reference.ancestors().find((ancestor) => ancestor.parent()?.kind() === "program");
|
|
597
|
-
return statement?.kind() !== "import_statement" && !(statement && moduleName(statement));
|
|
598
|
-
});
|
|
565
|
+
const stillUsed = input.analysis.usedInSource;
|
|
599
566
|
if (stillUsed) {
|
|
600
567
|
const module = specifierFor(sourceFile, targetFile, style(sourceRoot, targetRoot));
|
|
601
568
|
const specifiers = moved.names.map((name) => ({ text: name, typeOnly: moved.types.has(name) }));
|
|
602
|
-
|
|
569
|
+
const insertion = importInsertion(sourceRoot, sourceText, [importStatement("import", specifiers, module, '"')]);
|
|
570
|
+
// Inserted after the last import: if that import is being rewritten, the new line joins its replacement.
|
|
571
|
+
const rewritten = sourceEdits.find((edit) => edit.start < insertion.start && insertion.start <= edit.end);
|
|
572
|
+
if (!rewritten) sourceEdits.push(insertion);
|
|
573
|
+
else if (rewritten.text) rewritten.text += insertion.text;
|
|
574
|
+
else rewritten.text = insertion.text.slice(sourceNewline.length) + sourceNewline;
|
|
603
575
|
}
|
|
604
576
|
|
|
605
577
|
// Importers of an exported declaration are repointed at the target. A module loaded with import() or
|
|
606
|
-
// require()
|
|
578
|
+
// require(), or read as an object, is used whole, which cannot be repointed name by name.
|
|
607
579
|
const importers: FileEdits[] = [];
|
|
608
580
|
if (moved.exported) {
|
|
609
581
|
for (const file of new Set(input.filesLoadingModules())) {
|
|
@@ -619,7 +591,13 @@ export function planImports(input: MoveInput): ImportPlan | null {
|
|
|
619
591
|
throw new MoveError(`${file} loads ${sourceFile} with ${callee}()`);
|
|
620
592
|
}
|
|
621
593
|
}
|
|
622
|
-
for (const file of
|
|
594
|
+
for (const file of input.analysis.usedThroughModule)
|
|
595
|
+
if (!sameFile(file, sourceFile))
|
|
596
|
+
throw new MoveError(`${file} uses ${moved.names.join(", ")} through a namespace import or module object`);
|
|
597
|
+
const candidates = new Set(
|
|
598
|
+
[...input.analysis.referencing, ...input.filesReexportingAll()].map((file) => resolve(file)),
|
|
599
|
+
);
|
|
600
|
+
for (const file of [...candidates].toSorted()) {
|
|
623
601
|
if (sameFile(file, sourceFile) || sameFile(file, targetFile)) continue;
|
|
624
602
|
const fileLang = scriptLanguage(file);
|
|
625
603
|
if (!fileLang) continue;
|
|
@@ -656,16 +634,6 @@ export function planImports(input: MoveInput): ImportPlan | null {
|
|
|
656
634
|
.find((part) => part.kind() === "named_imports")
|
|
657
635
|
?.namedChildren() ?? [])
|
|
658
636
|
).filter((specifier) => names.has(specifier.field("name")!.text()));
|
|
659
|
-
const namespace = clause?.namedChildren().find((part) => part.kind() === "namespace_import");
|
|
660
|
-
if (namespace) {
|
|
661
|
-
const local = namespace.namedChildren().at(-1)!.text();
|
|
662
|
-
if (
|
|
663
|
-
moved.names.some((name) =>
|
|
664
|
-
new RegExp(`(?<![\\w$])${escape(local)}\\s*\\.\\s*${escape(name)}(?![\\w$])`).test(text),
|
|
665
|
-
)
|
|
666
|
-
)
|
|
667
|
-
throw new MoveError(`${file} uses ${local}.<name> through a namespace import`);
|
|
668
|
-
}
|
|
669
637
|
if (!specifiers.length) continue;
|
|
670
638
|
const list = statement.kind() === "import_statement" ? imports : reexports;
|
|
671
639
|
for (const specifier of specifiers)
|
|
@@ -677,7 +645,9 @@ export function planImports(input: MoveInput): ImportPlan | null {
|
|
|
677
645
|
edits.push({
|
|
678
646
|
start: start.index,
|
|
679
647
|
end: end.index,
|
|
680
|
-
text: withoutSpecifiers(statement,
|
|
648
|
+
text: withoutSpecifiers(statement, (imported) =>
|
|
649
|
+
specifiers.some((specifier) => specifier.field("name")!.text() === imported),
|
|
650
|
+
),
|
|
681
651
|
});
|
|
682
652
|
}
|
|
683
653
|
if (!targetModule || (!imports.length && !reexports.length)) continue;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-shorthand",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Pi tool for editing repositories with Bun programs,
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"description": "Pi tool for editing repositories with Bun programs, with first-class support for TypeScript and JavaScript.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ast-grep",
|
|
7
7
|
"bun",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"@ast-grep/napi": "^0.45.3",
|
|
42
42
|
"@getgrit/cli": "0.1.0-alpha.1743007075",
|
|
43
43
|
"diff": "^9.0.0",
|
|
44
|
-
"typescript": "
|
|
44
|
+
"typescript": "7.0.2",
|
|
45
45
|
"vscode-jsonrpc": "^9.0.2"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
package/placement.ts
CHANGED
|
@@ -3,7 +3,8 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from
|
|
|
3
3
|
import { dirname, resolve } from "node:path";
|
|
4
4
|
import { Lang, parse, type SgNode } from "@ast-grep/napi";
|
|
5
5
|
import { editingFiles } from "./file-outcomes.ts";
|
|
6
|
-
import {
|
|
6
|
+
import { analyzeMove, type MoveAnalysis } from "./move-analysis.ts";
|
|
7
|
+
import { declaredNames, planImports, topLevelDeclaration } from "./move-imports.ts";
|
|
7
8
|
|
|
8
9
|
export interface Match {
|
|
9
10
|
file: string;
|
|
@@ -124,10 +125,17 @@ interface Edit {
|
|
|
124
125
|
text: string;
|
|
125
126
|
}
|
|
126
127
|
|
|
128
|
+
/** Removes a statement, and the line break after it when it has whole lines to itself. */
|
|
127
129
|
function removal(saved: Snapshot): Edit {
|
|
128
130
|
statement(saved.node);
|
|
129
131
|
const { start, end } = saved.node.range();
|
|
130
|
-
|
|
132
|
+
const { source } = saved;
|
|
133
|
+
const lineStart = source.lastIndexOf("\n", start.index - 1) + 1;
|
|
134
|
+
const newline = source.startsWith("\r\n", end.index) ? 2 : source.startsWith("\n", end.index) ? 1 : 0;
|
|
135
|
+
const wholeLines = !source.slice(lineStart, start.index).trim() && newline > 0;
|
|
136
|
+
return wholeLines
|
|
137
|
+
? { parent: saved.node.parent()!, start: lineStart, end: end.index + newline, text: "" }
|
|
138
|
+
: { parent: saved.node.parent()!, start: start.index, end: end.index, text: "" };
|
|
131
139
|
}
|
|
132
140
|
|
|
133
141
|
function placement(text: string, destination: Destination) {
|
|
@@ -314,30 +322,44 @@ export function move(match: Match, destination: Destination, transform?: (text:
|
|
|
314
322
|
return editingFiles([match.file, ...destinationFiles(destination)], () => moveNodes(match, destination, transform));
|
|
315
323
|
}
|
|
316
324
|
|
|
325
|
+
/** What refactor.move needs from the repository besides the moved declaration. */
|
|
326
|
+
export interface MoveFiles {
|
|
327
|
+
root: string;
|
|
328
|
+
/** Every JS/TS file Git sees. */
|
|
329
|
+
scripts: () => string[];
|
|
330
|
+
/** Files that may load modules dynamically, with import() or require(). */
|
|
331
|
+
loadingModules: () => string[];
|
|
332
|
+
/** Files that may re-export a module wholesale, with `export *`. */
|
|
333
|
+
reexportingAll: () => string[];
|
|
334
|
+
}
|
|
335
|
+
|
|
317
336
|
/**
|
|
318
337
|
* Moves the top-level declaration of `symbol` in `from` to the end of `to`, updating imports: the target
|
|
319
338
|
* imports what the declaration uses, the source imports it back if still needed, and files importing it
|
|
320
|
-
* from the source import it from the target.
|
|
339
|
+
* from the source import it from the target. TypeScript's checker decides what the declaration uses and who
|
|
340
|
+
* refers to it.
|
|
321
341
|
*/
|
|
322
|
-
export function moveDeclaration(
|
|
323
|
-
from: string,
|
|
324
|
-
symbol: string,
|
|
325
|
-
to: string,
|
|
326
|
-
files: { mentioning: (names: string[]) => string[]; loadingModules: () => string[] },
|
|
327
|
-
): void {
|
|
342
|
+
export async function moveDeclaration(from: string, symbol: string, to: string, files: MoveFiles): Promise<void> {
|
|
328
343
|
const lang = scriptLanguage(from);
|
|
329
344
|
if (!lang || !scriptLanguage(to)) throw new Error("refactor.move requires JS/TS files");
|
|
330
345
|
const source = readFileSync(from, "utf8");
|
|
331
346
|
const node = topLevelDeclaration(parse(lang, source).root(), symbol, from);
|
|
347
|
+
const analysis = await analyzeMove({
|
|
348
|
+
root: files.root,
|
|
349
|
+
file: from,
|
|
350
|
+
node,
|
|
351
|
+
names: declaredNames(node),
|
|
352
|
+
files: files.scripts(),
|
|
353
|
+
});
|
|
332
354
|
const match = remember({ file: from, text: node.text(), node }, source);
|
|
333
|
-
return editingFiles([from, to], () => moveNodes(match, { endOf: file(to) }, undefined, files));
|
|
355
|
+
return editingFiles([from, to], () => moveNodes(match, { endOf: file(to) }, undefined, { ...files, analysis }));
|
|
334
356
|
}
|
|
335
357
|
|
|
336
358
|
function moveNodes(
|
|
337
359
|
match: Match,
|
|
338
360
|
destination: Destination,
|
|
339
361
|
transform?: (text: string) => string,
|
|
340
|
-
files?:
|
|
362
|
+
files?: MoveFiles & { analysis: MoveAnalysis },
|
|
341
363
|
): void {
|
|
342
364
|
const source = snapshot(match);
|
|
343
365
|
const deletion = removal(source);
|
|
@@ -366,8 +388,9 @@ function moveNodes(
|
|
|
366
388
|
node: source.node,
|
|
367
389
|
targetFile: target.saved.file,
|
|
368
390
|
targetRoot,
|
|
369
|
-
|
|
391
|
+
analysis: files.analysis,
|
|
370
392
|
filesLoadingModules: files.loadingModules,
|
|
393
|
+
filesReexportingAll: files.reexportingAll,
|
|
371
394
|
})
|
|
372
395
|
: null;
|
|
373
396
|
const placed = { ...target.edit };
|
package/prelude.ts
CHANGED
|
@@ -307,70 +307,32 @@ function selectScope(helper: string, inputs: (string | FileTarget)[], select: (i
|
|
|
307
307
|
];
|
|
308
308
|
}
|
|
309
309
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
"-C",
|
|
316
|
-
repositoryRoot,
|
|
317
|
-
"grep",
|
|
318
|
-
"-l",
|
|
319
|
-
"-z",
|
|
320
|
-
"-w",
|
|
321
|
-
"-F",
|
|
322
|
-
"--untracked",
|
|
323
|
-
...names.flatMap((name) => ["-e", name]),
|
|
324
|
-
"--",
|
|
325
|
-
"*.ts",
|
|
326
|
-
"*.tsx",
|
|
327
|
-
"*.mts",
|
|
328
|
-
"*.cts",
|
|
329
|
-
"*.js",
|
|
330
|
-
"*.jsx",
|
|
331
|
-
"*.mjs",
|
|
332
|
-
"*.cjs",
|
|
333
|
-
],
|
|
334
|
-
[1],
|
|
335
|
-
);
|
|
336
|
-
return output
|
|
310
|
+
const SCRIPTS = ["*.ts", "*.tsx", "*.mts", "*.cts", "*.js", "*.jsx", "*.mjs", "*.cjs"];
|
|
311
|
+
|
|
312
|
+
/** JS/TS files Git sees, tracked or new. */
|
|
313
|
+
function scriptFiles(): string[] {
|
|
314
|
+
return git(["-C", repositoryRoot, "ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", ...SCRIPTS])
|
|
337
315
|
.split("\0")
|
|
338
316
|
.filter(Boolean)
|
|
339
317
|
.map((file) => resolve(repositoryRoot, file));
|
|
340
318
|
}
|
|
341
319
|
|
|
342
|
-
/** JS/TS files Git sees
|
|
343
|
-
function
|
|
344
|
-
|
|
345
|
-
[
|
|
346
|
-
"-C",
|
|
347
|
-
repositoryRoot,
|
|
348
|
-
"grep",
|
|
349
|
-
"-l",
|
|
350
|
-
"-z",
|
|
351
|
-
"-E",
|
|
352
|
-
"--untracked",
|
|
353
|
-
"-e",
|
|
354
|
-
// The call may continue on the next line or after a comment: `import\n("./a")`.
|
|
355
|
-
"(^|[^[:alnum:]_$])(import|require)[[:space:]]*(\\(|/[*/]|$)",
|
|
356
|
-
"--",
|
|
357
|
-
"*.ts",
|
|
358
|
-
"*.tsx",
|
|
359
|
-
"*.mts",
|
|
360
|
-
"*.cts",
|
|
361
|
-
"*.js",
|
|
362
|
-
"*.jsx",
|
|
363
|
-
"*.mjs",
|
|
364
|
-
"*.cjs",
|
|
365
|
-
],
|
|
366
|
-
[1],
|
|
367
|
-
);
|
|
368
|
-
return output
|
|
320
|
+
/** JS/TS files Git sees with a line matching an extended regular expression. */
|
|
321
|
+
function scriptFilesMatching(pattern: string): string[] {
|
|
322
|
+
return git(["-C", repositoryRoot, "grep", "-l", "-z", "-E", "--untracked", "-e", pattern, "--", ...SCRIPTS], [1])
|
|
369
323
|
.split("\0")
|
|
370
324
|
.filter(Boolean)
|
|
371
325
|
.map((file) => resolve(repositoryRoot, file));
|
|
372
326
|
}
|
|
373
327
|
|
|
328
|
+
/** JS/TS files Git sees that call import() or require(). */
|
|
329
|
+
const filesLoadingModules = () =>
|
|
330
|
+
// The call may continue on the next line or after a comment: `import\n("./a")`.
|
|
331
|
+
scriptFilesMatching("(^|[^[:alnum:]_$])(import|require)[[:space:]]*(\\(|/[*/]|$)");
|
|
332
|
+
|
|
333
|
+
/** JS/TS files Git sees that may re-export a module wholesale, with `export *`, which may span lines. */
|
|
334
|
+
const filesReexportingAll = () => scriptFilesMatching("(^|[^[:alnum:]_$])export[[:space:]]*(\\*|/[*/]|$)");
|
|
335
|
+
|
|
374
336
|
/** A scope for a warning: short scopes in full, long ones as a count and the first few paths. */
|
|
375
337
|
function describeScope(scope: FileScope): string {
|
|
376
338
|
const paths = (Array.isArray(scope) ? scope : [scope]).map((entry) =>
|
|
@@ -813,7 +775,12 @@ const globals = {
|
|
|
813
775
|
const to = explicitPath(pathArgument("refactor.move", options.to));
|
|
814
776
|
if (typeof options.symbol !== "string" || !options.symbol)
|
|
815
777
|
throw new TypeError("refactor.move expects { file, symbol, to } with a symbol name");
|
|
816
|
-
moveDeclaration(from, options.symbol, to, {
|
|
778
|
+
await moveDeclaration(from, options.symbol, to, {
|
|
779
|
+
root: repositoryRoot,
|
|
780
|
+
scripts: scriptFiles,
|
|
781
|
+
loadingModules: filesLoadingModules,
|
|
782
|
+
reexportingAll: filesReexportingAll,
|
|
783
|
+
});
|
|
817
784
|
}),
|
|
818
785
|
renameFile: (options: RenameFileOptions) =>
|
|
819
786
|
logged("refactor.renameFile", [options], () => {
|
package/program-lint.ts
CHANGED
|
@@ -120,6 +120,6 @@ export function typeScriptApiHint(program: string, output: string, version: stri
|
|
|
120
120
|
if (!/(?:\bfrom\s*|\brequire\(\s*|\bimport\(\s*)["']typescript["']/.test(program)) return [];
|
|
121
121
|
if (!version || Number.parseInt(version, 10) < 7) return [];
|
|
122
122
|
return [
|
|
123
|
-
`typescript resolves to ${version} here, which has
|
|
123
|
+
`typescript resolves to ${version} here, which no longer has the classic compiler API (ts.createSourceFile, ts.SyntaxKind and so on); its unstable replacement is typescript/unstable/sync. Use sg to read and edit syntax, or refactor.rename and refactor.renameFile for refactors.`,
|
|
124
124
|
];
|
|
125
125
|
}
|