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