@syncular/typegen 0.5.1 → 0.7.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 (56) hide show
  1. package/README.md +79 -32
  2. package/dist/cli.js +40 -17
  3. package/dist/emit-queries-dart.js +168 -55
  4. package/dist/emit-queries-kotlin.js +167 -55
  5. package/dist/emit-queries-swift.js +183 -57
  6. package/dist/emit-queries.js +286 -123
  7. package/dist/fmt.d.ts +2 -2
  8. package/dist/fmt.js +348 -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 +349 -187
  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 +110 -63
  19. package/dist/query.js +89 -11
  20. package/dist/syql-ast.d.ts +12 -13
  21. package/dist/syql-ast.js +26 -20
  22. package/dist/syql-lexer.d.ts +2 -0
  23. package/dist/syql-lexer.js +9 -2
  24. package/dist/syql-lowering.d.ts +22 -0
  25. package/dist/syql-lowering.js +434 -0
  26. package/dist/syql-parser.d.ts +19 -18
  27. package/dist/syql-parser.js +341 -93
  28. package/dist/syql-semantics.d.ts +73 -0
  29. package/dist/syql-semantics.js +607 -0
  30. package/dist/syql-template-parser.d.ts +5 -20
  31. package/dist/syql-template-parser.js +116 -109
  32. package/dist/syql-validator.d.ts +35 -0
  33. package/dist/syql-validator.js +993 -0
  34. package/package.json +3 -3
  35. package/src/cli.ts +49 -21
  36. package/src/emit-queries-dart.ts +195 -69
  37. package/src/emit-queries-kotlin.ts +197 -69
  38. package/src/emit-queries-swift.ts +224 -68
  39. package/src/emit-queries.ts +410 -165
  40. package/src/fmt.ts +405 -303
  41. package/src/generate.ts +43 -9
  42. package/src/index.ts +3 -1
  43. package/src/lsp.ts +414 -216
  44. package/src/manifest.ts +2 -4
  45. package/src/query-ir.ts +52 -42
  46. package/src/query.ts +229 -79
  47. package/src/syql-ast.ts +38 -34
  48. package/src/syql-lexer.ts +12 -1
  49. package/src/syql-lowering.ts +664 -0
  50. package/src/syql-parser.ts +472 -134
  51. package/src/syql-semantics.ts +930 -0
  52. package/src/syql-template-parser.ts +119 -163
  53. package/src/syql-validator.ts +1486 -0
  54. package/dist/syql.d.ts +0 -102
  55. package/dist/syql.js +0 -1141
  56. 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 === 'boolean' ? 'bool' : type.base}${type.nullable ? ' | null' : ''}`;
82
+ }
83
+ function parameterHover(parameter) {
84
+ if (parameter.kind === 'range') {
85
+ return `${parameter.optional ? 'optional' : 'required'} inclusive range \`${parameter.name}: range<${typeText(parameter.type)}>\``;
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' : parameter.default === false ? 'default-false' : '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) }];
130
147
  }
148
+ if (method === 'textDocument/documentSymbol') {
149
+ return [{ jsonrpc: '2.0', id, result: this.#symbols(message.params) }];
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,323 @@ 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;
210
+ }
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
+ }
176
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 };
177
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.
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;
330
+ }
331
+ #hover(params) {
332
+ const request = this.#request(params);
333
+ if (request === null)
334
+ return null;
335
+ const { found, view, offset } = request;
336
+ const predicate = view.semantic.predicateScopes
337
+ .get(view.file)
338
+ ?.get(found.word);
339
+ if (predicate !== undefined) {
265
340
  return {
266
341
  contents: {
267
342
  kind: 'markdown',
268
- value: `\`\`\`sql\n${fragment.body}\n\`\`\``,
343
+ value: `predicate \`${predicate.declaration.name}\` from \`${relative(view.root, predicate.module.file)}\`\n\n\`\`\`sql\n${predicate.declaration.body.text.trim()}\n\`\`\``,
269
344
  },
270
345
  };
271
346
  }
