uql-codemod 0.48.1 → 0.49.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.
Files changed (43) hide show
  1. package/README.md +27 -16
  2. package/dist/builders.d.ts +18 -0
  3. package/dist/builders.js +588 -0
  4. package/dist/classOptions.d.ts +9 -0
  5. package/dist/classOptions.js +240 -0
  6. package/dist/columnMethods.d.ts +11 -0
  7. package/dist/columnMethods.js +6 -0
  8. package/dist/columnRefs.js +3 -10
  9. package/dist/context.d.ts +18 -5
  10. package/dist/context.js +22 -4
  11. package/dist/definers.d.ts +24 -0
  12. package/dist/definers.js +34 -0
  13. package/dist/edits.d.ts +43 -11
  14. package/dist/edits.js +105 -26
  15. package/dist/entitySql.d.ts +15 -16
  16. package/dist/entitySql.js +33 -31
  17. package/dist/imports.d.ts +33 -16
  18. package/dist/imports.js +213 -99
  19. package/dist/index.d.ts +3 -3
  20. package/dist/index.js +9 -12
  21. package/dist/jsonIndex.d.ts +11 -0
  22. package/dist/jsonIndex.js +78 -0
  23. package/dist/keyMaps.d.ts +3 -1
  24. package/dist/keyMaps.js +5 -1
  25. package/dist/migrationBuilders.d.ts +13 -0
  26. package/dist/migrationBuilders.js +182 -0
  27. package/dist/relationTarget.d.ts +6 -0
  28. package/dist/relationTarget.js +21 -0
  29. package/dist/schemaEntity.d.ts +12 -0
  30. package/dist/schemaEntity.js +518 -0
  31. package/dist/schemaOptions.d.ts +6 -0
  32. package/dist/schemaOptions.js +6 -0
  33. package/dist/statementSql.d.ts +2 -1
  34. package/dist/statementSql.js +10 -4
  35. package/dist/syntax.d.ts +54 -0
  36. package/dist/syntax.js +131 -1
  37. package/dist/transform.d.ts +11 -2
  38. package/dist/transform.js +174 -165
  39. package/dist/tsconfig.d.ts +3 -9
  40. package/dist/tsconfig.js +18 -22
  41. package/package.json +1 -1
  42. package/dist/fieldType.d.ts +0 -13
  43. package/dist/fieldType.js +0 -56
