uql-codemod 0.48.2 → 0.50.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.
@@ -0,0 +1,9 @@
1
+ import ts from 'typescript';
2
+ import type { Context } from './context.js';
3
+ /**
4
+ * Folds `@Index`, `@Trigger` and `@Filter` into the class's `@Entity` options, `indexes`, `triggers` and `filters`.
5
+ * Triggers keep the order they register in, which is the order they run in: decorators apply bottom-up, so those
6
+ * below `@Entity` go ahead of its own and those above after them. Indexes and filters, whose order means nothing,
7
+ * follow `@Entity`'s own in the order written. A class with no `@Entity` becomes `@Entity({ abstract: true, ... })`.
8
+ */
9
+ export declare function foldClassDecorators(node: ts.ClassLikeDeclaration, ctx: Context): void;
@@ -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);
@@ -4,7 +4,7 @@ import { inserted, replaced } from './edits.js';
4
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
@@ -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,4 +1,4 @@
1
- import type ts from 'typescript';
1
+ import ts from 'typescript';
2
2
  /** A stretch of the original text, which a replacement can keep. */
3
3
  export type Span = {
4
4
  readonly start: number;
@@ -22,14 +22,35 @@ export declare function original(node: ts.Node): Span;
22
22
  * replacement keeps the stretch it sits in; edits that overlap without one holding the other are a bug.
23
23
  */
24
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 = {
27
+ readonly text: string;
28
+ readonly sourceOffset: (at: number) => number;
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;
36
+ /**
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.
39
+ */
40
+ export declare function editedText(source: string, edits: readonly Edit[]): Pass;
25
41
  export declare function replaced(node: ts.Node, text: Replacement): Edit;
26
42
  export declare function inserted(node: ts.Node, text: string): Edit;
27
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;
28
46
  /**
29
- * Removes elements of a comma-separated list with the separators that would dangle. Each removed element takes
30
- * the text up to the next item; the removed run at the end takes the separator before it, so the ranges never
31
- * overlap. `source` is passed because a tree from `ts.parseJsonText` carries no back-reference for `getStart()`.
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()`.
32
50
  */
33
51
  export declare function removeFromList(items: readonly ts.Node[], removed: readonly ts.Node[], source: ts.SourceFile): readonly Edit[];
34
- /** Removes a statement together with the rest of its line, so nothing is left blank behind it. */
52
+ /**
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.
55
+ */
35
56
  export declare function removeStatement(node: ts.Node): Edit;
package/dist/edits.js CHANGED
@@ -1,3 +1,5 @@
1
+ import ts from 'typescript';
2
+ import { lineStartOf } from './syntax.js';
1
3
  /** The text of `node` as written, for a replacement to keep. */
2
4
  export function original(node) {
3
5
  return { start: node.getStart(), end: node.getEnd() };
@@ -9,20 +11,49 @@ const isInsertion = (edit) => edit.start === edit.end;
9
11
  * replacement keeps the stretch it sits in; edits that overlap without one holding the other are a bug.
10
12
  */
11
13
  export function applyEdits(source, edits) {
14
+ return editedText(source, edits).text;
15
+ }
16
+ /** `text` as a pass that changed nothing. */
17
+ export const unedited = (text) => ({ text, sourceOffset: (at) => at });
18
+ /** `second` run on what `first` wrote, each offset of its text mapped back to what `first` read. */
19
+ export function followed(first, second) {
20
+ return { text: second.text, sourceOffset: (at) => first.sourceOffset(second.sourceOffset(at)) };
21
+ }
22
+ /** `pass` edited again by `edits`, which read its text. */
23
+ function editedAgain(pass, edits) {
24
+ return followed(pass, editedText(pass.text, edits));
25
+ }
26
+ /** `pass` edited again by the edits `edits` reads off its text, parsed. */
27
+ export function reparsedAgain(fileName, pass, edits) {
28
+ return editedAgain(pass, edits(ts.createSourceFile(fileName, pass.text, ts.ScriptTarget.Latest, true)));
29
+ }
30
+ /**
31
+ * {@link applyEdits}, with where each offset of the result was in `source`, for a later pass reading the result to
32
+ * report against the file as written: an offset inside an edit's text maps to where the edit starts.
33
+ */
34
+ export function editedText(source, edits) {
35
+ const moved = [];
12
36
  const ordered = edits
13
37
  .map((edit, index) => ({ edit, index }))
14
38
  .sort((a, b) => a.edit.start - b.edit.start ||
15
39
  Number(!isInsertion(a.edit)) - Number(!isInsertion(b.edit)) ||
16
40
  (isInsertion(a.edit) ? b.index - a.index : b.edit.end - a.edit.end))
17
41
  .map(({ edit }) => edit);
18
- return render(source, ordered, { start: 0, end: source.length });
42
+ const text = render(source, ordered, { start: 0, end: source.length }, undefined, moved);
43
+ const sourceOffset = (at) => {
44
+ const before = moved.findLast((edit) => edit.from <= at);
45
+ if (!before)
46
+ return at;
47
+ return at < before.to ? before.start : before.end + at - before.to;
48
+ };
49
+ return { text, sourceOffset };
19
50
  }
20
51
  /**
21
52
  * `range` of `source` with `edits` (ordered outermost first, an insertion before a replacement at its offset, a
22
53
  * later insertion before an earlier) applied. `parent` is the edit keeping `range`: an insertion at its edges
23
54
  * is written beside it, so it is not written again inside.
24
55
  */
25
- function render(source, edits, range, parent) {
56
+ function render(source, edits, range, parent, moved) {
26
57
  const within = (edit) => edit !== parent &&
27
58
  range.start <= edit.start &&
28
59
  edit.end <= range.end &&
@@ -39,7 +70,9 @@ function render(source, edits, range, parent) {
39
70
  }
40
71
  const parts = typeof edit.text === 'string' ? [edit.text] : edit.text;
41
72
  written += source.slice(at, edit.start);
73
+ const from = written.length;
42
74
  written += parts.map((part) => (typeof part === 'string' ? part : render(source, inside, part, edit))).join('');
75
+ moved?.push({ start: edit.start, end: edit.end, from, to: written.length });
43
76
  at = edit.end;
44
77
  }
45
78
  return written + source.slice(at, range.end);
@@ -53,10 +86,14 @@ export function inserted(node, text) {
53
86
  export function appended(node, text) {
54
87
  return { start: node.getEnd(), end: node.getEnd(), text };
55
88
  }
89
+ /** `node`'s text with each of `uses`, the nodes inside it, written as `text`. */
90
+ export function replacingUses(node, uses, text) {
91
+ return applyEdits(node.getText(), uses.map((use) => ({ start: use.getStart() - node.getStart(), end: use.getEnd() - node.getStart(), text })));
92
+ }
56
93
  /**
57
- * Removes elements of a comma-separated list with the separators that would dangle. Each removed element takes
58
- * the text up to the next item; the removed run at the end takes the separator before it, so the ranges never
59
- * overlap. `source` is passed because a tree from `ts.parseJsonText` carries no back-reference for `getStart()`.
94
+ * Removes elements of a comma-separated list, at least one of them kept, with the separators that would dangle.
95
+ * Each removed element takes the text up to the next item; the removed run at the end takes the separator before
96
+ * it, so the ranges never overlap. `source` is passed because a tree from `ts.parseJsonText` has no `getStart()`.
60
97
  */
61
98
  export function removeFromList(items, removed, source) {
62
99
  const kept = items.map((item) => !removed.includes(item));
@@ -68,13 +105,18 @@ export function removeFromList(items, removed, source) {
68
105
  if (!trailing.length) {
69
106
  return between;
70
107
  }
71
- const from = lastKept < 0 ? trailing[0].getStart(source) : items[lastKept].getEnd();
72
- return [...between, { start: from, end: items[items.length - 1].getEnd(), text: '' }];
108
+ return [...between, { start: items[lastKept].getEnd(), end: items[items.length - 1].getEnd(), text: '' }];
73
109
  }
74
- /** Removes a statement together with the rest of its line, so nothing is left blank behind it. */
110
+ /**
111
+ * Removes a statement with the blanks before it on its line, the rest of its line and the blank lines after it,
112
+ * so nothing is left blank behind it.
113
+ */
75
114
  export function removeStatement(node) {
76
- const source = node.getSourceFile();
77
- const line = source.getLineAndCharacterOfPosition(node.getEnd()).line;
78
- const nextLineStart = source.getLineStarts()[line + 1];
79
- return { start: node.getStart(), end: nextLineStart ?? node.getEnd(), text: '' };
115
+ const text = node.getSourceFile().text;
116
+ const lineStart = lineStartOf(text, node.getStart());
117
+ const head = text.slice(lineStart, node.getStart());
118
+ const after = /[^\n]*\n?(?:[ \t]*\n)*/y;
119
+ after.lastIndex = node.getEnd();
120
+ after.exec(text);
121
+ return { start: lineStart + head.replace(/[ \t]*$/, '').length, end: after.lastIndex, text: '' };
80
122
  }
package/dist/entitySql.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import ts from 'typescript';
2
2
  import { inserted, replaced } from './edits.js';
3
3
  import { isSqlExport, uqlExport } from './imports.js';
4
- import { propertyValue } from './syntax.js';
4
+ import { columnListEntries, propertyValue } from './syntax.js';
5
5
  export function isSqlCall(node, checker) {
6
6
  const callee = ts.isTaggedTemplateExpression(node)
7
7
  ? node.tag
@@ -12,9 +12,7 @@ export function isSqlCall(node, checker) {
12
12
  }
13
13
  /** The expressions of a column list, written as one or returned by its callback: each entry or `column` that is `sql`. */
14
14
  export function columnExpressions(list, checker) {
15
- const body = list && ts.isArrowFunction(list) ? list.body : list;
16
- const entries = body && ts.isArrayLiteralExpression(body) ? body.elements : [];
17
- return entries.flatMap((entry) => {
15
+ return columnListEntries(list).flatMap((entry) => {
18
16
  const sql = ts.isObjectLiteralExpression(entry) ? propertyValue(entry, 'column') : entry;
19
17
  return sql && isSqlCall(sql, checker) ? [sql] : [];
20
18
  });