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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  ![A code call in Pi](https://raw.githubusercontent.com/sebinsua/pi-shorthand/main/docs/screenshot.png?v=2)
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 || info.op != PTRACE_SYSCALL_INFO_SECCOMP)
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");
@@ -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. This is module and
3
- * scope bookkeeping, done from syntax: which names the declaration uses, which files import it, and what the
4
- * source file still needs. Cases syntax cannot settle soundly are refused before anything is written.
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
- /** Files that may mention any of these names: every JS/TS file Git sees would also do. */
38
- filesMentioning: (names: string[]) => string[];
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
- if (style === "ts") return path;
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
- /** Rebuild an import or re-export without some named specifiers; empty when nothing is left. */
437
- function withoutSpecifiers(statement: SgNode, remove: Set<string>): string {
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
- if (!remove.has(specifier.field("name")!.text())) named.push(specifier.text());
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 parts.push(part.text());
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
- const targetImports = new Map<string, { quote: string; specifiers: { text: string; typeOnly: boolean }[] }>();
504
- const addTargetImport = (module: string, quote: string, specifier: { text: string; typeOnly: boolean }) => {
505
- const entry = targetImports.get(module) ?? { quote, specifiers: [] };
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(module, entry);
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({ start: start.index, end: end.index, text: withoutSpecifiers(statement, removed) });
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 = new Set(freeNames(node));
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
- if (binding.kind === "named") addTargetImport(module, quote, { text: binding.text, typeOnly: binding.typeOnly });
564
- else targetLines.push(`import ${binding.typeOnly ? "type " : ""}${binding.text} from ${quote}${module}${quote};`);
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 [module, { quote, specifiers }] of targetImports)
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 { start: movedStart, end: movedEnd } = node.range();
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
- sourceEdits.push(importInsertion(sourceRoot, sourceText, [importStatement("import", specifiers, module, '"')]));
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() is used as a whole object, which cannot be repointed name by name.
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 new Set(input.filesMentioning(moved.names))) {
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, new Set(specifiers.map((specifier) => specifier.field("name")!.text()))),
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.7.1",
4
- "description": "Pi tool for editing repositories with Bun programs, text edits and structural transformations.",
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": "^7.0.2",
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 { planImports, topLevelDeclaration } from "./move-imports.ts";
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
- return { parent: saved.node.parent()!, start: start.index, end: end.index, text: "" };
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. `filesMentioning` lists JS/TS files that may contain the names.
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?: { mentioning: (names: string[]) => string[]; loadingModules: () => string[] },
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
- filesMentioning: files.mentioning,
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
- /** JS/TS files Git sees that contain any of these names as a word, for updating their imports. */
311
- function filesMentioning(names: string[]): string[] {
312
- if (!names.length) return [];
313
- const output = git(
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 that call import() or require(). */
343
- function filesLoadingModules(): string[] {
344
- const output = git(
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, { mentioning: filesMentioning, loadingModules: filesLoadingModules });
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 no compiler API (ts.createSourceFile, ts.SyntaxKind and so on). Use sg to read and edit syntax, or refactor.rename and refactor.renameFile for refactors.`,
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
  }