@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.
- package/README.md +75 -33
- package/dist/cli.js +43 -17
- package/dist/emit-queries-dart.js +167 -55
- package/dist/emit-queries-kotlin.js +166 -55
- package/dist/emit-queries-swift.js +182 -57
- package/dist/emit-queries.js +289 -123
- package/dist/fmt.d.ts +2 -2
- package/dist/fmt.js +323 -316
- package/dist/generate.d.ts +1 -1
- package/dist/generate.js +28 -9
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/lsp.d.ts +1 -3
- package/dist/lsp.js +359 -185
- package/dist/manifest.d.ts +1 -3
- package/dist/manifest.js +1 -1
- package/dist/query-ir.js +53 -42
- package/dist/query.d.ts +115 -63
- package/dist/query.js +60 -11
- package/dist/syql-lexer.d.ts +2 -0
- package/dist/syql-lexer.js +9 -2
- package/dist/syql-lowering.d.ts +22 -0
- package/dist/syql-lowering.js +411 -0
- package/dist/syql-semantics.d.ts +76 -0
- package/dist/syql-semantics.js +551 -0
- package/dist/syql-validator.d.ts +35 -0
- package/dist/syql-validator.js +966 -0
- package/package.json +3 -3
- package/src/cli.ts +51 -21
- package/src/emit-queries-dart.ts +195 -69
- package/src/emit-queries-kotlin.ts +197 -69
- package/src/emit-queries-swift.ts +224 -68
- package/src/emit-queries.ts +413 -165
- package/src/fmt.ts +377 -303
- package/src/generate.ts +43 -9
- package/src/index.ts +3 -1
- package/src/lsp.ts +425 -215
- package/src/manifest.ts +2 -4
- package/src/query-ir.ts +52 -42
- package/src/query.ts +199 -79
- package/src/syql-lexer.ts +12 -1
- package/src/syql-lowering.ts +638 -0
- package/src/syql-semantics.ts +859 -0
- package/src/syql-validator.ts +1492 -0
- package/dist/syql.d.ts +0 -102
- package/dist/syql.js +0 -1141
- 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 {
|
|
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 {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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;
|
|
48
|
+
severity: number;
|
|
55
49
|
source: string;
|
|
50
|
+
code?: string;
|
|
56
51
|
message: string;
|
|
57
52
|
}
|
|
58
53
|
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
68
|
-
if (text[
|
|
79
|
+
for (let index = 0; index < clamped; index += 1) {
|
|
80
|
+
if (text[index] === '\n') {
|
|
69
81
|
line += 1;
|
|
70
|
-
lineStart =
|
|
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
|
|
79
|
-
while (
|
|
80
|
-
if (text[
|
|
81
|
-
|
|
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,
|
|
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
|
-
): {
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
118
|
-
for (let
|
|
119
|
-
if (existsSync(resolve(
|
|
120
|
-
const parent = dirname(
|
|
121
|
-
if (parent ===
|
|
122
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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 === '
|
|
184
|
-
return [{ jsonrpc: '2.0', id, result:
|
|
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):
|
|
251
|
+
#context(uri: string): ProjectLookup {
|
|
204
252
|
const path = uriToPath(uri);
|
|
205
253
|
const manifestDir = findManifestDir(path);
|
|
206
|
-
if (manifestDir === null) return
|
|
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
|
|
216
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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,
|
|
232
|
-
return
|
|
233
|
-
} catch {
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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 {
|
|
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
|
-
#
|
|
305
|
-
|
|
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(
|
|
413
|
+
const text = this.#documents.get(request.textDocument.uri);
|
|
310
414
|
if (text === undefined) return null;
|
|
311
|
-
const offset = positionToOffset(text,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
325
|
-
(
|
|
326
|
-
|
|
327
|
-
if (
|
|
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:
|
|
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
|
-
|
|
337
|
-
|
|
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
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
const
|
|
377
|
-
if (
|
|
378
|
-
|
|
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 =
|
|
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
|
|
584
|
+
return [];
|
|
383
585
|
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
const
|
|
389
|
-
return
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
start
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
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
|
-
|
|
442
|
-
|
|
651
|
+
for (const outgoing of server.handle(incoming)) write(outgoing);
|
|
652
|
+
if (incoming.method === 'exit') return;
|
|
443
653
|
}
|
|
444
654
|
}
|
|
445
655
|
}
|