@syncular/typegen 0.5.0 → 0.6.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 (47) hide show
  1. package/README.md +75 -33
  2. package/dist/cli.js +43 -17
  3. package/dist/emit-queries-dart.js +167 -55
  4. package/dist/emit-queries-kotlin.js +166 -55
  5. package/dist/emit-queries-swift.js +182 -57
  6. package/dist/emit-queries.js +289 -123
  7. package/dist/fmt.d.ts +2 -2
  8. package/dist/fmt.js +323 -316
  9. package/dist/generate.d.ts +1 -1
  10. package/dist/generate.js +28 -9
  11. package/dist/index.d.ts +3 -1
  12. package/dist/index.js +3 -1
  13. package/dist/lsp.d.ts +1 -3
  14. package/dist/lsp.js +359 -185
  15. package/dist/manifest.d.ts +1 -3
  16. package/dist/manifest.js +1 -1
  17. package/dist/query-ir.js +53 -42
  18. package/dist/query.d.ts +115 -63
  19. package/dist/query.js +60 -11
  20. package/dist/syql-lexer.d.ts +2 -0
  21. package/dist/syql-lexer.js +9 -2
  22. package/dist/syql-lowering.d.ts +22 -0
  23. package/dist/syql-lowering.js +411 -0
  24. package/dist/syql-semantics.d.ts +76 -0
  25. package/dist/syql-semantics.js +551 -0
  26. package/dist/syql-validator.d.ts +35 -0
  27. package/dist/syql-validator.js +966 -0
  28. package/package.json +3 -3
  29. package/src/cli.ts +51 -21
  30. package/src/emit-queries-dart.ts +195 -69
  31. package/src/emit-queries-kotlin.ts +197 -69
  32. package/src/emit-queries-swift.ts +224 -68
  33. package/src/emit-queries.ts +413 -165
  34. package/src/fmt.ts +377 -303
  35. package/src/generate.ts +43 -9
  36. package/src/index.ts +3 -1
  37. package/src/lsp.ts +425 -215
  38. package/src/manifest.ts +2 -4
  39. package/src/query-ir.ts +52 -42
  40. package/src/query.ts +199 -79
  41. package/src/syql-lexer.ts +12 -1
  42. package/src/syql-lowering.ts +638 -0
  43. package/src/syql-semantics.ts +859 -0
  44. package/src/syql-validator.ts +1492 -0
  45. package/dist/syql.d.ts +0 -102
  46. package/dist/syql.js +0 -1141
  47. package/src/syql.ts +0 -1470
package/src/fmt.ts CHANGED
@@ -1,137 +1,39 @@
1
- /**
2
- * `syncular fmt` — the canonical `.syql` formatter (DESIGN-queries.md §10,
3
- * §12). One style, no options:
1
+ /** Canonical revision-1 SYQL formatter (§19).
4
2
  *
5
- * - SQL keywords lowercase; identifier casing preserved (SQL-truth).
6
- * - one space after commas; collapsed whitespace elsewhere.
7
- * - one clause per line in the body; WHERE conjuncts one per line,
8
- * `and`-prefixed and indented under the `where`.
9
- * - declarations separated by one blank line; knobs on their own lines.
10
- * - comments are preserved: a declaration's leading comment block stays
11
- * above it; comments inside bodies stay on their own lines.
12
- *
13
- * The formatter is built on the same parser as the generator (`.syql` that
14
- * does not parse does not format), and formatting is semantics-preserving
15
- * by construction — `fmt` output re-parses to the same declarations.
3
+ * Formatting is a lossless-token rewrite: semantic/atomic tokens and comments
4
+ * keep their order and exact spelling, while trivia is regenerated. The pass
5
+ * parses both sides and refuses output unless their normalized semantic ASTs
6
+ * agree. This keeps the formatter on the same lexer/parser contract as codegen.
16
7
  */
17
8
  import { TypegenError } from './errors';
