@syncular/typegen 0.2.1 → 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 +2 -2
  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/dist/lsp.js ADDED
@@ -0,0 +1,376 @@
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.js';
23
+ import { buildIr, loadMigrations, makeQueryDb } from './generate.js';
24
+ import { MANIFEST_FILENAME, parseManifest } from './manifest.js';
25
+ import { analyzeSyqlFile, parseSyqlFile } from './syql.js';
26
+ // ---------------------------------------------------------------------------
27
+ // Text/position helpers
28
+ // ---------------------------------------------------------------------------
29
+ function offsetToPosition(text, offset) {
30
+ const clamped = Math.max(0, Math.min(offset, text.length));
31
+ let line = 0;
32
+ let lineStart = 0;
33
+ for (let i = 0; i < clamped; i++) {
34
+ if (text[i] === '\n') {
35
+ line += 1;
36
+ lineStart = i + 1;
37
+ }
38
+ }
39
+ return { line, character: clamped - lineStart };
40
+ }
41
+ function positionToOffset(text, position) {
42
+ let line = 0;
43
+ let i = 0;
44
+ while (i < text.length && line < position.line) {
45
+ if (text[i] === '\n')
46
+ line += 1;
47
+ i += 1;
48
+ }
49
+ return Math.min(text.length, i + position.character);
50
+ }
51
+ /** The `@word` / `word` under the cursor, with its range. */
52
+ function wordAt(text, offset) {
53
+ const isWord = (ch) => ch !== undefined && /[A-Za-z0-9_@]/.test(ch);
54
+ if (!isWord(text[offset]) && !isWord(text[offset - 1]))
55
+ return null;
56
+ let start = isWord(text[offset]) ? offset : offset - 1;
57
+ while (start > 0 && isWord(text[start - 1]))
58
+ start -= 1;
59
+ let end = start;
60
+ while (end < text.length && isWord(text[end]))
61
+ end += 1;
62
+ return { word: text.slice(start, end), start, end };
63
+ }
64
+ function uriToPath(uri) {
65
+ return uri.startsWith('file://') ? decodeURIComponent(uri.slice(7)) : uri;
66
+ }
67
+ function findManifestDir(fromPath) {
68
+ let dir = dirname(fromPath);
69
+ for (let i = 0; i < 20; i++) {
70
+ if (existsSync(resolve(dir, MANIFEST_FILENAME)))
71
+ return dir;
72
+ const parent = dirname(dir);
73
+ if (parent === dir)
74
+ return null;
75
+ dir = parent;
76
+ }
77
+ return null;
78
+ }
79
+ // ---------------------------------------------------------------------------
80
+ // The server
81
+ // ---------------------------------------------------------------------------
82
+ export class SyqlLanguageServer {
83
+ #documents = new Map();
84
+ #contexts = new Map();
85
+ /** Handle one incoming message; returns the outgoing messages. */
86
+ handle(message) {
87
+ const { method, id } = message;
88
+ if (method === 'initialize') {
89
+ return [
90
+ {
91
+ jsonrpc: '2.0',
92
+ id,
93
+ result: {
94
+ capabilities: {
95
+ textDocumentSync: 1, // full
96
+ hoverProvider: true,
97
+ definitionProvider: true,
98
+ },
99
+ serverInfo: { name: 'syncular-syql-lsp', version: '0.0.1' },
100
+ },
101
+ },
102
+ ];
103
+ }
104
+ if (method === 'textDocument/didOpen') {
105
+ const params = message.params;
106
+ this.#documents.set(params.textDocument.uri, params.textDocument.text);
107
+ return [this.#publishDiagnostics(params.textDocument.uri)];
108
+ }
109
+ if (method === 'textDocument/didChange') {
110
+ const params = message.params;
111
+ const last = params.contentChanges[params.contentChanges.length - 1];
112
+ if (last !== undefined) {
113
+ this.#documents.set(params.textDocument.uri, last.text);
114
+ }
115
+ return [this.#publishDiagnostics(params.textDocument.uri)];
116
+ }
117
+ if (method === 'textDocument/didClose') {
118
+ const params = message.params;
119
+ this.#documents.delete(params.textDocument.uri);
120
+ return [];
121
+ }
122
+ if (method === 'textDocument/hover') {
123
+ return [{ jsonrpc: '2.0', id, result: this.#hover(message.params) }];
124
+ }
125
+ if (method === 'textDocument/definition') {
126
+ return [{ jsonrpc: '2.0', id, result: this.#definition(message.params) }];
127
+ }
128
+ if (method === 'shutdown') {
129
+ return [{ jsonrpc: '2.0', id, result: null }];
130
+ }
131
+ if (id !== undefined && method !== undefined) {
132
+ return [
133
+ {
134
+ jsonrpc: '2.0',
135
+ id,
136
+ error: { code: -32601, message: `unhandled method ${method}` },
137
+ },
138
+ ];
139
+ }
140
+ return [];
141
+ }
142
+ /** Reset cached manifest contexts (e.g. after schema edits). */
143
+ invalidateProjects() {
144
+ this.#contexts = new Map();
145
+ }
146
+ #context(uri) {
147
+ const path = uriToPath(uri);
148
+ const manifestDir = findManifestDir(path);
149
+ if (manifestDir === null)
150
+ return null;
151
+ const cached = this.#contexts.get(manifestDir);
152
+ if (cached !== undefined)
153
+ return cached;
154
+ try {
155
+ const manifest = parseManifest(JSON.parse(readFileSync(resolve(manifestDir, MANIFEST_FILENAME), 'utf8')));
156
+ const migrations = loadMigrations(resolve(manifestDir, manifest.migrations));
157
+ const ir = buildIr(manifest, migrations);
158
+ const { db } = makeQueryDb(ir);
159
+ const targets = ['ts'];
160
+ const context = {
161
+ ir,
162
+ db,
163
+ manifestDir,
164
+ naming: {
165
+ naming: manifest.naming,
166
+ targets,
167
+ backend: manifest.queryBackend,
168
+ },
169
+ };
170
+ this.#contexts.set(manifestDir, context);
171
+ return context;
172
+ }
173
+ catch {
174
+ this.#contexts.set(manifestDir, null);
175
+ return null;
176
+ }
177
+ }
178
+ #publishDiagnostics(uri) {
179
+ const text = this.#documents.get(uri) ?? '';
180
+ const diagnostics = this.#diagnose(uri, text);
181
+ return {
182
+ jsonrpc: '2.0',
183
+ method: 'textDocument/publishDiagnostics',
184
+ params: { uri, diagnostics },
185
+ };
186
+ }
187
+ #diagnose(uri, text) {
188
+ const path = uriToPath(uri);
189
+ const context = this.#context(uri);
190
+ try {
191
+ if (context !== null) {
192
+ // The full generate-time analysis: parse + lower + SQLite check.
193
+ const rel = relative(context.manifestDir, path).split('\\').join('/');
194
+ analyzeSyqlFile(rel, text, context.ir, context.db, context.naming);
195
+ }
196
+ else {
197
+ // No manifest reachable: parser + lowering only.
198
+ const parsed = parseSyqlFile(path, text);
199
+ void parsed;
200
+ }
201
+ return [];
202
+ }
203
+ catch (error) {
204
+ return [this.#errorToDiagnostic(error, text)];
205
+ }
206
+ }
207
+ #errorToDiagnostic(error, text) {
208
+ const message = error instanceof Error ? error.message : String(error);
209
+ let range = {
210
+ start: { line: 0, character: 0 },
211
+ end: { line: 0, character: 1 },
212
+ };
213
+ if (error instanceof TypegenError) {
214
+ // Cursor errors carry `file:line`; lowering errors carry
215
+ // `file (query name)` — anchor those at the declaration.
216
+ const lineMatch = /:(\d+): /.exec(message);
217
+ const queryMatch = /\(query ([A-Za-z][A-Za-z0-9]*)\)/.exec(message);
218
+ if (lineMatch !== null) {
219
+ const line = Number.parseInt(lineMatch[1], 10) - 1;
220
+ range = {
221
+ start: { line, character: 0 },
222
+ end: { line, character: 200 },
223
+ };
224
+ }
225
+ else if (queryMatch !== null) {
226
+ try {
227
+ const parsed = parseSyqlFile('doc', text);
228
+ const decl = parsed.queries.find((q) => q.name === queryMatch[1]);
229
+ if (decl !== undefined) {
230
+ const start = offsetToPosition(text, decl.offset);
231
+ range = {
232
+ start,
233
+ end: { line: start.line, character: start.character + 200 },
234
+ };
235
+ }
236
+ }
237
+ catch {
238
+ // fall through to line 0
239
+ }
240
+ }
241
+ }
242
+ return { range, severity: 1, source: 'syncular', message };
243
+ }
244
+ #hover(params) {
245
+ const p = params;
246
+ const text = this.#documents.get(p.textDocument.uri);
247
+ if (text === undefined)
248
+ return null;
249
+ const offset = positionToOffset(text, p.position);
250
+ const found = wordAt(text, offset);
251
+ if (found === null)
252
+ return null;
253
+ let parsed;
254
+ try {
255
+ parsed = parseSyqlFile('doc', text);
256
+ }
257
+ catch {
258
+ return null;
259
+ }
260
+ // `@fragment` ref → the fragment's body.
261
+ if (found.word.startsWith('@')) {
262
+ const fragment = parsed.fragments.find((f) => f.name === found.word.slice(1));
263
+ if (fragment === undefined)
264
+ return null;
265
+ return {
266
+ contents: {
267
+ kind: 'markdown',
268
+ value: `\`\`\`sql\n${fragment.body}\n\`\`\``,
269
+ },
270
+ };
271
+ }
272
+ // A query name → the lowered, checked SQL (needs the project schema).
273
+ const query = parsed.queries.find((q) => q.name === found.word);
274
+ if (query !== undefined) {
275
+ const context = this.#context(p.textDocument.uri);
276
+ if (context === null)
277
+ return null;
278
+ try {
279
+ const analyzed = analyzeSyqlFile('doc.syql', text, context.ir, context.db, context.naming).find((q) => q.name === found.word);
280
+ if (analyzed === undefined)
281
+ return null;
282
+ const lines = [
283
+ '```sql',
284
+ analyzed.sql,
285
+ '```',
286
+ '',
287
+ `tables: ${analyzed.tables.join(', ')}`,
288
+ ...(analyzed.variants !== undefined
289
+ ? [`variants: ${analyzed.variants.length} enumerated statements`]
290
+ : []),
291
+ ];
292
+ return { contents: { kind: 'markdown', value: lines.join('\n') } };
293
+ }
294
+ catch {
295
+ return null;
296
+ }
297
+ }
298
+ return null;
299
+ }
300
+ #definition(params) {
301
+ const p = params;
302
+ const text = this.#documents.get(p.textDocument.uri);
303
+ if (text === undefined)
304
+ return null;
305
+ const offset = positionToOffset(text, p.position);
306
+ const found = wordAt(text, offset);
307
+ if (found === null || !found.word.startsWith('@'))
308
+ return null;
309
+ let parsed;
310
+ try {
311
+ parsed = parseSyqlFile('doc', text);
312
+ }
313
+ catch {
314
+ return null;
315
+ }
316
+ const fragment = parsed.fragments.find((f) => f.name === found.word.slice(1));
317
+ if (fragment === undefined)
318
+ return null;
319
+ const start = offsetToPosition(text, fragment.offset);
320
+ return {
321
+ uri: p.textDocument.uri,
322
+ range: {
323
+ start,
324
+ end: {
325
+ line: start.line,
326
+ character: start.character + 'fragment '.length + fragment.name.length,
327
+ },
328
+ },
329
+ };
330
+ }
331
+ }
332
+ // ---------------------------------------------------------------------------
333
+ // stdio transport (Content-Length framing)
334
+ // ---------------------------------------------------------------------------
335
+ /** Run the language server over stdio until `exit`. */
336
+ export async function runLspStdio() {
337
+ const server = new SyqlLanguageServer();
338
+ let buffer = Buffer.alloc(0);
339
+ const write = (message) => {
340
+ const body = Buffer.from(JSON.stringify(message), 'utf8');
341
+ process.stdout.write(Buffer.concat([
342
+ Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii'),
343
+ body,
344
+ ]));
345
+ };
346
+ for await (const chunk of process.stdin) {
347
+ buffer = Buffer.concat([buffer, chunk]);
348
+ for (;;) {
349
+ const headerEnd = buffer.indexOf('\r\n\r\n');
350
+ if (headerEnd === -1)
351
+ break;
352
+ const header = buffer.subarray(0, headerEnd).toString('ascii');
353
+ const lengthMatch = /Content-Length: (\d+)/i.exec(header);
354
+ if (lengthMatch === null) {
355
+ buffer = buffer.subarray(headerEnd + 4);
356
+ continue;
357
+ }
358
+ const length = Number.parseInt(lengthMatch[1], 10);
359
+ if (buffer.length < headerEnd + 4 + length)
360
+ break;
361
+ const body = buffer.subarray(headerEnd + 4, headerEnd + 4 + length);
362
+ buffer = buffer.subarray(headerEnd + 4 + length);
363
+ let message;
364
+ try {
365
+ message = JSON.parse(body.toString('utf8'));
366
+ }
367
+ catch {
368
+ continue;
369
+ }
370
+ if (message.method === 'exit')
371
+ return;
372
+ for (const outgoing of server.handle(message))
373
+ write(outgoing);
374
+ }
375
+ }
376
+ }
@@ -1,3 +1,6 @@
1
+ import type { NamingMode } from './naming.js';
2
+ /** §7/§8 `.syql` conditional-lowering backend selection. */
3
+ export type ManifestQueryBackend = 'neutralize' | 'variants' | 'auto';
1
4
  export declare const MANIFEST_FILENAME = "syncular.json";
2
5
  /** Same shorthand the server schema accepts (§3.1). */
3
6
  export type ManifestScopeSpec = string | {
@@ -67,6 +70,14 @@ export interface Manifest {
67
70
  /** Directory of `.sql` named-query files (default `./queries`). Only read
68
71
  * when at least one output requests a named-queries file. */
69
72
  readonly queries: string;
73
+ /** §5 casing: "camel" (default) maps snake_case SQL names to camelCase in
74
+ * generated row types/params (queries lower with `AS` aliases); "preserve"
75
+ * keeps SQL-truth names everywhere. */
76
+ readonly naming: NamingMode;
77
+ /** §7 `.syql` backend: "neutralize" (default), "variants" (enumerate
78
+ * whenever eligible), or "auto" (enumerate at ≤ 2 optional groups). The
79
+ * per-query `variants` knob always forces enumeration. */
80
+ readonly queryBackend: ManifestQueryBackend;
70
81
  readonly output: ManifestOutput;
71
82
  readonly schemaVersions: readonly ManifestSchemaVersion[];
72
83
  readonly tables: readonly ManifestTable[];
package/dist/manifest.js CHANGED
@@ -209,6 +209,8 @@ export function parseManifest(raw) {
209
209
  'manifestVersion',
210
210
  'migrations',
211
211
  'queries',
212
+ 'naming',
213
+ 'queryBackend',
212
214
  'output',
213
215
  'schemaVersions',
214
216
  'tables',
@@ -222,6 +224,22 @@ export function parseManifest(raw) {
222
224
  ? './migrations'
223
225
  : asString(obj.migrations, 'migrations');
224
226
  const queries = obj.queries === undefined ? './queries' : asString(obj.queries, 'queries');
227
+ let naming = 'camel';
228
+ if (obj.naming !== undefined) {
229
+ if (obj.naming !== 'camel' && obj.naming !== 'preserve') {
230
+ fail(`naming must be "camel" or "preserve", got ${JSON.stringify(obj.naming)}`);
231
+ }
232
+ naming = obj.naming;
233
+ }
234
+ let queryBackend = 'neutralize';
235
+ if (obj.queryBackend !== undefined) {
236
+ if (obj.queryBackend !== 'neutralize' &&
237
+ obj.queryBackend !== 'variants' &&
238
+ obj.queryBackend !== 'auto') {
239
+ fail(`queryBackend must be "neutralize", "variants" or "auto", got ${JSON.stringify(obj.queryBackend)}`);
240
+ }
241
+ queryBackend = obj.queryBackend;
242
+ }
225
243
  let ir = './syncular.ir.json';
226
244
  let module = './syncular.generated.ts';
227
245
  let queriesOut;
@@ -294,6 +312,8 @@ export function parseManifest(raw) {
294
312
  manifestVersion: 1,
295
313
  migrations,
296
314
  queries,
315
+ naming,
316
+ queryBackend,
297
317
  output: outputSpec,
298
318
  schemaVersions,
299
319
  tables,
@@ -0,0 +1,22 @@
1
+ /** Manifest `"naming"`: camelCase emission (default) or SQL-truth names. */
2
+ export type NamingMode = 'camel' | 'preserve';
3
+ /** The pinned §12 snake→camel conversion. Non-identifier names (computed
4
+ * result columns like `count(*)`) are returned unchanged. */
5
+ export declare function snakeToCamel(name: string): string;
6
+ /** The emitter targets whose keyword sets we police. `ts` is included for
7
+ * completeness (its emitters can quote any property, so its list is the
8
+ * small set that breaks generated FUNCTION/const identifiers). */
9
+ export type NamingTarget = 'ts' | 'swift' | 'kotlin' | 'dart';
10
+ /** One naming-map entry: the SQL-truth name and its language-facing name. */
11
+ export interface NameMapping {
12
+ readonly sqlName: string;
13
+ readonly langName: string;
14
+ }
15
+ /**
16
+ * Map a set of SQL names to language names under `mode`, enforcing the §12
17
+ * hard errors within the scope (one table's columns / one query's projection
18
+ * / one query's params). `context` and `scope` name the error location
19
+ * (e.g. `queries/list.sql`, `table todos`); `targets` are the emitters this
20
+ * run generates — keyword hazards are only real on targets that exist.
21
+ */
22
+ export declare function buildNamingMap(sqlNames: readonly string[], mode: NamingMode, context: string, scope: string, targets: readonly NamingTarget[]): NameMapping[];