@@ -0,0 +1,240 @@
1
+ import ts from 'typescript';
2
+ import { original } from './edits.js';
3
+ import { memberKey } from './keyMaps.js';
4
+ import { arrayElements, decoratorName, decoratorsOf, indentOf, indentUnit, lineStartOf, namedProperty, objectLiteral, } from './syntax.js';
5
+ /** The class decorators `@Entity`'s options took over, each with the option its entries go under. */
6
+ const FOLDED = { Index: 'indexes', Trigger: 'triggers', Filter: 'filters' };
7
+ const isFolded = (name) => name !== undefined && Object.hasOwn(FOLDED, name);
8
+ const slotEntries = (entries) => new Map([...entries].map(([option, { before, after }]) => [option, [...before, ...after]]));
9
+ /**
10
+ * Folds `@Index`, `@Trigger` and `@Filter` into the class's `@Entity` options, `indexes`, `triggers` and `filters`.
11
+ * Triggers keep the order they register in, which is the order they run in: decorators apply bottom-up, so those
12
+ * below `@Entity` go ahead of its own and those above after them. Indexes and filters, whose order means nothing,
13
+ * follow `@Entity`'s own in the order written. A class with no `@Entity` becomes `@Entity({ abstract: true, ... })`.
14
+ */
15
+ export function foldClassDecorators(node, ctx) {
16
+ const decorators = decoratorsOf(node);
17
+ const unit = indentUnit(node.getSourceFile());
18
+ const entity = decorators.find((decorator) => decoratorName(decorator) === 'Entity');
19
+ const at = entity ? decorators.indexOf(entity) : -1;
20
+ const folded = decorators.flatMap((decorator, index) => {
21
+ const name = decoratorName(decorator);
22
+ return isFolded(name) && ts.isCallExpression(decorator.expression)
23
+ ? [{ decorator, call: decorator.expression, name, index }]
24
+ : [];
25
+ });
26
+ if (!folded.length) {
27
+ return;
28
+ }
29
+ const call = entity && entityCall(entity, ctx);
30
+ const options = call && entityOptions(call, ctx);
31
+ if (call === null || options === null) {
32
+ return;
33
+ }
34
+ const triggers = folded.filter(({ name }) => name === 'Trigger');
35
+ const applied = (above) => triggers.filter(({ index }) => index < at === above).toReversed();
36
+ const before = applied(false);
37
+ const after = [...applied(true), ...folded.filter(({ name }) => name !== 'Trigger')];
38
+ const entries = new Map();
39
+ for (const [list, side] of [
40
+ [before, 'before'],
41
+ [after, 'after'],
42
+ ]) {
43
+ for (const { call, name, decorator } of list) {
44
+ const written = entriesOf(name, call, decorator, ctx);
45
+ if (!written) {
46
+ return;
47
+ }
48
+ const comments = commentTexts(decorator);
49
+ const slot = entries.get(FOLDED[name]) ?? { before: [], after: [] };
50
+ slot[side].push(...written.map((parts, index) => ({ parts, comments: index ? [] : comments })));
51
+ entries.set(FOLDED[name], slot);
52
+ }
53
+ }
54
+ for (const { decorator, name } of folded) {
55
+ ctx.edits.push(removedDecorator(decorator));
56
+ ctx.rewritten[name] += 1;
57
+ }
58
+ if (!call) {
59
+ const first = removedDecorator(folded[0].decorator);
60
+ const indent = indentOf(folded[0].decorator);
61
+ const text = ['@Entity({ abstract: true, ', ...optionParts(slotEntries(entries), indent, unit), ' })'];
62
+ const onItsLine = node.getSourceFile().text.slice(first.start, first.end).endsWith('\n');
63
+ ctx.edits.push({
64
+ start: first.start,
65
+ end: first.start,
66
+ text: onItsLine ? [indent, ...text, '\n'] : [...text, ' '],
67
+ });
68
+ ctx.imports.set('Entity', 'the abstract base its class decorators moved into');
69
+ return;
70
+ }
71
+ ctx.edits.push(...entityEdits(call, options, entries, unit));
72
+ }
73
+ /** `@Entity`'s call, or `null`, reported, where it is written uncalled and so takes no options. */
74
+ function entityCall(entity, ctx) {
75
+ if (ts.isCallExpression(entity.expression)) {
76
+ return entity.expression;
77
+ }
78
+ ctx.report(entity, "call '@Entity()' to move '@Index', '@Trigger' and '@Filter' into its options");
79
+ return null;
80
+ }
81
+ /** `@Entity`'s options literal, `undefined` where it takes none, or `null`, reported, where it cannot be read. */
82
+ function entityOptions(call, ctx) {
83
+ const [first] = call.arguments;
84
+ if (!first) {
85
+ return undefined;
86
+ }
87
+ const literal = objectLiteral(first);
88
+ if (!literal) {
89
+ ctx.report(call, "move '@Index', '@Trigger' and '@Filter' into '@Entity' options this cannot read");
90
+ return null;
91
+ }
92
+ return literal;
93
+ }
94
+ /** The entries one decorator registers, or nothing, reported, where it cannot say them as an option. */
95
+ function entriesOf(name, call, decorator, ctx) {
96
+ const [first, second] = call.arguments;
97
+ if (name === 'Trigger') {
98
+ return call.arguments.map((trigger) => [original(trigger)]);
99
+ }
100
+ if (name === 'Index') {
101
+ if (!first) {
102
+ ctx.report(decorator, "move this '@Index' into '@Entity({ indexes })' by hand: it names no columns");
103
+ return undefined;
104
+ }
105
+ const props = objectLiteral(second);
106
+ const rest = props
107
+ ? props.properties.flatMap((prop) => [', ', original(prop)])
108
+ : second
109
+ ? [', ...', original(second)]
110
+ : [];
111
+ return [['{ columns: ', original(first), ...rest, ' }']];
112
+ }
113
+ if (!first || !ts.isStringLiteralLike(first) || !second) {
114
+ ctx.report(decorator, "move this '@Filter' into '@Entity({ filters })' by hand: its name is not a string");
115
+ return undefined;
116
+ }
117
+ return [[memberKey(first.text), ': ', original(second)]];
118
+ }
119
+ /** Each option's entries as comma-separated properties, a list holding a comment one entry a line. */
120
+ function optionParts(entries, indent, unit) {
121
+ return [...entries].flatMap(([option, list], index) => [
122
+ ...(index ? [', '] : []),
123
+ ...bracketed(option, list, indent, unit),
124
+ ]);
125
+ }
126
+ function bracketed(option, list, indent, unit) {
127
+ const [open, close] = option === 'filters' ? ['{', '}'] : ['[', ']'];
128
+ if (list.length < 2 && !list.some(({ comments }) => comments.length)) {
129
+ const pad = option === 'filters' ? ' ' : '';
130
+ return [`${option}: ${open}${pad}`, ...joined(list, ', '), `${pad}${close}`];
131
+ }
132
+ const inner = `${indent}${unit}`;
133
+ return [`${option}: ${open}\n`, ...lines(list, inner), `${indent}${close}`];
134
+ }
135
+ function joined(list, separator) {
136
+ return list.flatMap(({ parts }, index) => (index ? [separator, ...parts] : [...parts]));
137
+ }
138
+ /** Each entry on its own line under `indent`, its comments on the lines above it. */
139
+ function lines(list, indent) {
140
+ return list.flatMap(({ parts, comments }) => [
141
+ ...comments.flatMap((comment) => [indent, comment, '\n']),
142
+ indent,
143
+ ...parts,
144
+ ',\n',
145
+ ]);
146
+ }
147
+ /**
148
+ * The line comments directly above `decorator`, which say something of it: a run with no blank line before the
149
+ * decorator. A doc comment, or one a blank line parts from it, is the class's and stays where it is.
150
+ */
151
+ function decoratorComments(decorator) {
152
+ const text = decorator.getSourceFile().text;
153
+ const ranges = ts.getLeadingCommentRanges(text, decorator.pos) ?? [];
154
+ const kept = [];
155
+ let next = decorator.getStart();
156
+ for (const range of ranges.toReversed()) {
157
+ const between = text.slice(range.end, next);
158
+ if (range.kind !== ts.SyntaxKind.SingleLineCommentTrivia || /\n\s*\n/.test(between))
159
+ break;
160
+ kept.unshift(range);
161
+ next = range.pos;
162
+ }
163
+ return kept;
164
+ }
165
+ /** A comment closing the decorator's line, which says something of it too. */
166
+ function trailingComments(decorator) {
167
+ const text = decorator.getSourceFile().text;
168
+ return (ts.getTrailingCommentRanges(text, decorator.getEnd()) ?? []).filter(({ kind }) => kind === ts.SyntaxKind.SingleLineCommentTrivia);
169
+ }
170
+ /** The comments the decorator's entry carries: those above it, then the one closing its line. */
171
+ function commentTexts(decorator) {
172
+ const text = decorator.getSourceFile().text;
173
+ return [...decoratorComments(decorator), ...trailingComments(decorator)].map(({ pos, end }) => text.slice(pos, end));
174
+ }
175
+ /** The edits writing `entries` into `@Entity`'s options, each option's own list merged where it states one. */
176
+ function entityEdits(call, options, entries, unit) {
177
+ const indent = indentOf(call);
178
+ if (!options) {
179
+ const at = call.getEnd() - 1;
180
+ return [{ start: at, end: at, text: ['{ ', ...optionParts(slotEntries(entries), indent, unit), ' }'] }];
181
+ }
182
+ const edits = [];
183
+ const added = new Map();
184
+ for (const [option, slot] of entries) {
185
+ const stated = namedProperty(options, option);
186
+ const value = stated && ts.isPropertyAssignment(stated) ? stated.initializer : undefined;
187
+ const list = value && (ts.isArrayLiteralExpression(value) ? arrayElements(value) : objectLiteral(value)?.properties);
188
+ if (!value || !list) {
189
+ added.set(option, [...slot.before, ...slot.after]);
190
+ continue;
191
+ }
192
+ const open = value.getStart() + 1;
193
+ const close = value.getEnd() - 1;
194
+ const inner = `${indentOf(value)}${unit}`;
195
+ const commented = (side) => side.some(({ comments }) => comments.length);
196
+ if (slot.before.length) {
197
+ const text = commented(slot.before)
198
+ ? ['\n', ...lines(slot.before, inner), inner]
199
+ : [...joined(slot.before, ', '), ...(list.length ? [', '] : [])];
200
+ edits.push({ start: open, end: open, text });
201
+ }
202
+ if (slot.after.length) {
203
+ const end = list.length ? list[list.length - 1].getEnd() : close;
204
+ const lead = list.length || slot.before.length ? ', ' : '';
205
+ const text = commented(slot.after)
206
+ ? slot.after.flatMap(({ parts, comments }, index) => [
207
+ index || lead ? ',\n' : '\n',
208
+ ...comments.flatMap((comment) => [inner, comment, '\n']),
209
+ inner,
210
+ ...parts,
211
+ ])
212
+ : [lead, ...joined(slot.after, ', ')];
213
+ edits.push({ start: end, end, text });
214
+ }
215
+ }
216
+ if (added.size) {
217
+ const last = options.properties.at(-1);
218
+ const at = last ? last.getEnd() : options.getEnd() - 1;
219
+ const parts = optionParts(added, indent, unit);
220
+ edits.push({ start: at, end: at, text: last ? [', ', ...parts] : [' ', ...parts, ' '] });
221
+ }
222
+ return edits;
223
+ }
224
+ /** Removes a decorator, with its line where it stands alone on one, or the blank after it where it shares one. */
225
+ function removedDecorator(decorator) {
226
+ const text = decorator.getSourceFile().text;
227
+ const start = decoratorComments(decorator)[0]?.pos ?? decorator.getStart();
228
+ const end = trailingComments(decorator).at(-1)?.end ?? decorator.getEnd();
229
+ const lineStart = lineStartOf(text, start);
230
+ const lineEnd = text.indexOf('\n', end);
231
+ // A class follows its decorators, so the line of one standing alone ends before the file does.
232
+ const alone = text.slice(lineStart, start).trim() === '' && text.slice(end, lineEnd).trim() === '';
233
+ if (alone) {
234
+ return { start: lineStart, end: lineEnd + 1, text: '' };
235
+ }
236
+ const after = /\s*/y;
237
+ after.lastIndex = end;
238
+ after.exec(text);
239
+ return { start, end: after.lastIndex, text: '' };
240
+ }
@@ -0,0 +1,11 @@
1
+ /** How an option's value becomes a column method, or `undefined` where it cannot be written as one. */
2
+ export type OptionMethod<R extends string | undefined = string> = readonly [
3
+ option: string,
4
+ method: (value: string) => R
5
+ ];
6
+ /** The option's value passed to `method`: `.length(80)`. */
7
+ export declare const call: (method: string) => (value: string) => string;
8
+ /** A flag as its method where it is `true`, nothing where `false`, and `undefined` where no literal says. */
9
+ export declare const flag: (method: string) => (value: string) => string | undefined;
10
+ /** {@link flag}, its value passed to the method where it is no literal. */
11
+ export declare const flagOrCall: (method: string) => (value: string) => string;
@@ -0,0 +1,6 @@
1
+ /** The option's value passed to `method`: `.length(80)`. */
2
+ export const call = (method) => (value) => `.${method}(${value})`;
3
+ /** A flag as its method where it is `true`, nothing where `false`, and `undefined` where no literal says. */
4
+ export const flag = (method) => (value) => value === 'true' ? `.${method}()` : value === 'false' ? '' : undefined;
5
+ /** {@link flag}, its value passed to the method where it is no literal. */
6
+ export const flagOrCall = (method) => (value) => flag(method)(value) ?? call(method)(value);
@@ -1,10 +1,10 @@
1
1
  import ts from 'typescript';
