@prisma-next/language-server 0.14.0-dev.17

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/src/server.ts ADDED
@@ -0,0 +1,378 @@
1
+ import { join } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { findNearestConfigPathForFile } from '@prisma-next/config-loader';
4
+ import type { SymbolTable } from '@prisma-next/psl-parser';
5
+ import { type FormatOptions, format } from '@prisma-next/psl-parser/format';
6
+ import {
7
+ type Connection,
8
+ type Diagnostic,
9
+ DiagnosticSeverity,
10
+ DidChangeWatchedFilesNotification,
11
+ type InitializeParams,
12
+ type InitializeResult,
13
+ RegistrationRequest,
14
+ TextDocumentSyncKind,
15
+ TextDocuments,
16
+ type TextEdit,
17
+ } from 'vscode-languageserver';
18
+ import { TextDocument } from 'vscode-languageserver-textdocument';
19
+ import { CONFIG_FILENAME, resolveConfigInputs } from './config-resolution';
20
+ import { ParseDiagnosticSeverity } from './diagnostic-mapping';
21
+ import type { PipelineInputs } from './pipeline';
22
+ import {
23
+ type CachedDocument,
24
+ createProjectArtifacts,
25
+ type ProjectArtifacts,
26
+ } from './project-artifacts';
27
+ import type { SchemaInputSet } from './schema-inputs';
28
+
29
+ export interface LanguageServer {
30
+ dispose(): void;
31
+ /**
32
+ * Exposed for future features (completion, semantic tokens); nothing consumes
33
+ * them yet.
34
+ */
35
+ getDocumentAst(uri: string): CachedDocument | undefined;
36
+ getProjectSymbolTable(uri: string): SymbolTable | undefined;
37
+ }
38
+
39
+ interface ProjectState {
40
+ readonly configPath: string;
41
+ readonly inputs: SchemaInputSet;
42
+ readonly formatter?: FormatOptions;
43
+ /**
44
+ * Resolved once per config and refreshed by the config-watch path — never
45
+ * rebuilt per document.
46
+ */
47
+ readonly controlStack: PipelineInputs;
48
+ readonly artifacts: ProjectArtifacts;
49
+ }
50
+
51
+ export function createServer(connection: Connection): LanguageServer {
52
+ const documents = new TextDocuments(TextDocument);
53
+ const projects = new Map<string, ProjectState>();
54
+ const projectLoads = new Map<string, Promise<ProjectState>>();
55
+ const documentConfigPaths = new Map<string, string>();
56
+ let rootPath = process.cwd();
57
+ let watchedConfigGlob = join(rootPath, '**', CONFIG_FILENAME);
58
+ let supportsWatchedFilesRegistration = false;
59
+
60
+ async function publish(uri: string, text: string): Promise<void> {
61
+ const project = await resolveProjectForDocument(uri);
62
+ if (project === undefined) {
63
+ return;
64
+ }
65
+ const currentDocument = documents.get(uri);
66
+ if (currentDocument === undefined) {
67
+ documentConfigPaths.delete(uri);
68
+ return;
69
+ }
70
+ if (currentDocument.getText() !== text) {
71
+ return;
72
+ }
73
+ const computed = project.artifacts.update(uri, text, project.inputs, project.controlStack);
74
+ if (computed === null) {
75
+ void connection.sendDiagnostics({ uri, diagnostics: [] });
76
+ return;
77
+ }
78
+ const diagnostics: Diagnostic[] = computed.map((diagnostic) => ({
79
+ range: diagnostic.range,
80
+ message: diagnostic.message,
81
+ code: diagnostic.code,
82
+ severity: toLspSeverity(diagnostic.severity),
83
+ source: 'prisma-next',
84
+ }));
85
+ void connection.sendDiagnostics({ uri, diagnostics });
86
+ }
87
+
88
+ async function resolveProjectForDocument(uri: string): Promise<ProjectState | undefined> {
89
+ const knownConfigPath = documentConfigPaths.get(uri);
90
+ if (knownConfigPath !== undefined) {
91
+ return resolveProject(knownConfigPath);
92
+ }
93
+
94
+ const filePath = filePathFromUri(uri);
95
+ if (filePath === undefined) {
96
+ return undefined;
97
+ }
98
+
99
+ const configPath = await findNearestConfigPathForFile(filePath);
100
+ if (configPath === undefined) {
101
+ return undefined;
102
+ }
103
+
104
+ documentConfigPaths.set(uri, configPath);
105
+ const project = await resolveProjectIfLoadable(configPath);
106
+ if (project === undefined) {
107
+ documentConfigPaths.delete(uri);
108
+ }
109
+ return project;
110
+ }
111
+
112
+ async function resolveProjectIfLoadable(configPath: string): Promise<ProjectState | undefined> {
113
+ try {
114
+ return await resolveProject(configPath);
115
+ } catch {
116
+ stopManagingProject(configPath);
117
+ return undefined;
118
+ }
119
+ }
120
+
121
+ async function resolveProject(configPath: string): Promise<ProjectState> {
122
+ const existing = projects.get(configPath);
123
+ if (existing !== undefined) {
124
+ return existing;
125
+ }
126
+ const existingLoad = projectLoads.get(configPath);
127
+ if (existingLoad !== undefined) {
128
+ return existingLoad;
129
+ }
130
+ return queueProjectLoad(configPath);
131
+ }
132
+
133
+ function refreshProject(configPath: string): Promise<ProjectState> {
134
+ return queueProjectLoad(configPath);
135
+ }
136
+
137
+ function queueProjectLoad(configPath: string): Promise<ProjectState> {
138
+ const previousLoad = projectLoads.get(configPath) ?? Promise.resolve();
139
+ const load = previousLoad
140
+ .catch(() => undefined)
141
+ .then(() => loadProject(configPath))
142
+ .finally(() => {
143
+ if (projectLoads.get(configPath) === load) {
144
+ projectLoads.delete(configPath);
145
+ }
146
+ });
147
+ projectLoads.set(configPath, load);
148
+ return load;
149
+ }
150
+
151
+ async function loadProject(configPath: string): Promise<ProjectState> {
152
+ const resolution = await resolveConfigInputs(configPath);
153
+ // Preserve open-document ASTs across config reloads; the project symbol table
154
+ // refreshes on the next publish against the new stack.
155
+ const artifacts = projects.get(configPath)?.artifacts ?? createProjectArtifacts();
156
+ const project: ProjectState =
157
+ resolution.formatter === undefined
158
+ ? {
159
+ configPath,
160
+ inputs: resolution.inputs,
161
+ controlStack: resolution.controlStack,
162
+ artifacts,
163
+ }
164
+ : {
165
+ configPath,
166
+ inputs: resolution.inputs,
167
+ formatter: resolution.formatter,
168
+ controlStack: resolution.controlStack,
169
+ artifacts,
170
+ };
171
+ projects.set(configPath, project);
172
+ return project;
173
+ }
174
+
175
+ function stopManagingProject(configPath: string): void {
176
+ const hadProject = projects.delete(configPath);
177
+ for (const document of documents.all()) {
178
+ if (documentConfigPaths.get(document.uri) === configPath) {
179
+ documentConfigPaths.delete(document.uri);
180
+ if (hadProject) {
181
+ void connection.sendDiagnostics({ uri: document.uri, diagnostics: [] });
182
+ }
183
+ }
184
+ }
185
+ }
186
+
187
+ async function republishOpenDocumentsForConfig(configPath: string): Promise<void> {
188
+ for (const document of documents.all()) {
189
+ const knownConfigPath = documentConfigPaths.get(document.uri);
190
+ if (knownConfigPath === configPath) {
191
+ await publish(document.uri, document.getText());
192
+ continue;
193
+ }
194
+
195
+ const filePath = filePathFromUri(document.uri);
196
+ if (filePath === undefined) {
197
+ continue;
198
+ }
199
+ const nearestConfigPath = await findNearestConfigPathForFile(filePath);
200
+ if (nearestConfigPath === configPath) {
201
+ documentConfigPaths.set(document.uri, configPath);
202
+ await publish(document.uri, document.getText());
203
+ }
204
+ }
205
+ }
206
+
207
+ function publishSafely(uri: string, text: string): void {
208
+ void publish(uri, text).catch((error: unknown) => {
209
+ connection.console.error(error instanceof Error ? error.message : String(error));
210
+ });
211
+ }
212
+
213
+ async function formatDocument(uri: string): Promise<TextEdit[]> {
214
+ const document = documents.get(uri);
215
+ if (document === undefined) {
216
+ return [];
217
+ }
218
+
219
+ let project: ProjectState | undefined;
220
+ try {
221
+ project = await resolveProjectForDocument(uri);
222
+ } catch {
223
+ return [];
224
+ }
225
+ if (project === undefined || !project.inputs.includes(uri)) {
226
+ return [];
227
+ }
228
+
229
+ const source = document.getText();
230
+ let formatted: string;
231
+ try {
232
+ formatted = format(source, project.formatter);
233
+ } catch {
234
+ return [];
235
+ }
236
+
237
+ if (formatted === source) {
238
+ return [];
239
+ }
240
+
241
+ return [
242
+ {
243
+ range: { start: { line: 0, character: 0 }, end: document.positionAt(source.length) },
244
+ newText: formatted,
245
+ },
246
+ ];
247
+ }
248
+
249
+ connection.onInitialize(async (params): Promise<InitializeResult> => {
250
+ rootPath = resolveRootPath(params.rootUri, params.rootPath);
251
+ watchedConfigGlob = join(rootPath, '**', CONFIG_FILENAME);
252
+ supportsWatchedFilesRegistration = clientSupportsWatchedFilesRegistration(params);
253
+
254
+ return {
255
+ capabilities: {
256
+ textDocumentSync: TextDocumentSyncKind.Incremental,
257
+ documentFormattingProvider: true,
258
+ },
259
+ };
260
+ });
261
+
262
+ connection.onInitialized(() => {
263
+ if (supportsWatchedFilesRegistration) {
264
+ void connection
265
+ .sendRequest(RegistrationRequest.type, {
266
+ registrations: [
267
+ {
268
+ id: 'prisma-next-config-watcher',
269
+ method: DidChangeWatchedFilesNotification.type.method,
270
+ registerOptions: { watchers: [{ globPattern: watchedConfigGlob }] },
271
+ },
272
+ ],
273
+ })
274
+ .catch(() => undefined);
275
+ } else {
276
+ connection.console.warn(
277
+ 'Client does not support dynamic file-watcher registration; Prisma Next config changes will not be picked up without a restart.',
278
+ );
279
+ }
280
+ });
281
+
282
+ connection.onDidChangeWatchedFiles(async (params) => {
283
+ const changedConfigPaths = configPathsFromWatchedChanges(
284
+ params.changes.map((change) => filePathFromUri(change.uri)),
285
+ );
286
+ for (const configPath of changedConfigPaths) {
287
+ try {
288
+ await refreshProject(configPath);
289
+ } catch {
290
+ stopManagingProject(configPath);
291
+ continue;
292
+ }
293
+ await republishOpenDocumentsForConfig(configPath);
294
+ }
295
+ });
296
+
297
+ connection.onDocumentFormatting((params) => formatDocument(params.textDocument.uri));
298
+
299
+ documents.onDidOpen((event) => {
300
+ publishSafely(event.document.uri, event.document.getText());
301
+ });
302
+ documents.onDidChangeContent((event) => {
303
+ publishSafely(event.document.uri, event.document.getText());
304
+ });
305
+ documents.onDidClose((event) => {
306
+ const uri = event.document.uri;
307
+ const configPath = documentConfigPaths.get(uri);
308
+ if (configPath !== undefined) {
309
+ projects.get(configPath)?.artifacts.remove(uri);
310
+ }
311
+ documentConfigPaths.delete(uri);
312
+ void connection.sendDiagnostics({ uri, diagnostics: [] });
313
+ });
314
+
315
+ documents.listen(connection);
316
+ connection.listen();
317
+
318
+ function artifactsForDocument(uri: string): ProjectArtifacts | undefined {
319
+ const configPath = documentConfigPaths.get(uri);
320
+ return configPath === undefined ? undefined : projects.get(configPath)?.artifacts;
321
+ }
322
+
323
+ return {
324
+ dispose: () => {
325
+ connection.dispose();
326
+ },
327
+ getDocumentAst: (uri) => artifactsForDocument(uri)?.getDocument(uri),
328
+ getProjectSymbolTable: (uri) => artifactsForDocument(uri)?.getSymbolTable(),
329
+ };
330
+ }
331
+
332
+ function toLspSeverity(severity: number): DiagnosticSeverity {
333
+ switch (severity) {
334
+ case ParseDiagnosticSeverity.Warning:
335
+ return DiagnosticSeverity.Warning;
336
+ case ParseDiagnosticSeverity.Information:
337
+ return DiagnosticSeverity.Information;
338
+ case ParseDiagnosticSeverity.Hint:
339
+ return DiagnosticSeverity.Hint;
340
+ default:
341
+ return DiagnosticSeverity.Error;
342
+ }
343
+ }
344
+
345
+ function clientSupportsWatchedFilesRegistration(params: InitializeParams): boolean {
346
+ return params.capabilities.workspace?.didChangeWatchedFiles?.dynamicRegistration === true;
347
+ }
348
+
349
+ function resolveRootPath(
350
+ rootUri: string | null | undefined,
351
+ rootPath: string | null | undefined,
352
+ ): string {
353
+ if (rootUri) {
354
+ return fileURLToPath(rootUri);
355
+ }
356
+ if (rootPath) {
357
+ return rootPath;
358
+ }
359
+ return process.cwd();
360
+ }
361
+
362
+ function filePathFromUri(uri: string): string | undefined {
363
+ try {
364
+ return fileURLToPath(uri);
365
+ } catch {
366
+ return undefined;
367
+ }
368
+ }
369
+
370
+ function configPathsFromWatchedChanges(paths: readonly (string | undefined)[]): Set<string> {
371
+ const configPaths = new Set<string>();
372
+ for (const path of paths) {
373
+ if (path?.endsWith(CONFIG_FILENAME)) {
374
+ configPaths.add(path);
375
+ }
376
+ }
377
+ return configPaths;
378
+ }
@@ -0,0 +1,7 @@
1
+ import { createConnection, ProposedFeatures } from 'vscode-languageserver/node';
2
+ import { createServer, type LanguageServer } from './server';
3
+
4
+ export function startServer(): LanguageServer {
5
+ const connection = createConnection(ProposedFeatures.all);
6
+ return createServer(connection);
7
+ }