@syncular/typegen 0.2.0 → 0.3.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 (51) hide show
  1. package/README.md +25 -10
  2. package/dist/cli.js +155 -4
  3. package/dist/emit-dart.js +3 -2
  4. package/dist/emit-kotlin.js +3 -2
  5. package/dist/emit-queries-dart.js +87 -15
  6. package/dist/emit-queries-kotlin.js +89 -15
  7. package/dist/emit-queries-swift.js +92 -14
  8. package/dist/emit-queries.js +131 -22
  9. package/dist/emit-swift.js +3 -2
  10. package/dist/emit.d.ts +2 -1
  11. package/dist/emit.js +13 -27
  12. package/dist/fmt.d.ts +3 -0
  13. package/dist/fmt.js +356 -0
  14. package/dist/generate.d.ts +16 -6
  15. package/dist/generate.js +34 -9
  16. package/dist/index.d.ts +6 -0
  17. package/dist/index.js +6 -0
  18. package/dist/lower.d.ts +54 -0
  19. package/dist/lower.js +283 -0
  20. package/dist/lsp.d.ts +20 -0
  21. package/dist/lsp.js +376 -0
  22. package/dist/manifest.d.ts +11 -0
  23. package/dist/manifest.js +20 -0
  24. package/dist/naming.d.ts +22 -0
  25. package/dist/naming.js +240 -0
  26. package/dist/query-ir.d.ts +14 -0
  27. package/dist/query-ir.js +69 -0
  28. package/dist/query.d.ts +102 -6
  29. package/dist/query.js +126 -34
  30. package/dist/syql.d.ts +83 -0
  31. package/dist/syql.js +945 -0
  32. package/package.json +4 -4
  33. package/src/cli.ts +155 -4
  34. package/src/emit-dart.ts +3 -2
  35. package/src/emit-kotlin.ts +3 -2
  36. package/src/emit-queries-dart.ts +109 -16
  37. package/src/emit-queries-kotlin.ts +111 -18
  38. package/src/emit-queries-swift.ts +106 -17
  39. package/src/emit-queries.ts +179 -28
  40. package/src/emit-swift.ts +3 -2
  41. package/src/emit.ts +18 -28
  42. package/src/fmt.ts +351 -0
  43. package/src/generate.ts +54 -11
  44. package/src/index.ts +6 -0
  45. package/src/lower.ts +331 -0
  46. package/src/lsp.ts +445 -0
  47. package/src/manifest.ts +38 -0
  48. package/src/naming.ts +271 -0
  49. package/src/query-ir.ts +82 -0
  50. package/src/query.ts +241 -34
  51. package/src/syql.ts +1171 -0