2
2
  import { constructedType, memberNames, ownerOf, propertyNames } from './context.js';
3
3
  import { inserted, replaced } from './edits.js';
4
- import { isRaw } from './entitySql.js';
4
+ import { isSqlCall } from './entitySql.js';
5
5
  import { uqlImport, usesOf } from './imports.js';
6
6
  import { memberAccess } from './keyMaps.js';
7
- import { identifierText, propertyKey, propertyValue } from './syntax.js';
7
+ import { identifierText, mentions, propertyKey, propertyValue } from './syntax.js';
8
8
  /**
9
9
  * Rewrites each `col('x')` into the ref it names - `product.x` in a `computed` callback, `refs(Item).x` in a
10
10
  * statement - where the entity its SQL renders against, and a member named `x`, can be told from here. Every
@@ -19,7 +19,7 @@ export function rewriteColumnRefs(ctx) {
19
19
  const unread = [];
20
20
  for (const use of usesOf(ctx.source, element.name, ctx.checker)) {
21
21
  const call = ts.isCallExpression(use.parent) && use.parent.expression === use ? use.parent : undefined;
22
- const sql = call && ts.findAncestor(call, isRaw);
22
+ const sql = call && ts.findAncestor(call, (node) => isSqlCall(node, ctx.checker));
23
23
  const option = sql?.parent;
24
24
  const owner = option && ts.isPropertyAssignment(option) && isComputedOption(option) ? computedOwner(option) : undefined;
25
25
  const edit = call && !owner ? statementRefEdit(call, ctx) : undefined;
@@ -85,13 +85,6 @@ function columnMember(call, members) {
85
85
  const [name] = call.arguments;
86
86
  return call.arguments.length === 1 && ts.isStringLiteralLike(name) && members.has(name.text) ? name.text : undefined;
87
87
  }
88
- /** Whether `node` reads a binding named `name`, which a callback parameter of that name would shadow. */
89
- function mentions(node, name) {
90
- if (ts.isIdentifier(node)) {
91
- return node.text === name && !(ts.isPropertyAccessExpression(node.parent) && node.parent.name === node);
92
- }
93
- return ts.forEachChild(node, (child) => mentions(child, name) || undefined) ?? false;
94
- }
95
88
  /**
96
89
  * The entity a statement's SQL renders against at `node`: the one its query names, or a relation a `$populate`
97
90
  * reads there. Nothing where a function or a relation filter in between opens a scope of its own.
package/dist/context.d.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  import ts from 'typescript';
2
2
  import type { Edit } from './edits.js';
3
+ /**
4
+ * Each name whose import goes once every use was rewritten: `expr` defaults, and the class decorators folded into
5
+ * `@Entity`.
6
+ */
7
+ export declare const REWRITTEN: readonly ["expr", "Index", "Trigger", "Filter"];
8
+ type Rewritten = (typeof REWRITTEN)[number];
3
9
  /** What a rewrite of one file reads and writes. */
