@syncular/typegen 0.5.1 → 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/lsp.ts CHANGED
@@ -1,34 +1,28 @@
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
- */
1
+ /** Revision-1 SYQL language server backed by the compiler frontend (§21). */
20
2
  import { existsSync, readFileSync } from 'node:fs';
21
- import { dirname, relative, resolve } from 'node:path';
3
+ import { dirname, relative, resolve, sep } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
22
5
  import { TypegenError } from './errors';
23
- import { buildIr, loadMigrations, makeQueryDb } from './generate';
6
+ import { formatSyql } from './fmt';
7
+ import { buildIr, loadMigrations, loadQueries, makeQueryDb } from './generate';
24
8
  import type { IrDocument } from './ir';
25
9
  import { MANIFEST_FILENAME, parseManifest } from './manifest';
26
10
  import type { QueryDb, QueryNamingOptions } from './query';
27
- import { analyzeSyqlFile, parseSyqlFile } from './syql';
28
-
29
- // ---------------------------------------------------------------------------
30
- // JSON-RPC / LSP shapes (the subset we speak)
31
- // ---------------------------------------------------------------------------
11
+ import { SyqlFrontendError, type SyqlSourceSpan } from './syql-lexer';
12
+ import type { SyqlLoweredQuery } from './syql-lowering';
13
+ import { lowerSyqlQuery } from './syql-lowering';
14
+ import { buildSyqlModuleGraph } from './syql-modules';
15
+ import {
16
+ parseSyqlSyntaxFile,
17
+ type SyqlQueryParameter,
18
+ type SyqlSyntaxFile,
19
+ type SyqlValueType,
20
+ } from './syql-parser';
21
+ import {
22
+ analyzeSyqlSemantics,
23
+ type SyqlSemanticProgram,
24
+ } from './syql-semantics';
25
+ import { validateSyqlProgram } from './syql-validator';
32
26
 