18
- import { parseSyqlFile, type SyqlQueryDecl } from './syql';
9
+ import { type SyqlSemanticFile, toSyqlSemanticAst } from './syql-ast';
10
+ import type { SyqlToken } from './syql-lexer';
11
+ import {
12
+ parseSyqlSyntaxFile,
13
+ type SyqlSyntaxFile,
14
+ type SyqlTemplate,
15
+ } from './syql-parser';
19
16
 
20
- /** SQL keywords the canon lowercases (a pragmatic core set — identifiers
21
- * that collide with these would need quoting anyway). */
22
17
  const SQL_KEYWORDS = new Set(
23
18
  (
24
- 'select from where group by having order limit offset window and or not ' +
25
- 'in is null like glob between exists case when then else end as on using ' +
26
- 'join left right full outer inner cross natural union all except ' +
27
- 'intersect distinct values with recursive asc desc collate cast'
28
- ).split(' '),
19
+ 'abort action add after all alter analyze and as asc attach autoincrement ' +
20
+ 'before begin between by cascade case cast check collate column commit ' +
21
+ 'conflict constraint create cross current current_date current_time ' +
22
+ 'current_timestamp database default deferrable deferred delete desc ' +
23
+ 'detach distinct do drop each else end escape except exclude exclusive ' +
24
+ 'exists explain fail filter first following for foreign from full ' +
25
+ 'generated glob group groups having if ignore immediate in index indexed ' +
26
+ 'initially inner insert instead intersect into is isnull join key last ' +
27
+ 'left like limit match materialized natural no not nothing notnull null ' +
28
+ 'nulls of offset on or order others outer over partition plan pragma ' +
29
+ 'preceding primary query raise range recursive references regexp reindex ' +
30
+ 'release rename replace restrict returning right rollback row rows savepoint ' +
31
+ 'select set table temp temporary then ties to transaction trigger ' +
32
+ 'unbounded union unique update using vacuum values view virtual when where ' +
33
+ 'window with without asc desc'
34
+ ).split(/\s+/),
29
35
  );
30
36
 