4
10
  export type Context = {
5
11
  readonly source: ts.SourceFile;
@@ -11,16 +17,22 @@ export type Context = {
11
17
  readonly notes: string[];
12
18
  /** Names written that the file has to import from `uql-orm`, each with what it was written for. */
13
19
  readonly imports: Map<string, string>;
14
- /** Uses rewritten of each name whose import goes once every use was: `Relation<T>` unwrapped, `expr` defaults. */
15
- readonly rewritten: {
16
- Relation: number;
17
- expr: number;
18
- };
20
+ /** Uses rewritten of each of `REWRITTEN`. */
21
+ readonly rewritten: Record<Rewritten, number>;
19
22
  /** Records what needs a human, at `node`. */
20
23
  readonly report: (node: ts.Node, message: string) => void;
21
24
  /** Records a rewrite that is correct but worth a second look, at `node`. */
22
25
  readonly note: (node: ts.Node, message: string) => void;
23
26
  };
27
+ /** `file:line` of offset `at` in `source`. */
28
+ export declare function locationAt(source: ts.SourceFile, at: number): string;
29
+ /** A finding before it is located: the node it is about, and what to do. */
30
+ export type Finding = {
31
+ readonly node: ts.Node;
32
+ readonly message: string;
33
+ };
34
+ /** Findings in source order, each as `file:line: message`, the line `locate` reads its offset at. */
35
+ export declare function formatFindings(findings: readonly Finding[], locate: (at: number) => string): string[];
24
36
  export declare function createContext(source: ts.SourceFile, checker: ts.TypeChecker, strictNullChecks: boolean): Context;
25
37
  /** The entity a definition describes: the parameter its callbacks are named after, and the node naming it. */
26
38
  export type Owner = {
@@ -36,3 +48,4 @@ export declare function instanceTypeOf(node: ts.Node, checker: ts.TypeChecker):
36
48
  export declare function propertyNames(type: ts.Type): ReadonlySet<string>;
37
49
  /** The names of the members of the entity `node` declares or names. */
38
50
  export declare function memberNames(node: ts.Node, checker: ts.TypeChecker): ReadonlySet<string>;
51
+ export {};
package/dist/context.js CHANGED
@@ -1,10 +1,28 @@
1
1
  import ts from 'typescript';
2
2
  import { paramFor } from './keyMaps.js';
3
3
  import { identifierText } from './syntax.js';
4
+ /**
5
+ * Each name whose import goes once every use was rewritten: `expr` defaults, and the class decorators folded into
6
+ * `@Entity`.
7
+ */
8
+ export const REWRITTEN = ['expr', 'Index', 'Trigger', 'Filter'];
9
+ /** Where `node` is, as a finding names it: `file:line`. */
10
+ function locationOf(node) {
11
+ return locationAt(node.getSourceFile(), node.getStart());
12
+ }
13
+ /** `file:line` of offset `at` in `source`. */
14
+ export function locationAt(source, at) {
15
+ return `${source.fileName}:${source.getLineAndCharacterOfPosition(at).line + 1}`;
16
+ }
17
+ /** Findings in source order, each as `file:line: message`, the line `locate` reads its offset at. */
18
+ export function formatFindings(findings, locate) {
19
+ return findings
20
+ .toSorted((a, b) => a.node.getStart() - b.node.getStart())
21
+ .map(({ node, message }) => `${locate(node.getStart())}: ${message}`);
22
+ }
4
23
  export function createContext(source, checker, strictNullChecks) {
5
24
  const unresolved = [];
6
25
  const notes = [];
7
- const at = (node) => `${source.fileName}:${source.getLineAndCharacterOfPosition(node.getStart()).line + 1}`;
8
26
  return {
9
27
  source,
10
28
  checker,
@@ -13,12 +31,12 @@ export function createContext(source, checker, strictNullChecks) {
13
31
  unresolved,
14
32
  notes,
15
33
  imports: new Map(),
16
- rewritten: { Relation: 0, expr: 0 },
34
+ rewritten: { expr: 0, Index: 0, Trigger: 0, Filter: 0 },
17
35
  report: (node, message) => {
18
- unresolved.push(`${at(node)}: ${message}`);
36
+ unresolved.push(`${locationOf(node)}: ${message}`);
19
37
  },
20
38
  note: (node, message) => {
21
- notes.push(`${at(node)}: ${message}`);
39
+ notes.push(`${locationOf(node)}: ${message}`);
22
40
  },
23
41
  };
24
42
  }
@@ -0,0 +1,24 @@
1
+ import ts from 'typescript';
2
+ /** The definers a class was registered with, each with the decorator that registers the same on a decorated class. */
3
+ export declare const CLASS_DEFINERS: {
4
+ readonly defineEntity: "@Entity";
5
+ readonly defineField: "@Field";
6
+ readonly defineId: "@Id";
7
+ readonly defineRelation: "a relation decorator";
8
+ readonly defineHook: "a hook decorator";
9
+ readonly defineIndex: "@Entity's 'indexes'";
10
+ readonly defineTrigger: "@Entity's 'triggers'";
11
+ readonly defineFilter: "@Entity's 'filters'";
12
+ };
13
+ export type DefinerName = keyof typeof CLASS_DEFINERS;
14
+ /** A class-form definer's arguments by what each is: `defineX(Entity, key?, options)`, every one after the entity in `rest`. */
15
+ export type DefinerCall = {
16
+ readonly call: ts.CallExpression;
17
+ readonly definer: DefinerName;
18
+ readonly entity: ts.Expression;
19
+ readonly key: string | undefined;
20
+ readonly options: ts.Expression | undefined;
21
+ readonly rest: readonly ts.Expression[];
22
+ };
23
+ /** `call` read as a class-form definer, or nothing where it is another call, the schema form included. */
24
+ export declare function definerCall(call: ts.CallExpression): DefinerCall | undefined;
@@ -0,0 +1,34 @@
1
+ import ts from 'typescript';
2
+ import { identifierText } from './syntax.js';
3
+ /** The definers a class was registered with, each with the decorator that registers the same on a decorated class. */
4
+ export const CLASS_DEFINERS = {
5
+ defineEntity: '@Entity',
6
+ defineField: '@Field',
7
+ defineId: '@Id',
8
+ defineRelation: 'a relation decorator',
9
+ defineHook: 'a hook decorator',
10
+ defineIndex: "@Entity's 'indexes'",
11
+ defineTrigger: "@Entity's 'triggers'",
12
+ defineFilter: "@Entity's 'filters'",
13
+ };
14
+ const isDefinerName = (name) => name !== undefined && Object.hasOwn(CLASS_DEFINERS, name);
15
+ /** The definers naming a member or a filter after the entity, their options after that. */
16
+ const KEYED_DEFINERS = new Set([
17
+ 'defineField',
18
+ 'defineId',
19
+ 'defineRelation',
20
+ 'defineHook',
21
+ 'defineFilter',
22
+ ]);
23
+ /** `call` read as a class-form definer, or nothing where it is another call, the schema form included. */
24
+ export function definerCall(call) {
25
+ const definer = identifierText(call.expression);
26
+ const [entity, ...rest] = call.arguments;
27
+ if (!isDefinerName(definer) || !entity || ts.isObjectLiteralExpression(entity)) {
28
+ return undefined;
29
+ }
30
+ const [second, third] = rest;
31
+ const keyed = KEYED_DEFINERS.has(definer);
32
+ const key = keyed && second && ts.isStringLiteralLike(second) ? second.text : undefined;
33
+ return { call, definer, entity, key, options: keyed ? third : second, rest };
34
+ }
package/dist/edits.d.ts CHANGED
@@ -1,24 +1,56 @@
1
- import type ts from 'typescript';
1
+ import ts from 'typescript';
2
+ /** A stretch of the original text, which a replacement can keep. */
3
+ export type Span = {
4
+ readonly start: number;
5
+ readonly end: number;
6
+ };
7
+ /** A piece of a replacement: text written, or a stretch of the original kept. */
8
+ export type Part = string | Span;
9
+ /** What replaces a range: text, or text with stretches of the original in it, edits inside them included. */
10
+ export type Replacement = string | readonly Part[];
2
11
  /** One text replacement in a file. */
3
12
  export type Edit = {
4
13
  readonly start: number;
5
14
  readonly end: number;
15
+ readonly text: Replacement;
16
+ };
17
+ /** The text of `node` as written, for a replacement to keep. */
18
+ export declare function original(node: ts.Node): Span;
19
+ /**
20
+ * Applies edits to `source`, which is spliced rather than reprinted: a codemod that reformats everything it
21
+ * touches buries its own change in the diff. An edit inside a range another replaces is moot, unless that
22
+ * replacement keeps the stretch it sits in; edits that overlap without one holding the other are a bug.
23
+ */
24
+ export declare function applyEdits(source: string, edits: readonly Edit[]): string;
25
+ /** A text a pass wrote, with where each of its offsets was in the text the pass read. */
26
+ export type Pass = {
6
27
  readonly text: string;
28
+ readonly sourceOffset: (at: number) => number;
7
29
  };
30
+ /** `text` as a pass that changed nothing. */
31
+ export declare const unedited: (text: string) => Pass;
32
+ /** `second` run on what `first` wrote, each offset of its text mapped back to what `first` read. */
33
+ export declare function followed(first: Pass, second: Pass): Pass;
34
+ /** `pass` edited again by the edits `edits` reads off its text, parsed. */
35
+ export declare function reparsedAgain(fileName: string, pass: Pass, edits: (source: ts.SourceFile) => readonly Edit[]): Pass;
8
36
  /**
9
- * Applies edits back to front, so none shifts the offsets of another, and at a shared offset the replacement
10
- * first, so an insertion there lands before it. Splicing rather than reprinting keeps the rest of the file
11
- * byte for byte: a codemod that reformats everything it touches buries its own change in the diff.
37
+ * {@link applyEdits}, with where each offset of the result was in `source`, for a later pass reading the result to
38
+ * report against the file as written: an offset inside an edit's text maps to where the edit starts.
12
39
  */
13
- export declare function applyEdits(text: string, edits: readonly Edit[]): string;
14
- export declare function replaced(node: ts.Node, text: string): Edit;
40
+ export declare function editedText(source: string, edits: readonly Edit[]): Pass;
41
+ export declare function replaced(node: ts.Node, text: Replacement): Edit;
15
42
  export declare function inserted(node: ts.Node, text: string): Edit;
16
43
  export declare function appended(node: ts.Node, text: string): Edit;
44
+ /** `node`'s text with each of `uses`, the nodes inside it, written as `text`. */
45
+ export declare function replacingUses(node: ts.Node, uses: readonly ts.Node[], text: string): string;
46
+ /**
47
+ * Removes elements of a comma-separated list, at least one of them kept, with the separators that would dangle.
48
+ * Each removed element takes the text up to the next item; the removed run at the end takes the separator before
49
+ * it, so the ranges never overlap. `source` is passed because a tree from `ts.parseJsonText` has no `getStart()`.
50
+ */
51
+ export declare function removeFromList(items: readonly ts.Node[], removed: readonly ts.Node[], source: ts.SourceFile): readonly Edit[];
17
52
  /**
18
- * Removes one element of a comma-separated list with the separator that would dangle: the comma before the
19
- * last element, the one after any other. `source` is passed because a tree from `ts.parseJsonText` carries
20
- * no back-reference for `getStart()` to find.
53
+ * Removes a statement with the blanks before it on its line, the rest of its line and the blank lines after it,
54
+ * so nothing is left blank behind it.
21
55
  */
22
- export declare function removeFromList(items: readonly ts.Node[], item: ts.Node, source: ts.SourceFile): Edit;
23
- /** Removes a statement together with the rest of its line, so nothing is left blank behind it. */
24
56
  export declare function removeStatement(node: ts.Node): Edit;