272
- // A query name the lowered, checked SQL (needs the project schema).
273
- const query = parsed.queries.find((q) => q.name === found.word);
347
+ const query = view.module.queries.find((candidate) => offset >= candidate.span.start.offset &&
348
+ offset <= candidate.span.end.offset);
274
349
  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') } };
350
+ const parameter = query.parameters.find((candidate) => candidate.name === found.word);
351
+ if (parameter !== undefined) {
352
+ return {
353
+ contents: { kind: 'markdown', value: parameterHover(parameter) },
354
+ };
293
355
  }
294
- catch {
295
- return null;
356
+ if (query.sort?.control === found.word) {
357
+ return {
358
+ contents: {
359
+ kind: 'markdown',
360
+ value: `sort control \`${found.word}\`; default profile \`${query.sort.defaultProfile}\``,
361
+ },
362
+ };
363
+ }
364
+ const profile = query.sort?.profiles.find((candidate) => candidate.name === found.word);
365
+ if (profile !== undefined) {
366
+ return {
367
+ contents: {
368
+ kind: 'markdown',
369
+ value: `sort profile \`${profile.name}\`\n\n\`\`\`sql\n${profile.order.text.trim()}\n\`\`\``,
370
+ },
371
+ };
372
+ }
373
+ if (query.limit?.control === found.word) {
374
+ return {
375
+ contents: {
376
+ kind: 'markdown',
377
+ value: `limit control \`${found.word}\`: default ${query.limit.defaultSize}, maximum ${query.limit.maxSize}`,
378
+ },
379
+ };
296
380
  }
297
381
  }
382
+ if (query?.name === found.word) {
383
+ const lowered = view.lowered.find((candidate) => candidate.validated.logical.declaration === query);
384
+ if (lowered === undefined)
385
+ return null;
386
+ const defaultStatement = lowered.selected.statements.find((statement) => (statement.activationMask === undefined ||
387
+ statement.activationMask === 0) &&
388
+ (lowered.validated.sort === undefined ||
389
+ statement.sortProfile === lowered.validated.sort.defaultProfile));
390
+ return {
391
+ contents: {
392
+ kind: 'markdown',
393
+ value: [
394
+ `backend: **${lowered.selected.backend}** (${lowered.selected.statements.length} checked statement${lowered.selected.statements.length === 1 ? '' : 's'})`,
395
+ '',
396
+ '```sql',
397
+ defaultStatement?.sql ?? lowered.analysis.sql,
398
+ '```',
399
+ '',
400
+ `tables: ${lowered.analysis.tables.join(', ')}`,
401
+ ].join('\n'),
402
+ },
403
+ };
404
+ }
298
405
  return null;
299
406
  }
300
407
  #definition(params) {
301
- const p = params;
302
- const text = this.#documents.get(p.textDocument.uri);
303
- if (text === undefined)
408
+ const request = this.#request(params);
409
+ if (request === null)
304
410
  return null;
305
- const offset = positionToOffset(text, p.position);
306
- const found = wordAt(text, offset);
307
- if (found === null || !found.word.startsWith('@'))
411
+ const predicate = request.view.semantic.predicateScopes
412
+ .get(request.view.file)
413
+ ?.get(request.found.word);
414
+ if (predicate === undefined)
308
415
  return null;