31
- interface Token {
32
- readonly kind: 'word' | 'string' | 'comment' | 'punct' | 'param' | 'fragment';
33
- readonly text: string;
34
- }
35
-
36
- /** Tokenize a body: strings/comments verbatim, words, `:params`,
37
- * `@fragment` refs, single punctuation chars. */
38
- function tokenize(body: string): Token[] {
39
- const tokens: Token[] = [];
40
- let i = 0;
41
- while (i < body.length) {
42
- const ch = body[i] as string;
43
- if (/\s/.test(ch)) {
44
- i += 1;
45
- continue;
46
- }
47
- if (body.startsWith('--', i)) {
48
- const nl = body.indexOf('\n', i);
49
- const end = nl === -1 ? body.length : nl;
50
- tokens.push({ kind: 'comment', text: body.slice(i, end).trimEnd() });
51
- i = end;
52
- continue;
53
- }
54
- if (body.startsWith('/*', i)) {
55
- const close = body.indexOf('*/', i + 2);
56
- const end = close === -1 ? body.length : close + 2;
57
- tokens.push({ kind: 'comment', text: body.slice(i, end) });
58
- i = end;
59
- continue;
60
- }
61
- if (ch === "'") {
62
- let j = i + 1;
63
- for (;;) {
64
- if (j >= body.length) break;
65
- if (body[j] === "'") {
66
- if (body[j + 1] === "'") j += 2;
67
- else {
68
- j += 1;
69
- break;
70
- }
71
- } else j += 1;
72
- }
73
- tokens.push({ kind: 'string', text: body.slice(i, j) });
74
- i = j;
75
- continue;
76
- }
77
- if (ch === ':' && /[A-Za-z_]/.test(body[i + 1] ?? '')) {
78
- let j = i + 1;
79
- while (j < body.length && /[A-Za-z0-9_]/.test(body[j] as string)) j += 1;
80
- tokens.push({ kind: 'param', text: body.slice(i, j) });
81
- i = j;
82
- continue;
83
- }
84
- if (ch === '@' && /[A-Za-z_]/.test(body[i + 1] ?? '')) {
85
- let j = i + 1;
86
- while (j < body.length && /[A-Za-z0-9_]/.test(body[j] as string)) j += 1;
87
- tokens.push({ kind: 'fragment', text: body.slice(i, j) });
88
- i = j;
89
- continue;
90
- }
91
- if (/[A-Za-z_]/.test(ch)) {
92
- let j = i + 1;
93
- while (j < body.length && /[A-Za-z0-9_]/.test(body[j] as string)) j += 1;
94
- tokens.push({ kind: 'word', text: body.slice(i, j) });
95
- i = j;
96
- continue;
97
- }
98
- // Multi-char operators stay one token (`||`, `>=`, `<=`, `<>`, `!=`, `==`).
99
- const two = body.slice(i, i + 2);
100
- if (['||', '>=', '<=', '<>', '!=', '=='].includes(two)) {
101
- tokens.push({ kind: 'punct', text: two });
102
- i += 2;
103
- continue;
104
- }
105
- tokens.push({ kind: 'punct', text: ch });
106
- i += 1;
107
- }
108
- return tokens;
109
- }
110
-
111
- /** Render tokens back to one line with canonical spacing + keyword case. */
112
- function render(tokens: readonly Token[]): string {
113
- let out = '';
114
- let prev: Token | undefined;
115
- for (const token of tokens) {
116
- let text = token.text;
117
- if (token.kind === 'word' && SQL_KEYWORDS.has(text.toLowerCase())) {
118
- text = text.toLowerCase();
119
- }
120
- const glueLeft =
121
- token.kind === 'punct' &&
122
- (text === ',' || text === ')' || text === ';' || text === '.');
123
- const glueRight =
124
- prev !== undefined &&
125
- ((prev.kind === 'punct' && (prev.text === '(' || prev.text === '.')) ||
126
- prev.kind === 'fragment');
127
- if (out.length > 0 && !glueLeft && !glueRight) out += ' ';
128
- out += text;
129
- prev = token;
130
- }
131
- return out;
132
- }
133
-
134
- /** Clause keywords that start a new line at paren depth 0. */
135
37
  const CLAUSE_STARTERS = new Set([
136
38
  'select',
137
39
  'from',
@@ -140,212 +42,384 @@ const CLAUSE_STARTERS = new Set([
140
42
  'having',
141
43
  'order',
142
44
  'limit',
45
+ 'offset',
143
46
  'window',
144
47
  'union',
145
48
  'except',
146
49
  'intersect',
147
50
  ]);
148
51
 
149
- /** Format a body: one clause per line; WHERE conjuncts one per line. */
150
- function formatBody(body: string): string[] {
151
- const tokens = tokenize(body);
152
- const lines: string[] = [];
153
- let current: Token[] = [];
154
- let depth = 0;
155
- let braceDepth = 0;
156
- let inWhere = false;
157
- let pendingBetween = 0;
158
- const flush = (): void => {
159
- if (current.length > 0) lines.push(render(current));
160
- current = [];
161
- };
162
- for (const token of tokens) {
163
- if (token.kind === 'comment') {
164
- flush();
165
- lines.push(token.text);
166
- continue;
52
+ function templates(file: SyqlSyntaxFile): readonly SyqlTemplate[] {
53
+ return file.declarations.flatMap((declaration) => {
54
+ if (declaration.kind === 'predicate') return [declaration.body];
55
+ return [
56
+ declaration.sql.body,
57
+ ...(declaration.sort?.profiles.map((profile) => profile.order) ?? []),
58
+ ];
59
+ });
60
+ }
61
+
62
+ function inTemplate(
63
+ ranges: readonly SyqlTemplate[],
64
+ token: SyqlToken,
65
+ ): boolean {
66
+ return ranges.some(
67
+ (template) =>
68
+ token.span.start.offset >= template.span.start.offset &&
69
+ token.span.end.offset <= template.span.end.offset,
70
+ );
71
+ }
72
+
73
+ function normalizedAst(ast: SyqlSemanticFile): unknown {
74
+ return JSON.parse(
75
+ JSON.stringify(ast, (_key, value: unknown) => {
76
+ if (
77
+ value !== null &&
78
+ typeof value === 'object' &&
79
+ 'kind' in value &&
80
+ 'text' in value &&
81
+ (value as { kind?: unknown }).kind === 'identifier' &&
82
+ typeof (value as { text?: unknown }).text === 'string'
83
+ ) {
84
+ const token = value as { readonly kind: string; readonly text: string };
85
+ const lower = token.text.toLowerCase();
86
+ return {
87
+ kind: token.kind,
88
+ text: SQL_KEYWORDS.has(lower) ? lower : token.text,
89
+ };
90
+ }
91
+ return value;
92
+ }),
93
+ );
94
+ }
95
+
96
+ function comments(file: SyqlSyntaxFile): readonly string[] {
97
+ return file.tokens
98
+ .filter(
99
+ (token) =>
100
+ token.kind === 'line-comment' || token.kind === 'block-comment',
101
+ )
102
+ .map((token) => token.text);
103
+ }
104
+
105
+ class TokenWriter {
106
+ readonly #lines: string[] = [''];
107
+ readonly #blockIndents: {
108
+ readonly close: number;
109
+ readonly parent: number;
110
+ }[] = [];
111
+ #indent = 0;
112
+ #nextIndent = 0;
113
+
114
+ get line(): string {
115
+ return this.#lines[this.#lines.length - 1] as string;
116
+ }
117
+
118
+ #setLine(value: string): void {
119
+ this.#lines[this.#lines.length - 1] = value;
120
+ }
121
+
122
+ write(text: string, spaceBefore: boolean): void {
123
+ if (this.line.length === 0) {
124
+ this.#setLine(' '.repeat(this.#indent + this.#nextIndent));
125
+ this.#nextIndent = 0;
167
126
  }
168
- if (token.kind === 'punct') {
169
- if (token.text === '(') depth += 1;
170
- else if (token.text === ')') depth -= 1;
171
- else if (token.text === '{') braceDepth += 1;
172
- else if (token.text === '}') braceDepth -= 1;
127
+ if (
128
+ spaceBefore &&
129
+ this.line.trim().length > 0 &&
130
+ !this.line.endsWith(' ')
131
+ ) {
132
+ this.#setLine(`${this.line} `);
173
133
  }
174
- if (token.kind === 'word' && depth === 0 && braceDepth === 0) {
175
- const word = token.text.toLowerCase();
176
- if (CLAUSE_STARTERS.has(word)) {
177
- flush();
178
- inWhere = word === 'where';
179
- pendingBetween = 0;
180
- } else if (inWhere) {
181
- if (word === 'between') pendingBetween += 1;
182
- else if (word === 'and') {
183
- if (pendingBetween > 0) pendingBetween -= 1;
184
- else flush();
185
- }
186
- }
134
+ this.#setLine(`${this.line}${text}`);
135
+ }
136
+
137
+ comment(text: string): void {
138
+ if (this.line.trim().length > 0) this.write('', true);
139
+ const pieces = text.split('\n');
140
+ this.write(pieces[0] ?? '', false);
141
+ for (const piece of pieces.slice(1)) {
142
+ this.newline();
143
+ // Internal block-comment indentation is part of the token. Do not add
144
+ // formatter indentation before its continuation text.
145
+ this.#setLine(piece);
187
146
  }
188
- current.push(token);
147
+ this.newline();
148
+ }
149
+
150
+ newline(extraIndent = 0): void {
151
+ this.#setLine(this.line.trimEnd());
152
+ if (this.line.length > 0 || this.#lines.length === 1) this.#lines.push('');
153
+ this.#nextIndent = extraIndent;
154
+ }
155
+
156
+ blankline(): void {
157
+ this.newline();
158
+ while (
159
+ this.#lines.length >= 2 &&
160
+ this.#lines[this.#lines.length - 2] === ''
161
+ ) {
162
+ this.#lines.pop();
163
+ }
164
+ if (this.#lines[this.#lines.length - 1] !== '') this.#lines.push('');
165
+ this.#lines.push('');
166
+ }
167
+
168
+ openBlock(): void {
169
+ const parentIndent = this.#indent;
170
+ this.write('{', true);
171
+ const leadingSpaces = this.line.length - this.line.trimStart().length;
172
+ this.#blockIndents.push({
173
+ close: leadingSpaces / 2,
174
+ parent: parentIndent,
175
+ });
176
+ this.#indent = leadingSpaces / 2 + 1;
177
+ this.newline();
178
+ }
179
+
180
+ indent(): void {
181
+ this.#indent += 1;
182
+ }
183
+
184
+ dedent(): void {
185
+ this.#indent -= 1;
186
+ }
187
+
188
+ closeBlock(): void {
189
+ const block = this.#blockIndents.pop();
190
+ if (block === undefined) {
191
+ throw new Error('formatter block indentation stack underflow');
192
+ }
193
+ if (this.line.trim().length > 0) this.newline();
194
+ this.#indent = block.close;
195
+ this.write('}', false);
196
+ this.#indent = block.parent;
197
+ }
198
+
199
+ finish(): string {
200
+ while (this.#lines.length > 0 && this.#lines.at(-1)?.trim() === '') {
201
+ this.#lines.pop();
202
+ }
203
+ return `${this.#lines.join('\n')}\n`;
189
204
  }
190
- flush();
191
- // Indent: clauses flush-left; WHERE continuation (`and …`) indented 2.
192
- return lines.map((line) =>
193
- /^(and|or)\b/.test(line) || line.startsWith('--') ? ` ${line}` : line,
194
- );
195
205
  }
196
206
 
197
- function formatSignature(decl: {
198
- readonly params: SyqlQueryDecl['params'];
199
- }): string {
200
- const parts: string[] = [];
201
- const seenGroups = new Set<string>();
202
- for (const param of decl.params) {
203
- if (param.group !== undefined) {
204
- if (seenGroups.has(param.group)) continue;
205
- seenGroups.add(param.group);
206
- const members = decl.params.filter((p) => p.group === param.group);
207
- parts.push(`${members.map((p) => p.name).join('+')}?`);
208
- continue;
207
+ interface DeclarationParameters {
208
+ readonly depth: number;
209
+ readonly multiline: boolean;
210
+ }
211
+
212
+ function declarationParametersMultiline(
213
+ tokens: readonly SyqlToken[],
214
+ openIndex: number,
215
+ prefixLength: number,
216
+ ): boolean {
217
+ let depth = 0;
218
+ let parameters = 0;
219
+ let hasContent = false;
220
+ let length = prefixLength + 1;
221
+ for (let index = openIndex + 1; index < tokens.length; index += 1) {
222
+ const token = tokens[index] as SyqlToken;
223
+ if (token.kind === 'line-comment' || token.kind === 'block-comment') {
224
+ return true;
225
+ }
226
+ if (token.text === '(') depth += 1;
227
+ if (token.text === ')') {
228
+ if (depth === 0) {
229
+ if (hasContent) parameters += 1;
230
+ return parameters >= 4 || length + 1 > 88;
231
+ }
232
+ depth -= 1;
209
233
  }
210
- if (param.flag) parts.push(`${param.name}?: flag`);
211
- else parts.push(`${param.name}${param.optional ? '?' : ''}`);
234
+ if (depth === 0 && token.text === ',') {
235
+ parameters += 1;
236
+ hasContent = false;
237
+ } else if (depth === 0) {
238
+ hasContent = true;
239
+ }
240
+ length += token.text.length + 1;
212
241
  }
213
- return parts.join(', ');
242
+ return false;
243
+ }
244
+
245
+ function needsSpace(
246
+ previous: SyqlToken | undefined,
247
+ token: SyqlToken,
248
+ ): boolean {
249
+ if (previous === undefined) return false;
250
+ if ([',', ')', ']', ';', '.', '?', ':'].includes(token.text)) return false;
251
+ if (['(', '[', '.', '@'].includes(previous.text)) return false;
252
+ if (token.text === '(') return false;
253
+ if (previous.text === ':') return true;
254
+ if (token.kind === 'operator' || previous.kind === 'operator') return true;
255
+ return true;
214
256
  }
215
257
 
216
- /** The leading comment block (verbatim lines) before each declaration. */
217
- function leadingComments(source: string): Map<number, string[]> {
218
- // Map from declaration index (0-based, in order of appearance) to its
219
- // preceding comment lines.
220
- const out = new Map<number, string[]>();
221
- let pending: string[] = [];
222
- let declIndex = 0;
223
- let i = 0;
224
- while (i < source.length) {
225
- const ch = source[i] as string;
226
- if (/\s/.test(ch)) {
227
- i += 1;
258
+ function formatTokens(parsed: SyqlSyntaxFile): string {
259
+ const writer = new TokenWriter();
260
+ const ranges = templates(parsed);
261
+ const tokens = parsed.tokens.filter(
262
+ (token) => token.kind !== 'whitespace' && token.kind !== 'eof',
263
+ );
264
+ let previous: SyqlToken | undefined;
265
+ let braceDepth = 0;
266
+ let parenDepth = 0;
267
+ let importList = false;
268
+ let justClosedImportList = false;
269
+ let seenTopLevel = false;
270
+ let pendingBetween = 0;
271
+
272
+ let declarationParameters: DeclarationParameters | undefined;
273
+ let awaitsDeclarationParameters = false;
274
+
275
+ for (let index = 0; index < tokens.length; index += 1) {
276
+ const token = tokens[index] as SyqlToken;
277
+ const lower = token.text.toLowerCase();
278
+ const template = inTemplate(ranges, token);
279
+ const topLevelDeclaration =
280
+ braceDepth === 0 &&
281
+ token.kind === 'identifier' &&
282
+ (lower === 'import' || lower === 'predicate' || lower === 'query');
283
+ if (topLevelDeclaration) {
284
+ if (seenTopLevel) writer.blankline();
285
+ seenTopLevel = true;
286
+ previous = undefined;
287
+ awaitsDeclarationParameters = lower === 'predicate' || lower === 'query';
288
+ } else if (
289
+ previous?.text === '}' &&
290
+ !justClosedImportList &&
291
+ token.text !== ';' &&
292
+ token.text !== ','
293
+ ) {
294
+ writer.newline();
295
+ previous = undefined;
296
+ }
297
+
298
+ if (token.kind === 'line-comment' || token.kind === 'block-comment') {
299
+ writer.comment(token.text);
300
+ previous = undefined;
228
301
  continue;
229
302
  }
230
- if (source.startsWith('--', i)) {
231
- const nl = source.indexOf('\n', i);
232
- const end = nl === -1 ? source.length : nl;
233
- pending.push(source.slice(i, end).trimEnd());
234
- i = end;
303
+
304
+ if (
305
+ template &&
306
+ parenDepth === 0 &&
307
+ token.kind === 'identifier' &&
308
+ CLAUSE_STARTERS.has(lower) &&
309
+ writer.line.trim().length > 0
310
+ ) {
311
+ writer.newline();
312
+ previous = undefined;
313
+ pendingBetween = 0;
314
+ }
315
+ if (template && parenDepth === 0 && lower === 'and') {
316
+ if (pendingBetween > 0) pendingBetween -= 1;
317
+ else {
318
+ writer.newline(1);
319
+ previous = undefined;
320
+ }
321
+ } else if (template && lower === 'between') {
322
+ pendingBetween += 1;
323
+ }
324
+
325
+ if (token.text === '{') {
326
+ if (previous?.kind === 'identifier' && previous.text === 'import') {
327
+ writer.write('{', true);
328
+ importList = true;
329
+ } else {
330
+ writer.openBlock();
331
+ braceDepth += 1;
332
+ }
333
+ previous = token;
235
334
  continue;
236
335
  }
237
- if (source.startsWith('/*', i)) {
238
- const close = source.indexOf('*/', i + 2);
239
- const end = close === -1 ? source.length : close + 2;
240
- pending.push(source.slice(i, end));
241
- i = end;
336
+ if (token.text === '}') {
337
+ if (importList && braceDepth === 0) {
338
+ writer.write('}', true);
339
+ importList = false;
340
+ justClosedImportList = true;
341
+ } else {
342
+ writer.closeBlock();
343
+ braceDepth -= 1;
344
+ }
345
+ previous = token;
242
346
  continue;
243
347
  }
244
- // A declaration keyword: attach pending comments, then skip through its
245
- // brace block.
246
- if (pending.length > 0) {
247
- out.set(declIndex, pending);
248
- pending = [];
348
+ justClosedImportList = false;
349
+
350
+ if (token.text === '(') {
351
+ parenDepth += 1;
352
+ const multiline =
353
+ awaitsDeclarationParameters &&
354
+ declarationParametersMultiline(tokens, index, writer.line.length);
355
+ if (awaitsDeclarationParameters) {
356
+ declarationParameters = { depth: parenDepth, multiline };
357
+ awaitsDeclarationParameters = false;
358
+ }
359
+ writer.write(token.text, needsSpace(previous, token));
360
+ if (multiline) {
361
+ writer.indent();
362
+ writer.newline();
363
+ }
364
+ previous = token;
365
+ continue;
249
366
  }
250
- declIndex += 1;
251
- const brace = source.indexOf('{', i);
252
- if (brace === -1) break;
253
- let depth = 0;
254
- let j = brace;
255
- while (j < source.length) {
256
- const c = source[j] as string;
257
- if (c === "'") {
258
- j += 1;
259
- while (j < source.length && source[j] !== "'") j += 1;
260
- } else if (source.startsWith('--', j)) {
261
- const nl = source.indexOf('\n', j);
262
- j = nl === -1 ? source.length : nl;
263
- } else if (c === '{') depth += 1;
264
- else if (c === '}') {
265
- depth -= 1;
266
- if (depth === 0) {
267
- j += 1;
268
- break;
269
- }
367
+
368
+ if (token.text === ',' && declarationParameters?.depth === parenDepth) {
369
+ const next = tokens[index + 1];
370
+ if (!(declarationParameters.multiline === false && next?.text === ')')) {
371
+ writer.write(',', false);
372
+ if (declarationParameters.multiline) writer.newline();
270
373
  }
271
- j += 1;
374
+ previous = token;
375
+ continue;
272
376
  }
273
- i = j;
274
- }
275
- return out;
276
- }
277
377
 
278
- /** Format one `.syql` source file into its canonical form. Throws
279
- * {@link TypegenError} when the source does not parse. */
280
- export function formatSyql(file: string, source: string): string {
281
- const parsed = parseSyqlFile(file, source);
282
- const comments = leadingComments(source);
283
- const decls: { order: number; text: string }[] = [];
284
-
285
- // Re-derive declaration order (fragments/queries interleave in-source);
286
- // parseSyqlFile keeps per-kind order, so re-scan the source for kind
287
- // keywords to interleave faithfully.
288
- const orderRe = /\b(query|fragment)\s+([A-Za-z][A-Za-z0-9]*)/g;
289
- const blanked = source
290
- .replace(/--[^\n]*/g, (m) => ' '.repeat(m.length))
291
- .replace(/\/\*[\s\S]*?\*\//g, (m) => ' '.repeat(m.length))
292
- .replace(/'(?:[^']|'')*'/g, (m) => ' '.repeat(m.length));
293
- const sequence: { kind: string; name: string }[] = [];
294
- let braceDepth = 0;
295
- let lastIndex = 0;
296
- for (let i = 0; i < blanked.length; i++) {
297
- const c = blanked[i];
298
- if (c === '{') braceDepth += 1;
299
- else if (c === '}') braceDepth -= 1;
300
- else if (braceDepth === 0) {
301
- orderRe.lastIndex = i;
302
- const m = orderRe.exec(blanked);
303
- if (m !== null && m.index === i) {
304
- sequence.push({ kind: m[1] as string, name: m[2] as string });
305
- i = orderRe.lastIndex - 1;
306
- lastIndex = orderRe.lastIndex;
378
+ if (token.text === ')' && declarationParameters?.depth === parenDepth) {
379
+ if (declarationParameters.multiline) {
380
+ if (previous?.text !== ',') writer.write(',', false);
381
+ writer.dedent();
382
+ if (writer.line.trim().length > 0) writer.newline();
307
383
  }
384
+ writer.write(')', false);
385
+ declarationParameters = undefined;
386
+ parenDepth -= 1;
387
+ previous = token;
388
+ continue;
308
389
  }
309
- }
310
- void lastIndex;
311
-
312
- sequence.forEach((entry, index) => {
313
- const lines: string[] = [];
314
- const leading = comments.get(index);
315
- if (leading !== undefined) lines.push(...leading);
316
- if (entry.kind === 'fragment') {
317
- const decl = parsed.fragments.find((f) => f.name === entry.name);
318
- if (decl === undefined) return;
319
- lines.push(`fragment ${decl.name}(${formatSignature(decl)}) {`);
320
- for (const line of formatBody(decl.body)) lines.push(` ${line}`);
321
- lines.push('}');
390
+
391
+ if (token.text === ')') parenDepth -= 1;
392
+
393
+ const text =
394
+ template && token.kind === 'identifier' && SQL_KEYWORDS.has(lower)
395
+ ? lower
396
+ : token.text;
397
+ writer.write(text, needsSpace(previous, token));
398
+ if (token.text === ';') {
399
+ writer.newline();
400
+ previous = undefined;
322
401
  } else {
323
- const decl = parsed.queries.find((q) => q.name === entry.name);
324
- if (decl === undefined) return;
325
- const knobs: string[] = [];
326
- if (decl.orderBy !== undefined) {
327
- const dir = decl.orderBy.defaultDir === 'desc' ? ' desc' : '';
328
- knobs.push(
329
- ` orderBy ${decl.orderBy.allowed.join(' | ')} default ${decl.orderBy.defaultColumn}${dir}`,
330
- );
331
- }
332
- if (decl.limit !== undefined) {
333
- const parts: string[] = [];
334
- if (decl.limit.max !== undefined) parts.push(`max ${decl.limit.max}`);
335
- if (decl.limit.default !== undefined) {
336
- parts.push(`default ${decl.limit.default}`);
337
- }
338
- knobs.push(` limit ${parts.join(' ')}`);
339
- }
340
- if (decl.variants === true) knobs.push(' variants');
341
- lines.push(
342
- `query ${decl.name}(${formatSignature(decl)})${knobs.length > 0 ? `\n${knobs.join('\n')}\n{` : ' {'}`,
343
- );
344
- for (const line of formatBody(decl.body)) lines.push(` ${line}`);
345
- lines.push('}');
402
+ previous = token;
346
403
  }
347
- decls.push({ order: index, text: lines.join('\n') });
348
- });
404
+ }
405
+ return writer.finish();
406
+ }
349
407
 
350
- return `${decls.map((d) => d.text).join('\n\n')}\n`;
408
+ /** Format one `.syql` source. Invalid or non-equivalent output is rejected so
409
+ * the CLI never writes a partially understood file. */
410
+ export function formatSyql(file: string, source: string): string {
411
+ const before = parseSyqlSyntaxFile(file, source);
412
+ const formatted = formatTokens(before);
413
+ const after = parseSyqlSyntaxFile(file, formatted);
414
+ if (
415
+ JSON.stringify(normalizedAst(toSyqlSemanticAst(before))) !==
416
+ JSON.stringify(normalizedAst(toSyqlSemanticAst(after))) ||
417
+ JSON.stringify(comments(before)) !== JSON.stringify(comments(after))
418
+ ) {
419
+ throw new TypegenError(
420
+ file,
421
+ 'SYQL8001_FORMATTER_EQUIVALENCE: formatter output changed the revision-1 semantic AST or comment order',
422
+ );
423
+ }
424
+ return formatted;
351
425
  }