33
27
  export interface RpcMessage {
34
28
  jsonrpc?: '2.0';
@@ -51,23 +45,41 @@ interface Range {
51
45
 
52
46
  interface Diagnostic {
53
47
  range: Range;
54
- severity: number; // 1 = error
48
+ severity: number;
55
49
  source: string;
50
+ code?: string;
56
51
  message: string;
57
52
  }
58
53
 
59
- // ---------------------------------------------------------------------------
60
- // Text/position helpers
61
- // ---------------------------------------------------------------------------
54
+ interface ProjectContext {
55
+ readonly ir: IrDocument;
56
+ readonly db: QueryDb;
57
+ readonly naming: QueryNamingOptions;
58
+ readonly manifestDir: string;
59
+ readonly queriesRoot: string;
60
+ }
61
+
62
+ type ProjectLookup =
63
+ | { readonly kind: 'none' }
64
+ | { readonly kind: 'ready'; readonly context: ProjectContext }
65
+ | { readonly kind: 'error'; readonly error: unknown };
66
+
67
+ interface CompilerView {
68
+ readonly root: string;
69
+ readonly file: string;
70
+ readonly module: SyqlSyntaxFile;
71
+ readonly semantic: SyqlSemanticProgram;
72
+ readonly lowered: readonly SyqlLoweredQuery[];
73
+ }
62
74
 
63
75
  function offsetToPosition(text: string, offset: number): Position {
64
76
  const clamped = Math.max(0, Math.min(offset, text.length));
65
77
  let line = 0;
66
78
  let lineStart = 0;
67
- for (let i = 0; i < clamped; i++) {
68
- if (text[i] === '\n') {
79
+ for (let index = 0; index < clamped; index += 1) {
80
+ if (text[index] === '\n') {
69
81
  line += 1;
70
- lineStart = i + 1;
82
+ lineStart = index + 1;
71
83
  }
72
84
  }
73
85
  return { line, character: clamped - lineStart };
@@ -75,21 +87,24 @@ function offsetToPosition(text: string, offset: number): Position {
75
87
 
76
88
  function positionToOffset(text: string, position: Position): number {
77
89
  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;
90
+ let index = 0;
91
+ while (index < text.length && line < position.line) {
92
+ if (text[index] === '\n') line += 1;
93
+ index += 1;
82
94
  }
83
- return Math.min(text.length, i + position.character);
95
+ return Math.min(text.length, index + position.character);
84
96
  }
85
97
 
86
- /** The `@word` / `word` under the cursor, with its range. */
87
98
  function wordAt(
88
99
  text: string,
89
100
  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);
101
+ ): {
102
+ readonly word: string;
103
+ readonly start: number;
104
+ readonly end: number;
105
+ } | null {
106
+ const isWord = (character: string | undefined): boolean =>
107
+ character !== undefined && /[A-Za-z0-9_@]/.test(character);
93
108
  if (!isWord(text[offset]) && !isWord(text[offset - 1])) return null;
94
109
  let start = isWord(text[offset]) ? offset : offset - 1;
95
110
  while (start > 0 && isWord(text[start - 1])) start -= 1;
@@ -102,37 +117,56 @@ function uriToPath(uri: string): string {
102
117
  return uri.startsWith('file://') ? decodeURIComponent(uri.slice(7)) : uri;
103
118
  }
104
119
 
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
120
  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;
121
+ let directory = dirname(fromPath);
122
+ for (let count = 0; count < 20; count += 1) {
123
+ if (existsSync(resolve(directory, MANIFEST_FILENAME))) return directory;
124
+ const parent = dirname(directory);
125
+ if (parent === directory) return null;
126
+ directory = parent;
123
127
  }
124
128
  return null;
125
129
  }
126
130
 
127
- // ---------------------------------------------------------------------------
128
- // The server
129
- // ---------------------------------------------------------------------------
131
+ function isWithin(root: string, file: string): boolean {
132
+ const path = relative(root, file);
133
+ return path === '' || (!path.startsWith(`..${sep}`) && path !== '..');
134
+ }
135
+
136
+ function spanRange(text: string, span: SyqlSourceSpan): Range {
137
+ const start = offsetToPosition(text, span.start.offset);
138
+ const end = offsetToPosition(text, span.end.offset);
139
+ return {
140
+ start,
141
+ end:
142
+ end.line === start.line && end.character === start.character
143
+ ? { line: end.line, character: end.character + 1 }
144
+ : end,
145
+ };
146
+ }
147
+
148
+ function typeText(type: SyqlValueType | undefined): string {
149
+ return type === undefined
150
+ ? 'inferred'
151
+ : `${type.base}${type.nullable ? ' | null' : ''}`;
152
+ }
153
+
154
+ function parameterHover(parameter: SyqlQueryParameter): string {
155
+ if (parameter.kind === 'switch') {
156
+ return `switch \`${parameter.name}\` — false/absent or true`;
157
+ }
158
+ if (parameter.kind === 'group') {
159
+ return `optional group \`${parameter.name}\`\n\n${parameter.members
160
+ .map((member) => `- \`${member.name}: ${typeText(member.type)}\``)
161
+ .join('\n')}`;
162
+ }
163
+ return `${parameter.optional ? 'optional' : 'required'} input \`${parameter.name}: ${typeText(parameter.type)}\``;
164
+ }
130
165
 
131
166
  export class SyqlLanguageServer {
132
167
  readonly #documents = new Map<string, string>();
133
- #contexts = new Map<string, ProjectContext | null>();
168
+ #contexts = new Map<string, ProjectLookup>();
134
169
 
135
- /** Handle one incoming message; returns the outgoing messages. */
136
170
  handle(message: RpcMessage): RpcMessage[] {
137
171
  const { method, id } = message;
138
172
  if (method === 'initialize') {
@@ -142,11 +176,14 @@ export class SyqlLanguageServer {
142
176
  id,
143
177
  result: {
144
178
  capabilities: {
145
- textDocumentSync: 1, // full
179
+ textDocumentSync: 1,
146
180
  hoverProvider: true,
147
181
  definitionProvider: true,
182
+ referencesProvider: true,
183
+ documentSymbolProvider: true,
184
+ documentFormattingProvider: true,
148
185
  },
149
- serverInfo: { name: 'syncular-syql-lsp', version: '0.0.1' },
186
+ serverInfo: { name: 'syncular-syql-lsp', version: '1.0.0' },
150
187
  },
151
188
  },
152
189
  ];
@@ -163,10 +200,9 @@ export class SyqlLanguageServer {
163
200
  textDocument: { uri: string };
164
201
  contentChanges: { text: string }[];
165
202
  };
166
- const last = params.contentChanges[params.contentChanges.length - 1];
167
- if (last !== undefined) {
203
+ const last = params.contentChanges.at(-1);
204
+ if (last !== undefined)
168
205
  this.#documents.set(params.textDocument.uri, last.text);
169
- }
170
206
  return [this.#publishDiagnostics(params.textDocument.uri)];
171
207
  }
172
208
  if (method === 'textDocument/didClose') {
@@ -174,15 +210,28 @@ export class SyqlLanguageServer {
174
210
  this.#documents.delete(params.textDocument.uri);
175
211
  return [];
176
212
  }
213
+ if (method === 'workspace/didChangeWatchedFiles') {
214
+ this.invalidateProjects();
215
+ return [...this.#documents.keys()].map((uri) =>
216
+ this.#publishDiagnostics(uri),
217
+ );
218
+ }
177
219
  if (method === 'textDocument/hover') {
178
220
  return [{ jsonrpc: '2.0', id, result: this.#hover(message.params) }];
179
221
  }
180
222
  if (method === 'textDocument/definition') {
181
223
  return [{ jsonrpc: '2.0', id, result: this.#definition(message.params) }];
182
224
  }
183
- if (method === 'shutdown') {
184
- return [{ jsonrpc: '2.0', id, result: null }];
225
+ if (method === 'textDocument/references') {
226
+ return [{ jsonrpc: '2.0', id, result: this.#references(message.params) }];
227
+ }
228
+ if (method === 'textDocument/documentSymbol') {
229
+ return [{ jsonrpc: '2.0', id, result: this.#symbols(message.params) }];
185
230
  }
231
+ if (method === 'textDocument/formatting') {
232
+ return [{ jsonrpc: '2.0', id, result: this.#formatting(message.params) }];
233
+ }
234
+ if (method === 'shutdown') return [{ jsonrpc: '2.0', id, result: null }];
186
235
  if (id !== undefined && method !== undefined) {
187
236
  return [
188
237
  {
@@ -195,15 +244,14 @@ export class SyqlLanguageServer {
195
244
  return [];
196
245
  }
197
246
 
198
- /** Reset cached manifest contexts (e.g. after schema edits). */
199
247
  invalidateProjects(): void {
200
248
  this.#contexts = new Map();
201
249
  }
202
250
 
203
- #context(uri: string): ProjectContext | null {
251
+ #context(uri: string): ProjectLookup {
204
252
  const path = uriToPath(uri);
205
253
  const manifestDir = findManifestDir(path);
206
- if (manifestDir === null) return null;
254
+ if (manifestDir === null) return { kind: 'none' };
207
255
  const cached = this.#contexts.get(manifestDir);
208
256
  if (cached !== undefined) return cached;
209
257
  try {
@@ -212,199 +260,354 @@ export class SyqlLanguageServer {
212
260
  readFileSync(resolve(manifestDir, MANIFEST_FILENAME), 'utf8'),
213
261
  ),
214
262
  );
215
- const migrations = loadMigrations(
216
- resolve(manifestDir, manifest.migrations),
263
+ const ir = buildIr(
264
+ manifest,
265
+ loadMigrations(resolve(manifestDir, manifest.migrations)),
217
266
  );
218
- const ir = buildIr(manifest, migrations);
219
267
  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,
268
+ const targets: QueryNamingOptions['targets'][number][] = ['ts'];
269
+ if (manifest.output.swift !== undefined) targets.push('swift');
270
+ if (manifest.output.kotlin !== undefined) targets.push('kotlin');
271
+ if (manifest.output.dart !== undefined) targets.push('dart');
272
+ const lookup: ProjectLookup = {
273
+ kind: 'ready',
274
+ context: {
275
+ ir,
276
+ db,
277
+ manifestDir,
278
+ queriesRoot: resolve(manifestDir, manifest.queries),
279
+ naming: {
280
+ naming: manifest.naming,
281
+ targets,
282
+ backend: manifest.queryBackend,
283
+ },
229
284
  },
230
285
  };
231
- this.#contexts.set(manifestDir, context);
232
- return context;
233
- } catch {
234
- this.#contexts.set(manifestDir, null);
235
- return null;
286
+ this.#contexts.set(manifestDir, lookup);
287
+ return lookup;
288
+ } catch (error) {
289
+ const lookup: ProjectLookup = { kind: 'error', error };
290
+ this.#contexts.set(manifestDir, lookup);
291
+ return lookup;
292
+ }
293
+ }
294
+
295
+ #openText(file: string): string | undefined {
296
+ for (const [uri, text] of this.#documents) {
297
+ if (resolve(uriToPath(uri)) === resolve(file)) return text;
236
298
  }
299
+ return undefined;
300
+ }
301
+
302
+ #compilerView(uri: string, text: string): CompilerView {
303
+ const file = resolve(uriToPath(uri));
304
+ const lookup = this.#context(uri);
305
+ if (lookup.kind === 'error') throw lookup.error;
306
+ const project = lookup.kind === 'ready' ? lookup.context : undefined;
307
+ const root =
308
+ project !== undefined && isWithin(project.queriesRoot, file)
309
+ ? project.queriesRoot
310
+ : dirname(file);
311
+ const sourceByFile = new Map<string, string>();
312
+ const entries: string[] = [];
313
+ if (project !== undefined && root === project.queriesRoot) {
314
+ for (const input of loadQueries(root)) {
315
+ if (!input.file.endsWith('.syql')) continue;
316
+ const absolute = resolve(root, input.file);
317
+ sourceByFile.set(absolute, this.#openText(absolute) ?? input.sql);
318
+ entries.push(input.file);
319
+ }
320
+ }
321
+ sourceByFile.set(file, text);
322
+ const currentEntry = relative(root, file).split(sep).join('/');
323
+ if (!entries.includes(currentEntry)) entries.push(currentEntry);
324
+ entries.sort();
325
+ const graph = buildSyqlModuleGraph(root, entries, (candidate) => {
326
+ const open = this.#openText(candidate);
327
+ if (open !== undefined) return open;
328
+ const loaded = sourceByFile.get(candidate);
329
+ if (loaded !== undefined) return loaded;
330
+ return existsSync(candidate)
331
+ ? readFileSync(candidate, 'utf8')
332
+ : undefined;
333
+ });
334
+ const semantic = analyzeSyqlSemantics(graph);
335
+ const module = graph.moduleByPath.get(file);
336
+ if (module === undefined)
337
+ throw new TypegenError(file, 'current SYQL module missing');
338
+ const lowered =
339
+ project === undefined
340
+ ? []
341
+ : validateSyqlProgram(
342
+ semantic,
343
+ project.ir,
344
+ project.db,
345
+ project.naming,
346
+ ).queries.map((query) =>
347
+ lowerSyqlQuery(query, project.ir, project.db, project.naming),
348
+ );
349
+ return { root, file, module, semantic, lowered };
237
350
  }
238
351
 
239
352
  #publishDiagnostics(uri: string): RpcMessage {
240
353
  const text = this.#documents.get(uri) ?? '';
241
- const diagnostics = this.#diagnose(uri, text);
242
354
  return {
243
355
  jsonrpc: '2.0',
244
356
  method: 'textDocument/publishDiagnostics',
245
- params: { uri, diagnostics },
357
+ params: { uri, diagnostics: this.#diagnose(uri, text) },
246
358
  };
247
359
  }
248
360
 
249
361
  #diagnose(uri: string, text: string): Diagnostic[] {
250
- const path = uriToPath(uri);
251
- const context = this.#context(uri);
252
362
  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
- }
363
+ this.#compilerView(uri, text);
262
364
  return [];
263
365
  } catch (error) {
264
- return [this.#errorToDiagnostic(error, text)];
366
+ return [this.#errorToDiagnostic(error, text, resolve(uriToPath(uri)))];
265
367
  }
266
368
  }
267
369
 
268
- #errorToDiagnostic(error: unknown, text: string): Diagnostic {
370
+ #errorToDiagnostic(error: unknown, text: string, file: string): Diagnostic {
269
371
  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
- }
372
+ if (
373
+ error instanceof SyqlFrontendError &&
374
+ resolve(error.sourceFile) === file
375
+ ) {
376
+ return {
377
+ range: spanRange(text, error.span),
378
+ severity: 1,
379
+ source: 'syncular',
380
+ code: error.code,
381
+ message,
382
+ };
300
383
  }
301
- return { range, severity: 1, source: 'syncular', message };
384
+ return {
385
+ range: {
386
+ start: { line: 0, character: 0 },
387
+ end: {
388
+ line: 0,
389
+ character: Math.max(1, text.split('\n')[0]?.length ?? 1),
390
+ },
391
+ },
392
+ severity: 1,
393
+ source: 'syncular',
394
+ ...(error instanceof SyqlFrontendError ? { code: error.code } : {}),
395
+ message:
396
+ this.#context(pathToFileURL(file).href).kind === 'error'
397
+ ? `SYQL9001_PROJECT_CONTEXT: ${message}`
398
+ : message,
399
+ };
302
400
  }
303
401
 
304
- #hover(params: unknown): unknown {
305
- const p = params as {
402
+ #request(params: unknown): {
403
+ readonly uri: string;
404
+ readonly text: string;
405
+ readonly offset: number;
406
+ readonly found: NonNullable<ReturnType<typeof wordAt>>;
407
+ readonly view: CompilerView;
408
+ } | null {
409
+ const request = params as {
306
410
  textDocument: { uri: string };
307
411
  position: Position;
308
412
  };
309
- const text = this.#documents.get(p.textDocument.uri);
413
+ const text = this.#documents.get(request.textDocument.uri);
310
414
  if (text === undefined) return null;
311
- const offset = positionToOffset(text, p.position);
415
+ const offset = positionToOffset(text, request.position);
312
416
  const found = wordAt(text, offset);
313
417
  if (found === null) return null;
314
-
315
- let parsed: ReturnType<typeof parseSyqlFile>;
316
418
  try {
317
- parsed = parseSyqlFile('doc', text);
419
+ return {
420
+ uri: request.textDocument.uri,
421
+ text,
422
+ offset,
423
+ found,
424
+ view: this.#compilerView(request.textDocument.uri, text),
425
+ };
318
426
  } catch {
319
427
  return null;
320
428
  }
429
+ }
321
430
 
322
- // `@fragment` ref → the fragment's body.
431
+ #hover(params: unknown): unknown {
432
+ const request = this.#request(params);
433
+ if (request === null) return null;
434
+ const { found, view, offset } = request;
435
+ if (found.word === '@scope' || found.word === '@cover') {
436
+ return {
437
+ contents: {
438
+ kind: 'markdown',
439
+ value:
440
+ found.word === '@scope'
441
+ ? '`@scope` constructs exact reactive dependency keys and the same SQL predicate.'
442
+ : '`@cover` constructs exact dependencies plus checked window coverage.',
443
+ },
444
+ };
445
+ }
323
446
  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;
447
+ const predicate = view.semantic.predicateScopes
448
+ .get(view.file)
449
+ ?.get(found.word.slice(1));
450
+ if (predicate === undefined) return null;
328
451
  return {
329
452
  contents: {
330
453
  kind: 'markdown',
331
- value: `\`\`\`sql\n${fragment.body}\n\`\`\``,
454
+ value: `predicate \`${predicate.declaration.name}\` from \`${relative(view.root, predicate.module.file)}\`\n\n\`\`\`sql\n${predicate.declaration.body.text.trim()}\n\`\`\``,
332
455
  },
333
456
  };
334
457
  }
335
-
336
- // A query name → the lowered, checked SQL (needs the project schema).
337
- const query = parsed.queries.find((q) => q.name === found.word);
458
+ const query = view.module.queries.find(
459
+ (candidate) =>
460
+ offset >= candidate.span.start.offset &&
461
+ offset <= candidate.span.end.offset,
462
+ );
338
463
  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;
464
+ const parameter = query.parameters.find(
465
+ (candidate) => candidate.name === found.word,
466
+ );
467
+ if (parameter !== undefined) {
468
+ return {
469
+ contents: { kind: 'markdown', value: parameterHover(parameter) },
470
+ };
471
+ }
472
+ if (query.sort?.control === found.word) {
473
+ return {
474
+ contents: {
475
+ kind: 'markdown',
476
+ value: `sort control \`${found.word}\`; default profile \`${query.sort.defaultProfile}\``,
477
+ },
478
+ };
363
479
  }
480
+ const profile = query.sort?.profiles.find(
481
+ (candidate) => candidate.name === found.word,
482
+ );
483
+ if (profile !== undefined) {
484
+ return {
485
+ contents: {
486
+ kind: 'markdown',
487
+ value: `sort profile \`${profile.name}\`\n\n\`\`\`sql\n${profile.order.text.trim()}\n\`\`\``,
488
+ },
489
+ };
490
+ }
491
+ if (query.page?.control === found.word) {
492
+ return {
493
+ contents: {
494
+ kind: 'markdown',
495
+ value: `page control \`${found.word}\`: default ${query.page.defaultSize}, maximum ${query.page.maxSize}`,
496
+ },
497
+ };
498
+ }
499
+ }
500
+ if (query?.name === found.word) {
501
+ const lowered = view.lowered.find(
502
+ (candidate) => candidate.validated.logical.declaration === query,
503
+ );
504
+ if (lowered === undefined) return null;
505
+ const defaultStatement = lowered.selected.statements.find(
506
+ (statement) =>
507
+ (statement.activationMask === undefined ||
508
+ statement.activationMask === 0) &&
509
+ (lowered.validated.sort === undefined ||
510
+ statement.sortProfile === lowered.validated.sort.defaultProfile),
511
+ );
512
+ return {
513
+ contents: {
514
+ kind: 'markdown',
515
+ value: [
516
+ `backend: **${lowered.selected.backend}** (${lowered.selected.statements.length} checked statement${lowered.selected.statements.length === 1 ? '' : 's'})`,
517
+ '',
518
+ '```sql',
519
+ defaultStatement?.sql ?? lowered.analysis.sql,
520
+ '```',
521
+ '',
522
+ `tables: ${lowered.analysis.tables.join(', ')}`,
523
+ ].join('\n'),
524
+ },
525
+ };
364
526
  }
365
527
  return null;
366
528
  }
367
529
 
368
530
  #definition(params: unknown): unknown {
369
- const p = params as {
370
- textDocument: { uri: string };
371
- position: Position;
531
+ const request = this.#request(params);
532
+ if (request === null || !request.found.word.startsWith('@')) return null;
533
+ const predicate = request.view.semantic.predicateScopes
534
+ .get(request.view.file)
535
+ ?.get(request.found.word.slice(1));
536
+ if (predicate === undefined) return null;
537
+ const text =
538
+ this.#openText(predicate.module.file) ?? predicate.module.source;
539
+ return {
540
+ uri: pathToFileURL(predicate.module.file).href,
541
+ range: spanRange(text, predicate.declaration.nameSpan),
372
542
  };
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>;
543
+ }
544
+
545
+ #references(params: unknown): unknown {
546
+ const request = this.#request(params);
547
+ if (request === null || !request.found.word.startsWith('@')) return [];
548
+ const target = request.view.semantic.predicateScopes
549
+ .get(request.view.file)
550
+ ?.get(request.found.word.slice(1));
551
+ if (target === undefined) return [];
552
+ const locations: unknown[] = [];
553
+ for (const module of request.view.semantic.graph.modules) {
554
+ const scope = request.view.semantic.predicateScopes.get(module.file);
555
+ for (const token of module.tokens) {
556
+ if (token.kind !== 'at-identifier') continue;
557
+ const resolved = scope?.get(token.text.slice(1));
558
+ if (resolved?.id !== target.id) continue;
559
+ locations.push({
560
+ uri: pathToFileURL(module.file).href,
561
+ range: spanRange(module.source, token.span),
562
+ });
563
+ }
564
+ }
565
+ return locations;
566
+ }
567
+
568
+ #symbols(params: unknown): unknown {
569
+ const request = params as { textDocument: { uri: string } };
570
+ const text = this.#documents.get(request.textDocument.uri);
571
+ if (text === undefined) return [];
379
572
  try {
380
- parsed = parseSyqlFile('doc', text);
573
+ const parsed = parseSyqlSyntaxFile(
574
+ uriToPath(request.textDocument.uri),
575
+ text,
576
+ );
577
+ return parsed.declarations.map((declaration) => ({
578
+ name: declaration.name,
579
+ kind: declaration.kind === 'query' ? 12 : 12,
580
+ range: spanRange(text, declaration.span),
581
+ selectionRange: spanRange(text, declaration.nameSpan),
582
+ }));
381
583
  } catch {
382
- return null;
584
+ return [];
383
585
  }
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,
586
+ }
587
+
588
+ #formatting(params: unknown): unknown {
589
+ const request = params as { textDocument: { uri: string } };
590
+ const text = this.#documents.get(request.textDocument.uri);
591
+ if (text === undefined) return [];
592
+ try {
593
+ const formatted = formatSyql(uriToPath(request.textDocument.uri), text);
594
+ if (formatted === text) return [];
595
+ return [
596
+ {
597
+ range: {
598
+ start: { line: 0, character: 0 },
599
+ end: offsetToPosition(text, text.length),
600
+ },
601
+ newText: formatted,
397
602
  },
398
- },
399
- };
603
+ ];
604
+ } catch {
605
+ return [];
606
+ }
400
607
  }
401
608
  }
402
609
 
403
- // ---------------------------------------------------------------------------
404
- // stdio transport (Content-Length framing)
405
- // ---------------------------------------------------------------------------
406
-
407
- /** Run the language server over stdio until `exit`. */
610
+ /** Run the LSP over Content-Length-framed JSON-RPC stdio. */
408
611
  export async function runLspStdio(): Promise<void> {
409
612
  const server = new SyqlLanguageServer();
410
613
  let buffer = Buffer.alloc(0);
@@ -429,17 +632,24 @@ export async function runLspStdio(): Promise<void> {
429
632
  continue;
430
633
  }
431
634
  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;
635
+ const bodyStart = headerEnd + 4;
636
+ if (buffer.length < bodyStart + length) break;
637
+ const body = buffer
638
+ .subarray(bodyStart, bodyStart + length)
639
+ .toString('utf8');
640
+ buffer = buffer.subarray(bodyStart + length);
641
+ let incoming: RpcMessage;
436
642
  try {
437
- message = JSON.parse(body.toString('utf8')) as RpcMessage;
643
+ incoming = JSON.parse(body) as RpcMessage;
438
644
  } catch {
645
+ write({
646
+ jsonrpc: '2.0',
647
+ error: { code: -32700, message: 'parse error' },
648
+ });
439
649
  continue;
440
650
  }
441
- if (message.method === 'exit') return;
442
- for (const outgoing of server.handle(message)) write(outgoing);
651
+ for (const outgoing of server.handle(incoming)) write(outgoing);
652
+ if (incoming.method === 'exit') return;
443
653
  }
444
654
  }
445
655
  }