309
- let parsed;
416
+ const text = this.#openText(predicate.module.file) ?? predicate.module.source;
417
+ return {
418
+ uri: pathToFileURL(predicate.module.file).href,
419
+ range: spanRange(text, predicate.declaration.nameSpan),
420
+ };
421
+ }
422
+ #references(params) {
423
+ const request = this.#request(params);
424
+ if (request === null)
425
+ return [];
426
+ const target = request.view.semantic.predicateScopes
427
+ .get(request.view.file)
428
+ ?.get(request.found.word);
429
+ if (target === undefined)
430
+ return [];
431
+ const locations = [];
432
+ for (const module of request.view.semantic.graph.modules) {
433
+ const scope = request.view.semantic.predicateScopes.get(module.file);
434
+ for (const token of module.tokens) {
435
+ if (token.kind !== 'identifier')
436
+ continue;
437
+ const resolved = scope?.get(token.text);
438
+ if (resolved?.id !== target.id)
439
+ continue;
440
+ locations.push({
441
+ uri: pathToFileURL(module.file).href,
442
+ range: spanRange(module.source, token.span),
443
+ });
444
+ }
445
+ }
446
+ return locations;
447
+ }
448
+ #symbols(params) {
449
+ const request = params;
450
+ const text = this.#documents.get(request.textDocument.uri);
451
+ if (text === undefined)
452
+ return [];
310
453
  try {
311
- parsed = parseSyqlFile('doc', text);
454
+ const parsed = parseSyqlSyntaxFile(uriToPath(request.textDocument.uri), text);
455
+ return parsed.declarations.map((declaration) => ({
456
+ name: declaration.name,
457
+ kind: declaration.kind === 'query' ? 12 : 12,
458
+ range: spanRange(text, declaration.span),
459
+ selectionRange: spanRange(text, declaration.nameSpan),
460
+ }));
312
461
  }
313
462
  catch {
314
- return null;
463
+ return [];
315
464
  }
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,
465
+ }
466
+ #formatting(params) {
467
+ const request = params;
468
+ const text = this.#documents.get(request.textDocument.uri);
469
+ if (text === undefined)
470
+ return [];
471
+ try {
472
+ const formatted = formatSyql(uriToPath(request.textDocument.uri), text);
473
+ if (formatted === text)
474
+ return [];
475
+ return [
476
+ {
477
+ range: {
478
+ start: { line: 0, character: 0 },
479
+ end: offsetToPosition(text, text.length),
480
+ },
481
+ newText: formatted,
327
482
  },
328
- },
329
- };
483
+ ];
484
+ }
485
+ catch {
486
+ return [];
487
+ }
330
488
  }
331
489
  }
332
- // ---------------------------------------------------------------------------
333
- // stdio transport (Content-Length framing)
334
- // ---------------------------------------------------------------------------
335
- /** Run the language server over stdio until `exit`. */
490
+ /** Run the LSP over Content-Length-framed JSON-RPC stdio. */
336
491
  export async function runLspStdio() {
337
492
  const server = new SyqlLanguageServer();
338
493
  let buffer = Buffer.alloc(0);
@@ -356,21 +511,28 @@ export async function runLspStdio() {
356
511
  continue;
357
512
  }
358
513
  const length = Number.parseInt(lengthMatch[1], 10);
359
- if (buffer.length < headerEnd + 4 + length)
514
+ const bodyStart = headerEnd + 4;
515
+ if (buffer.length < bodyStart + length)
360
516
  break;
361
- const body = buffer.subarray(headerEnd + 4, headerEnd + 4 + length);
362
- buffer = buffer.subarray(headerEnd + 4 + length);
363
- let message;
517
+ const body = buffer
518
+ .subarray(bodyStart, bodyStart + length)
519
+ .toString('utf8');
520
+ buffer = buffer.subarray(bodyStart + length);
521
+ let incoming;
364
522
  try {
365
- message = JSON.parse(body.toString('utf8'));
523
+ incoming = JSON.parse(body);
366
524
  }
367
525
  catch {
526
+ write({
527
+ jsonrpc: '2.0',
528
+ error: { code: -32700, message: 'parse error' },
529
+ });
368
530
  continue;
369
531
  }
370
- if (message.method === 'exit')
371
- return;
372
- for (const outgoing of server.handle(message))
532
+ for (const outgoing of server.handle(incoming))
373
533
  write(outgoing);
534
+ if (incoming.method === 'exit')
535
+ return;
374
536
  }
375
537
  }
376
538
  }