package/src/lsp.ts ADDED
@@ -0,0 +1,445 @@
1
+ /**
2
+ * `syncular lsp` — a minimal `.syql` language server (DESIGN-queries.md
3
+ * §10, staged Q5). Zero dependencies: hand-rolled JSON-RPC over stdio with
4
+ * Content-Length framing, and the SAME parser/checker the generator runs —
5
+ * diagnostics are generate-time truth, not a re-implementation.
6
+ *
7
+ * Capabilities:
8
+ * - diagnostics (on open/change): parse errors, lowering errors (B1,
9
+ * knob conflicts), and — when a `syncular.json` is found above the file —
10
+ * the full SQLite-checked analysis (types, unknown columns, naming
11
+ * collisions).
12
+ * - hover: a query name shows its lowered, checked SQL (what actually
13
+ * runs); an `@fragment` ref shows the fragment body.
14
+ * - definition: an `@fragment` ref jumps to its declaration (same file —
15
+ * fragments are file-scoped).
16
+ *
17
+ * The server core is a pure(ish) class (`SyqlLanguageServer.handle`)
18
+ * so tests drive it without stdio; `runLspStdio` is the thin transport.
19
+ */
20
+ import { existsSync, readFileSync } from 'node:fs';
21
+ import { dirname, relative, resolve } from 'node:path';
22
+ import { TypegenError } from './errors';
23
+ import { buildIr, loadMigrations, makeQueryDb } from './generate';
24
+ import type { IrDocument } from './ir';
25
+ import { MANIFEST_FILENAME, parseManifest } from './manifest';
26
+ import type { QueryDb, QueryNamingOptions } from './query';
27
+ import { analyzeSyqlFile, parseSyqlFile } from './syql';
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // JSON-RPC / LSP shapes (the subset we speak)
31
+ // ---------------------------------------------------------------------------
32
+
33
+ export interface RpcMessage {
34
+ jsonrpc?: '2.0';
35
+ id?: number | string | undefined;
36
+ method?: string;
37
+ params?: unknown;
38
+ result?: unknown;
39
+ error?: { code: number; message: string };
40
+ }
41
+
42
+ interface Position {
43
+ line: number;
44
+ character: number;
45
+ }
46
+
47
+ interface Range {
48
+ start: Position;
49
+ end: Position;
50
+ }
51
+
52
+ interface Diagnostic {
53
+ range: Range;
54
+ severity: number; // 1 = error
55
+ source: string;
56
+ message: string;
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Text/position helpers
61
+ // ---------------------------------------------------------------------------
62
+
63
+ function offsetToPosition(text: string, offset: number): Position {
64
+ const clamped = Math.max(0, Math.min(offset, text.length));
65
+ let line = 0;
66
+ let lineStart = 0;
67
+ for (let i = 0; i < clamped; i++) {
68
+ if (text[i] === '\n') {
69
+ line += 1;
70
+ lineStart = i + 1;
71
+ }
72
+ }
73
+ return { line, character: clamped - lineStart };
74
+ }
75
+
76
+ function positionToOffset(text: string, position: Position): number {
77
+ let line = 0;
78
+ let i = 0;
79
+ while (i < text.length && line < position.line) {
80
+ if (text[i] === '\n') line += 1;
81
+ i += 1;
82
+ }
83
+ return Math.min(text.length, i + position.character);
84
+ }
85
+
86
+ /** The `@word` / `word` under the cursor, with its range. */
87
+ function wordAt(
88
+ text: string,
89
+ offset: number,
90
+ ): { word: string; start: number; end: number } | null {
91
+ const isWord = (ch: string | undefined): boolean =>
92
+ ch !== undefined && /[A-Za-z0-9_@]/.test(ch);
93
+ if (!isWord(text[offset]) && !isWord(text[offset - 1])) return null;
94
+ let start = isWord(text[offset]) ? offset : offset - 1;
95
+ while (start > 0 && isWord(text[start - 1])) start -= 1;
96
+ let end = start;
97
+ while (end < text.length && isWord(text[end])) end += 1;
98
+ return { word: text.slice(start, end), start, end };
99
+ }
100
+
101
+ function uriToPath(uri: string): string {
102
+ return uri.startsWith('file://') ? decodeURIComponent(uri.slice(7)) : uri;
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Project context (manifest discovery, cached per directory)
107
+ // ---------------------------------------------------------------------------
108
+
109
+ interface ProjectContext {
110
+ readonly ir: IrDocument;
111
+ readonly db: QueryDb;
112
+ readonly naming: QueryNamingOptions;
113
+ readonly manifestDir: string;
114
+ }
115
+
116
+ function findManifestDir(fromPath: string): string | null {
117
+ let dir = dirname(fromPath);
118
+ for (let i = 0; i < 20; i++) {
119
+ if (existsSync(resolve(dir, MANIFEST_FILENAME))) return dir;
120
+ const parent = dirname(dir);
121
+ if (parent === dir) return null;
122
+ dir = parent;
123
+ }
124
+ return null;
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // The server
129
+ // ---------------------------------------------------------------------------
130
+
131
+ export class SyqlLanguageServer {
132
+ readonly #documents = new Map<string, string>();
133
+ #contexts = new Map<string, ProjectContext | null>();
134
+
135
+ /** Handle one incoming message; returns the outgoing messages. */
136
+ handle(message: RpcMessage): RpcMessage[] {
137
+ const { method, id } = message;
138
+ if (method === 'initialize') {
139
+ return [
140
+ {
141
+ jsonrpc: '2.0',
142
+ id,
143
+ result: {
144
+ capabilities: {
145
+ textDocumentSync: 1, // full
146
+ hoverProvider: true,
147
+ definitionProvider: true,
148
+ },
149
+ serverInfo: { name: 'syncular-syql-lsp', version: '0.0.1' },
150
+ },
151
+ },
152
+ ];
153
+ }
154
+ if (method === 'textDocument/didOpen') {
155
+ const params = message.params as {
156
+ textDocument: { uri: string; text: string };
157
+ };
158
+ this.#documents.set(params.textDocument.uri, params.textDocument.text);
159
+ return [this.#publishDiagnostics(params.textDocument.uri)];
160
+ }
161
+ if (method === 'textDocument/didChange') {
162
+ const params = message.params as {
163
+ textDocument: { uri: string };
164
+ contentChanges: { text: string }[];
165
+ };
166
+ const last = params.contentChanges[params.contentChanges.length - 1];
167
+ if (last !== undefined) {
168
+ this.#documents.set(params.textDocument.uri, last.text);
169
+ }
170
+ return [this.#publishDiagnostics(params.textDocument.uri)];
171
+ }
172
+ if (method === 'textDocument/didClose') {
173
+ const params = message.params as { textDocument: { uri: string } };
174
+ this.#documents.delete(params.textDocument.uri);
175
+ return [];
176
+ }
177
+ if (method === 'textDocument/hover') {
178
+ return [{ jsonrpc: '2.0', id, result: this.#hover(message.params) }];
179
+ }
180
+ if (method === 'textDocument/definition') {
181
+ return [{ jsonrpc: '2.0', id, result: this.#definition(message.params) }];
182
+ }
183
+ if (method === 'shutdown') {
184
+ return [{ jsonrpc: '2.0', id, result: null }];
185
+ }
186
+ if (id !== undefined && method !== undefined) {
187
+ return [
188
+ {
189
+ jsonrpc: '2.0',
190
+ id,
191
+ error: { code: -32601, message: `unhandled method ${method}` },
192
+ },
193
+ ];
194
+ }
195
+ return [];
196
+ }
197
+
198
+ /** Reset cached manifest contexts (e.g. after schema edits). */
199
+ invalidateProjects(): void {
200
+ this.#contexts = new Map();
201
+ }
202
+
203
+ #context(uri: string): ProjectContext | null {
204
+ const path = uriToPath(uri);
205
+ const manifestDir = findManifestDir(path);
206
+ if (manifestDir === null) return null;
207
+ const cached = this.#contexts.get(manifestDir);
208
+ if (cached !== undefined) return cached;
209
+ try {
210
+ const manifest = parseManifest(
211
+ JSON.parse(
212
+ readFileSync(resolve(manifestDir, MANIFEST_FILENAME), 'utf8'),
213
+ ),
214
+ );
215
+ const migrations = loadMigrations(
216
+ resolve(manifestDir, manifest.migrations),
217
+ );
218
+ const ir = buildIr(manifest, migrations);
219
+ const { db } = makeQueryDb(ir);
220
+ const targets: QueryNamingOptions['targets'] = ['ts'];
221
+ const context: ProjectContext = {
222
+ ir,
223
+ db,
224
+ manifestDir,
225
+ naming: {
226
+ naming: manifest.naming,
227
+ targets,
228
+ backend: manifest.queryBackend,
229
+ },
230
+ };
231
+ this.#contexts.set(manifestDir, context);
232
+ return context;
233
+ } catch {
234
+ this.#contexts.set(manifestDir, null);
235
+ return null;
236
+ }
237
+ }
238
+
239
+ #publishDiagnostics(uri: string): RpcMessage {
240
+ const text = this.#documents.get(uri) ?? '';
241
+ const diagnostics = this.#diagnose(uri, text);
242
+ return {
243
+ jsonrpc: '2.0',
244
+ method: 'textDocument/publishDiagnostics',
245
+ params: { uri, diagnostics },
246
+ };
247
+ }
248
+
249
+ #diagnose(uri: string, text: string): Diagnostic[] {
250
+ const path = uriToPath(uri);
251
+ const context = this.#context(uri);
252
+ try {
253
+ if (context !== null) {
254
+ // The full generate-time analysis: parse + lower + SQLite check.
255
+ const rel = relative(context.manifestDir, path).split('\\').join('/');
256
+ analyzeSyqlFile(rel, text, context.ir, context.db, context.naming);
257
+ } else {
258
+ // No manifest reachable: parser + lowering only.
259
+ const parsed = parseSyqlFile(path, text);
260
+ void parsed;
261
+ }
262
+ return [];
263
+ } catch (error) {
264
+ return [this.#errorToDiagnostic(error, text)];
265
+ }
266
+ }
267
+
268
+ #errorToDiagnostic(error: unknown, text: string): Diagnostic {
269
+ const message = error instanceof Error ? error.message : String(error);
270
+ let range: Range = {
271
+ start: { line: 0, character: 0 },
272
+ end: { line: 0, character: 1 },
273
+ };
274
+ if (error instanceof TypegenError) {
275
+ // Cursor errors carry `file:line`; lowering errors carry
276
+ // `file (query name)` — anchor those at the declaration.
277
+ const lineMatch = /:(\d+): /.exec(message);
278
+ const queryMatch = /\(query ([A-Za-z][A-Za-z0-9]*)\)/.exec(message);
279
+ if (lineMatch !== null) {
280
+ const line = Number.parseInt(lineMatch[1] as string, 10) - 1;
281
+ range = {
282
+ start: { line, character: 0 },
283
+ end: { line, character: 200 },
284
+ };
285
+ } else if (queryMatch !== null) {
286
+ try {
287
+ const parsed = parseSyqlFile('doc', text);
288
+ const decl = parsed.queries.find((q) => q.name === queryMatch[1]);
289
+ if (decl !== undefined) {
290
+ const start = offsetToPosition(text, decl.offset);
291
+ range = {
292
+ start,
293
+ end: { line: start.line, character: start.character + 200 },
294
+ };
295
+ }
296
+ } catch {
297
+ // fall through to line 0
298
+ }
299
+ }
300
+ }
301
+ return { range, severity: 1, source: 'syncular', message };
302
+ }
303
+
304
+ #hover(params: unknown): unknown {
305
+ const p = params as {
306
+ textDocument: { uri: string };
307
+ position: Position;
308
+ };
309
+ const text = this.#documents.get(p.textDocument.uri);
310
+ if (text === undefined) return null;
311
+ const offset = positionToOffset(text, p.position);
312
+ const found = wordAt(text, offset);
313
+ if (found === null) return null;
314
+
315
+ let parsed: ReturnType<typeof parseSyqlFile>;
316
+ try {
317
+ parsed = parseSyqlFile('doc', text);
318
+ } catch {
319
+ return null;
320
+ }
321
+
322
+ // `@fragment` ref → the fragment's body.
323
+ if (found.word.startsWith('@')) {
324
+ const fragment = parsed.fragments.find(
325
+ (f) => f.name === found.word.slice(1),
326
+ );
327
+ if (fragment === undefined) return null;
328
+ return {
329
+ contents: {
330
+ kind: 'markdown',
331
+ value: `\`\`\`sql\n${fragment.body}\n\`\`\``,
332
+ },
333
+ };
334
+ }
335
+
336
+ // A query name → the lowered, checked SQL (needs the project schema).
337
+ const query = parsed.queries.find((q) => q.name === found.word);
338
+ if (query !== undefined) {
339
+ const context = this.#context(p.textDocument.uri);
340
+ if (context === null) return null;
341
+ try {
342
+ const analyzed = analyzeSyqlFile(
343
+ 'doc.syql',
344
+ text,
345
+ context.ir,
346
+ context.db,
347
+ context.naming,
348
+ ).find((q) => q.name === found.word);
349
+ if (analyzed === undefined) return null;
350
+ const lines = [
351
+ '```sql',
352
+ analyzed.sql,
353
+ '```',
354
+ '',
355
+ `tables: ${analyzed.tables.join(', ')}`,
356
+ ...(analyzed.variants !== undefined
357
+ ? [`variants: ${analyzed.variants.length} enumerated statements`]
358
+ : []),
359
+ ];
360
+ return { contents: { kind: 'markdown', value: lines.join('\n') } };
361
+ } catch {
362
+ return null;
363
+ }
364
+ }
365
+ return null;
366
+ }
367
+
368
+ #definition(params: unknown): unknown {
369
+ const p = params as {
370
+ textDocument: { uri: string };
371
+ position: Position;
372
+ };
373
+ const text = this.#documents.get(p.textDocument.uri);
374
+ if (text === undefined) return null;
375
+ const offset = positionToOffset(text, p.position);
376
+ const found = wordAt(text, offset);
377
+ if (found === null || !found.word.startsWith('@')) return null;
378
+ let parsed: ReturnType<typeof parseSyqlFile>;
379
+ try {
380
+ parsed = parseSyqlFile('doc', text);
381
+ } catch {
382
+ return null;
383
+ }
384
+ const fragment = parsed.fragments.find(
385
+ (f) => f.name === found.word.slice(1),
386
+ );
387
+ if (fragment === undefined) return null;
388
+ const start = offsetToPosition(text, fragment.offset);
389
+ return {
390
+ uri: p.textDocument.uri,
391
+ range: {
392
+ start,
393
+ end: {
394
+ line: start.line,
395
+ character:
396
+ start.character + 'fragment '.length + fragment.name.length,
397
+ },
398
+ },
399
+ };
400
+ }
401
+ }
402
+
403
+ // ---------------------------------------------------------------------------
404
+ // stdio transport (Content-Length framing)
405
+ // ---------------------------------------------------------------------------
406
+
407
+ /** Run the language server over stdio until `exit`. */
408
+ export async function runLspStdio(): Promise<void> {
409
+ const server = new SyqlLanguageServer();
410
+ let buffer = Buffer.alloc(0);
411
+ const write = (message: RpcMessage): void => {
412
+ const body = Buffer.from(JSON.stringify(message), 'utf8');
413
+ process.stdout.write(
414
+ Buffer.concat([
415
+ Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii'),
416
+ body,
417
+ ]),
418
+ );
419
+ };
420
+ for await (const chunk of process.stdin) {
421
+ buffer = Buffer.concat([buffer, chunk as Buffer]);
422
+ for (;;) {
423
+ const headerEnd = buffer.indexOf('\r\n\r\n');
424
+ if (headerEnd === -1) break;
425
+ const header = buffer.subarray(0, headerEnd).toString('ascii');
426
+ const lengthMatch = /Content-Length: (\d+)/i.exec(header);
427
+ if (lengthMatch === null) {
428
+ buffer = buffer.subarray(headerEnd + 4);
429
+ continue;
430
+ }
431
+ const length = Number.parseInt(lengthMatch[1] as string, 10);
432
+ if (buffer.length < headerEnd + 4 + length) break;
433
+ const body = buffer.subarray(headerEnd + 4, headerEnd + 4 + length);
434
+ buffer = buffer.subarray(headerEnd + 4 + length);
435
+ let message: RpcMessage;
436
+ try {
437
+ message = JSON.parse(body.toString('utf8')) as RpcMessage;
438
+ } catch {
439
+ continue;
440
+ }
441
+ if (message.method === 'exit') return;
442
+ for (const outgoing of server.handle(message)) write(outgoing);
443
+ }
444
+ }
445
+ }
package/src/manifest.ts CHANGED
@@ -40,6 +40,10 @@
40
40
  * WP-49 passthrough slot copied verbatim into the IR.
41
41
  */
42
42
  import { TypegenError } from './errors';
43
+ import type { NamingMode } from './naming';
44
+
45
+ /** §7/§8 `.syql` conditional-lowering backend selection. */
46
+ export type ManifestQueryBackend = 'neutralize' | 'variants' | 'auto';
43
47
 
44
48
  export const MANIFEST_FILENAME = 'syncular.json';
45
49
 
@@ -116,6 +120,14 @@ export interface Manifest {
116
120
  /** Directory of `.sql` named-query files (default `./queries`). Only read
117
121
  * when at least one output requests a named-queries file. */
118
122
  readonly queries: string;
123
+ /** §5 casing: "camel" (default) maps snake_case SQL names to camelCase in
124
+ * generated row types/params (queries lower with `AS` aliases); "preserve"
125
+ * keeps SQL-truth names everywhere. */
126
+ readonly naming: NamingMode;
127
+ /** §7 `.syql` backend: "neutralize" (default), "variants" (enumerate
128
+ * whenever eligible), or "auto" (enumerate at ≤ 2 optional groups). The
129
+ * per-query `variants` knob always forces enumeration. */
130
+ readonly queryBackend: ManifestQueryBackend;
119
131
  readonly output: ManifestOutput;
120
132
  readonly schemaVersions: readonly ManifestSchemaVersion[];
121
133
  readonly tables: readonly ManifestTable[];
@@ -343,6 +355,8 @@ export function parseManifest(raw: unknown): Manifest {
343
355
  'manifestVersion',
344
356
  'migrations',
345
357
  'queries',
358
+ 'naming',
359
+ 'queryBackend',
346
360
  'output',
347
361
  'schemaVersions',
348
362
  'tables',
@@ -362,6 +376,28 @@ export function parseManifest(raw: unknown): Manifest {
362
376
  : asString(obj.migrations, 'migrations');
363
377
  const queries =
364
378
  obj.queries === undefined ? './queries' : asString(obj.queries, 'queries');
379
+ let naming: NamingMode = 'camel';
380
+ if (obj.naming !== undefined) {
381
+ if (obj.naming !== 'camel' && obj.naming !== 'preserve') {
382
+ fail(
383
+ `naming must be "camel" or "preserve", got ${JSON.stringify(obj.naming)}`,
384
+ );
385
+ }
386
+ naming = obj.naming;
387
+ }
388
+ let queryBackend: ManifestQueryBackend = 'neutralize';
389
+ if (obj.queryBackend !== undefined) {
390
+ if (
391
+ obj.queryBackend !== 'neutralize' &&
392
+ obj.queryBackend !== 'variants' &&
393
+ obj.queryBackend !== 'auto'
394
+ ) {
395
+ fail(
396
+ `queryBackend must be "neutralize", "variants" or "auto", got ${JSON.stringify(obj.queryBackend)}`,
397
+ );
398
+ }
399
+ queryBackend = obj.queryBackend;
400
+ }
365
401
  let ir = './syncular.ir.json';
366
402
  let module = './syncular.generated.ts';
367
403
  let queriesOut: string | undefined;
@@ -436,6 +472,8 @@ export function parseManifest(raw: unknown): Manifest {
436
472
  manifestVersion: 1,
437
473
  migrations,
438
474
  queries,
475
+ naming,
476
+ queryBackend,
439
477
  output: outputSpec,
440
478
  schemaVersions,
441
479
  tables,