@domainlang/language 0.8.0 → 0.10.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.
@@ -0,0 +1,443 @@
1
+ /**
2
+ * LSP Custom Request Handlers for VS Code Language Model Tools (PRS-015)
3
+ *
4
+ * These handlers respond to custom LSP requests from the VS Code extension
5
+ * and return serialized model data suitable for Language Model Tools.
6
+ *
7
+ * Architecture:
8
+ * - Extension calls `client.sendRequest('domainlang/validate', params)`
9
+ * - LSP receives via `connection.onRequest('domainlang/validate', handler)`
10
+ * - Handler uses SDK `fromServices()` for zero-copy AST access
11
+ * - Handler returns plain JSON (no circular refs, no class instances)
12
+ *
13
+ * @module lsp/tool-handlers
14
+ */
15
+
16
+ import type { Connection } from 'vscode-languageserver';
17
+ import type { LangiumDocument } from 'langium';
18
+ import type { LangiumSharedServices } from 'langium/lsp';
19
+ import { URI } from 'langium';
20
+ import { fromDocument } from '../sdk/query.js';
21
+ import type { Query } from '../sdk/types.js';
22
+ import {
23
+ serializeNode,
24
+ serializeRelationship,
25
+ normalizeEntityType,
26
+ } from '../sdk/serializers.js';
27
+ import type { QueryEntityType, QueryFilters } from '../sdk/serializers.js';
28
+ import type { Model } from '../generated/ast.js';
29
+ import { generateExplanation } from './explain.js';
30
+
31
+ // ─────────────────────────────────────────────────────────────────────────────
32
+ // Request/Response Types
33
+ // ─────────────────────────────────────────────────────────────────────────────
34
+
35
+ /**
36
+ * Request parameters for domainlang/validate.
37
+ * No parameters needed - validates entire workspace.
38
+ */
39
+ export interface ValidateParams {
40
+ /** Optional: filter by file URI */
41
+ file?: string;
42
+ }
43
+
44
+ /**
45
+ * Response from domainlang/validate.
46
+ */
47
+ export interface ValidateResponse {
48
+ /** Total number of validation diagnostics */
49
+ count: number;
50
+ /** Validation diagnostics grouped by severity */
51
+ diagnostics: {
52
+ errors: DiagnosticInfo[];
53
+ warnings: DiagnosticInfo[];
54
+ info: DiagnosticInfo[];
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Diagnostic information for validation response.
60
+ */
61
+ export interface DiagnosticInfo {
62
+ /** File URI */
63
+ file: string;
64
+ /** Line number (1-indexed) */
65
+ line: number;
66
+ /** Column number (1-indexed) */
67
+ column: number;
68
+ /** Diagnostic message */
69
+ message: string;
70
+ /** Severity level */
71
+ severity: 'error' | 'warning' | 'info';
72
+ /** Optional diagnostic code */
73
+ code?: string | number;
74
+ }
75
+
76
+ /**
77
+ * Request parameters for domainlang/list.
78
+ */
79
+ export interface ListParams {
80
+ /** Entity type to query */
81
+ type: string;
82
+ /** Optional filters */
83
+ filters?: QueryFilters;
84
+ }
85
+
86
+ /**
87
+ * Response from domainlang/list.
88
+ */
89
+ export interface ListResponse {
90
+ /** Entity type queried */
91
+ entityType: QueryEntityType;
92
+ /** Number of results */
93
+ count: number;
94
+ /** Serialized results */
95
+ results: Record<string, unknown>[];
96
+ }
97
+
98
+ /**
99
+ * Request parameters for domainlang/get.
100
+ */
101
+ export interface GetParams {
102
+ /** Fully qualified name of element to retrieve */
103
+ fqn?: string;
104
+ /** If true, return model summary instead of single element */
105
+ summary?: boolean;
106
+ }
107
+
108
+ /**
109
+ * Response from domainlang/get.
110
+ */
111
+ export interface GetResponse {
112
+ /** Serialized element or model summary */
113
+ result: Record<string, unknown> | null;
114
+ }
115
+
116
+ /**
117
+ * Request parameters for domainlang/explain.
118
+ */
119
+ export interface ExplainParams {
120
+ /** Fully qualified name of element to explain */
121
+ fqn: string;
122
+ }
123
+
124
+ /**
125
+ * Response from domainlang/explain.
126
+ */
127
+ export interface ExplainResponse {
128
+ /** Rich markdown explanation */
129
+ explanation: string;
130
+ }
131
+
132
+ // ─────────────────────────────────────────────────────────────────────────────
133
+ // Handler Registration
134
+ // ─────────────────────────────────────────────────────────────────────────────
135
+
136
+ /**
137
+ * Registers all custom request handlers on the LSP connection.
138
+ * Call this from main.ts after creating the connection.
139
+ *
140
+ * @param connection - LSP connection
141
+ * @param sharedServices - Langium shared services for workspace access
142
+ */
143
+ export function registerToolHandlers(
144
+ connection: Connection,
145
+ sharedServices: LangiumSharedServices
146
+ ): void {
147
+ connection.onRequest('domainlang/validate', async (params: ValidateParams) => {
148
+ return handleValidate(params, sharedServices);
149
+ });
150
+
151
+ connection.onRequest('domainlang/list', async (params: ListParams) => {
152
+ return handleList(params, sharedServices);
153
+ });
154
+
155
+ connection.onRequest('domainlang/get', async (params: GetParams) => {
156
+ return handleGet(params, sharedServices);
157
+ });
158
+
159
+ connection.onRequest('domainlang/explain', async (params: ExplainParams) => {
160
+ return handleExplain(params, sharedServices);
161
+ });
162
+ }
163
+
164
+ // ─────────────────────────────────────────────────────────────────────────────
165
+ // Handler Implementations
166
+ // ─────────────────────────────────────────────────────────────────────────────
167
+
168
+ /**
169
+ * Handles domainlang/validate requests.
170
+ * Aggregates all validation diagnostics from the workspace.
171
+ */
172
+ async function handleValidate(
173
+ params: ValidateParams,
174
+ sharedServices: LangiumSharedServices
175
+ ): Promise<ValidateResponse> {
176
+ try {
177
+ const langiumDocs = sharedServices.workspace.LangiumDocuments;
178
+ const documents = params.file
179
+ ? [langiumDocs.getDocument(URI.parse(params.file))]
180
+ : Array.from(langiumDocs.all);
181
+
182
+ const errors: DiagnosticInfo[] = [];
183
+ const warnings: DiagnosticInfo[] = [];
184
+ const info: DiagnosticInfo[] = [];
185
+
186
+ for (const doc of documents) {
187
+ if (!doc) continue;
188
+
189
+ const diagnostics = doc.diagnostics ?? [];
190
+ for (const diag of diagnostics) {
191
+ const diagInfo: DiagnosticInfo = {
192
+ file: doc.uri.toString(),
193
+ line: diag.range.start.line + 1, // 1-indexed
194
+ column: diag.range.start.character + 1, // 1-indexed
195
+ message: diag.message,
196
+ severity: severityToString(diag.severity ?? 1),
197
+ code: diag.code,
198
+ };
199
+
200
+ if (diag.severity === 1) {
201
+ errors.push(diagInfo);
202
+ } else if (diag.severity === 2) {
203
+ warnings.push(diagInfo);
204
+ } else {
205
+ info.push(diagInfo);
206
+ }
207
+ }
208
+ }
209
+
210
+ return {
211
+ count: errors.length + warnings.length + info.length,
212
+ diagnostics: { errors, warnings, info },
213
+ };
214
+ } catch (error) {
215
+ console.error('domainlang/validate handler error:', error);
216
+ return { count: 0, diagnostics: { errors: [], warnings: [], info: [] } };
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Handles domainlang/list requests.
222
+ * Queries entities of a specific type and returns serialized results.
223
+ */
224
+ async function handleList(
225
+ params: ListParams,
226
+ sharedServices: LangiumSharedServices
227
+ ): Promise<ListResponse> {
228
+ try {
229
+ const entityType = normalizeEntityType(params.type);
230
+ const filters = params.filters ?? {};
231
+
232
+ // Get all documents and merge results
233
+ const langiumDocs = sharedServices.workspace.LangiumDocuments;
234
+ const documents = Array.from(langiumDocs.all);
235
+
236
+ const allResults: Record<string, unknown>[] = [];
237
+ const seen = new Set<string>(); // Deduplicate by FQN
238
+
239
+ for (const doc of documents) {
240
+ const query = fromDocument(doc as LangiumDocument<Model>);
241
+ const results = executeListQuery(query, entityType, filters);
242
+
243
+ for (const result of results) {
244
+ const fqn = result.fqn as string | undefined;
245
+ if (fqn && seen.has(fqn)) continue;
246
+ if (fqn) seen.add(fqn);
247
+ allResults.push(result);
248
+ }
249
+ }
250
+
251
+ return {
252
+ entityType,
253
+ count: allResults.length,
254
+ results: allResults,
255
+ };
256
+ } catch (error) {
257
+ console.error('domainlang/list handler error:', error);
258
+ return { entityType: normalizeEntityType(params.type), count: 0, results: [] };
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Handles domainlang/get requests.
264
+ * Retrieves a single element by FQN or returns a model summary.
265
+ */
266
+ async function handleGet(
267
+ params: GetParams,
268
+ sharedServices: LangiumSharedServices
269
+ ): Promise<GetResponse> {
270
+ try {
271
+ if (params.summary) {
272
+ return { result: await getModelSummary(sharedServices) };
273
+ }
274
+
275
+ if (!params.fqn) {
276
+ return { result: null };
277
+ }
278
+
279
+ // Search all documents for the element
280
+ const langiumDocs = sharedServices.workspace.LangiumDocuments;
281
+ const documents = Array.from(langiumDocs.all);
282
+
283
+ for (const doc of documents) {
284
+ const query = fromDocument(doc as LangiumDocument<Model>);
285
+ const element = query.byFqn(params.fqn);
286
+ if (element) {
287
+ return { result: serializeNode(element, query) };
288
+ }
289
+ }
290
+
291
+ return { result: null };
292
+ } catch (error) {
293
+ console.error('domainlang/get handler error:', error);
294
+ return { result: null };
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Handles domainlang/explain requests.
300
+ * Returns rich markdown explanation of a model element.
301
+ */
302
+ async function handleExplain(
303
+ params: ExplainParams,
304
+ sharedServices: LangiumSharedServices
305
+ ): Promise<ExplainResponse> {
306
+ try {
307
+ const langiumDocs = sharedServices.workspace.LangiumDocuments;
308
+ const documents = Array.from(langiumDocs.all);
309
+
310
+ for (const doc of documents) {
311
+ const query = fromDocument(doc as LangiumDocument<Model>);
312
+ const element = query.byFqn(params.fqn);
313
+ if (element) {
314
+ const explanation = generateExplanation(element);
315
+ return { explanation };
316
+ }
317
+ }
318
+
319
+ return {
320
+ explanation: `Element not found: ${params.fqn}`,
321
+ };
322
+ } catch (error) {
323
+ console.error('domainlang/explain handler error:', error);
324
+ return { explanation: `Error explaining element: ${params.fqn}` };
325
+ }
326
+ }
327
+
328
+ // ─────────────────────────────────────────────────────────────────────────────
329
+ // Helper Functions
330
+ // ─────────────────────────────────────────────────────────────────────────────
331
+
332
+ /**
333
+ * Executes a list query for a specific entity type.
334
+ */
335
+ function executeListQuery(
336
+ query: Query,
337
+ entityType: QueryEntityType,
338
+ filters: QueryFilters
339
+ ): Record<string, unknown>[] {
340
+ switch (entityType) {
341
+ case 'domains': {
342
+ let builder = query.domains();
343
+ if (filters.name) builder = builder.withName(filters.name);
344
+ if (filters.fqn) builder = builder.withFqn(filters.fqn);
345
+ return builder.toArray().map((d) => serializeNode(d, query));
346
+ }
347
+ case 'bcs': {
348
+ let builder = query.boundedContexts();
349
+ if (filters.domain) builder = builder.inDomain(filters.domain);
350
+ if (filters.team) builder = builder.withTeam(filters.team);
351
+ if (filters.classification)
352
+ builder = builder.withClassification(filters.classification);
353
+ if (filters.metadata) {
354
+ const [key, value] = filters.metadata.split('=');
355
+ builder = builder.withMetadata(key, value);
356
+ }
357
+ if (filters.name) builder = builder.withName(filters.name) as ReturnType<Query['boundedContexts']>;
358
+ if (filters.fqn) builder = builder.withFqn(filters.fqn) as ReturnType<Query['boundedContexts']>;
359
+ return builder.toArray().map((bc) => serializeNode(bc, query));
360
+ }
361
+ case 'teams': {
362
+ let builder = query.teams();
363
+ if (filters.name) builder = builder.withName(filters.name);
364
+ return builder.toArray().map((t) => serializeNode(t, query));
365
+ }
366
+ case 'classifications': {
367
+ let builder = query.classifications();
368
+ if (filters.name) builder = builder.withName(filters.name);
369
+ return builder.toArray().map((c) => serializeNode(c, query));
370
+ }
371
+ case 'relationships': {
372
+ const rels = query.relationships().toArray();
373
+ return rels.map((r) => serializeRelationship(r));
374
+ }
375
+ case 'context-maps': {
376
+ let builder = query.contextMaps();
377
+ if (filters.name) builder = builder.withName(filters.name);
378
+ return builder.toArray().map((cm) => serializeNode(cm, query));
379
+ }
380
+ case 'domain-maps': {
381
+ let builder = query.domainMaps();
382
+ if (filters.name) builder = builder.withName(filters.name);
383
+ return builder.toArray().map((dm) => serializeNode(dm, query));
384
+ }
385
+ default:
386
+ return [];
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Generates a model summary with counts of major entities.
392
+ */
393
+ async function getModelSummary(
394
+ sharedServices: LangiumSharedServices
395
+ ): Promise<Record<string, unknown>> {
396
+ const langiumDocs = sharedServices.workspace.LangiumDocuments;
397
+ const documents = Array.from(langiumDocs.all);
398
+
399
+ let domains = 0;
400
+ let bcs = 0;
401
+ let teams = 0;
402
+ let classifications = 0;
403
+ let relationships = 0;
404
+ let contextMaps = 0;
405
+ let domainMaps = 0;
406
+
407
+ for (const doc of documents) {
408
+ const query = fromDocument(doc as LangiumDocument<Model>);
409
+ domains += query.domains().count();
410
+ bcs += query.boundedContexts().count();
411
+ teams += query.teams().count();
412
+ classifications += query.classifications().count();
413
+ relationships += query.relationships().count();
414
+ contextMaps += query.contextMaps().count();
415
+ domainMaps += query.domainMaps().count();
416
+ }
417
+
418
+ return {
419
+ $type: 'ModelSummary',
420
+ documentCount: documents.length,
421
+ domains,
422
+ boundedContexts: bcs,
423
+ teams,
424
+ classifications,
425
+ relationships,
426
+ contextMaps,
427
+ domainMaps,
428
+ };
429
+ }
430
+
431
+ /**
432
+ * Converts diagnostic severity number to string.
433
+ */
434
+ function severityToString(severity: number): 'error' | 'warning' | 'info' {
435
+ switch (severity) {
436
+ case 1:
437
+ return 'error';
438
+ case 2:
439
+ return 'warning';
440
+ default:
441
+ return 'info';
442
+ }
443
+ }
package/src/main.ts CHANGED
@@ -4,6 +4,7 @@ import { createConnection, ProposedFeatures, FileChangeType } from 'vscode-langu
4
4
  import { createDomainLangServices } from './domain-lang-module.js';
5
5
  import { ensureImportGraphFromEntryFile } from './utils/import-utils.js';
6
6
  import { DomainLangIndexManager } from './lsp/domain-lang-index-manager.js';
7
+ import { registerToolHandlers } from './lsp/tool-handlers.js';
7
8
  import { URI } from 'langium';
8
9
 
9
10
  // Create a connection to the client
@@ -12,6 +13,9 @@ const connection = createConnection(ProposedFeatures.all);
12
13
  // Inject the shared services and language-specific services
13
14
  const { shared, DomainLang } = createDomainLangServices({ connection, ...NodeFileSystem });
14
15
 
16
+ // Register custom LSP request handlers for VS Code Language Model Tools (PRS-015)
17
+ registerToolHandlers(connection, shared);
18
+
15
19
  // Initialize workspace manager when language server initializes
16
20
  // Uses Langium's LanguageServer.onInitialize hook (not raw connection handler)
17
21
  // This integrates properly with Langium's initialization flow
package/src/sdk/index.ts CHANGED
@@ -16,6 +16,7 @@
16
16
  * - Fluent query chains with lazy iteration
17
17
  * - O(1) indexed lookups by FQN/name
18
18
  * - Resolution rules (which block wins for 0..1 properties)
19
+ * - File validation (Node.js only, via `validateFile()`)
19
20
  *
20
21
  * **Entry points for different deployment targets:**
21
22
  *
@@ -23,27 +24,24 @@
23
24
  * |--------|-------------|--------------|-------|
24
25
  * | VS Code Extension | `fromDocument()` | ✅ | Zero-copy LSP integration |
25
26
  * | Web Editor | `fromDocument()`, `loadModelFromText()` | ✅ | Browser-compatible |
26
- * | CLI (Node.js) | `loadModel()` from `sdk/loader-node` | ❌ | File system access |
27
+ * | CLI (Node.js) | `loadModel()`, `validateFile()` | ❌ | File system access |
27
28
  * | Hosted LSP | `fromDocument()`, `fromServices()` | ✅ | Server-side only |
28
29
  * | Testing | `loadModelFromText()` | ✅ | In-memory parsing |
29
30
  *
30
31
  * ## Browser vs Node.js
31
32
  *
32
- * This module (`sdk/index`) is **browser-safe** and exports only:
33
- * - `loadModelFromText()` - uses EmptyFileSystem
34
- * - `fromModel()`, `fromDocument()`, `fromServices()` - zero-copy wrappers
33
+ * Most of this module is **browser-safe**, but Node.js-specific functions are exported as well:
34
+ * - `loadModel()` - requires Node.js file system (uses NodeFileSystem)
35
+ * - `validateFile()` - requires Node.js file system (uses NodeFileSystem)
35
36
  *
36
- * For file-based loading in Node.js CLI tools:
37
- * ```typescript
38
- * import { loadModel } from 'domain-lang-language/sdk/loader-node';
39
- * ```
37
+ * These will fail at runtime in browser environments.
40
38
  *
41
39
  * @packageDocumentation
42
40
  *
43
41
  * @example
44
42
  * ```typescript
45
- * // Node.js CLI: Load from file (requires sdk/loader-node)
46
- * import { loadModel } from 'domain-lang-language/sdk/loader-node';
43
+ * // Node.js CLI: Load from file
44
+ * import { loadModel } from '@domainlang/language/sdk';
47
45
  *
48
46
  * const { query } = await loadModel('./domains.dlang', {
49
47
  * workspaceDir: process.cwd()
@@ -60,6 +58,24 @@
60
58
  *
61
59
  * @example
62
60
  * ```typescript
61
+ * // Node.js CLI: Validate a model (requires sdk/loader-node)
62
+ * import { validateFile } from '@domainlang/language/sdk';
63
+ *
64
+ * const result = await validateFile('./domains.dlang');
65
+ *
66
+ * if (!result.valid) {
67
+ * for (const error of result.errors) {
68
+ * console.error(`${error.file}:${error.line}: ${error.message}`);
69
+ * }
70
+ * process.exit(1);
71
+ * }
72
+ *
73
+ * console.log(`✓ Validated ${result.fileCount} files`);
74
+ * console.log(` ${result.domainCount} domains, ${result.bcCount} bounded contexts`);
75
+ * ```
76
+ *
77
+ * @example
78
+ * ```typescript
63
79
  * // Browser/Testing: Load from text (browser-safe)
64
80
  * import { loadModelFromText } from '@domainlang/language/sdk';
65
81
  *
@@ -125,3 +141,19 @@ export type {
125
141
  BcQueryBuilder,
126
142
  RelationshipView,
127
143
  } from './types.js';
144
+
145
+ // Serializers for tool responses (browser-safe)
146
+ export {
147
+ serializeNode,
148
+ serializeRelationship,
149
+ resolveName,
150
+ resolveMultiReference,
151
+ normalizeEntityType,
152
+ ENTITY_ALIASES,
153
+ } from './serializers.js';
154
+ export type { QueryEntityType, QueryEntityInput, QueryFilters } from './serializers.js';
155
+
156
+ // Node.js-specific exports (will fail in browser environments)
157
+ export { loadModel } from './loader-node.js';
158
+ export { validateFile, validateWorkspace } from './validator.js';
159
+ export type { ValidationResult, ValidationDiagnostic, ValidationOptions, WorkspaceValidationResult } from './validator.js';
@@ -145,3 +145,7 @@ export async function loadModel(
145
145
  query: fromModel(model),
146
146
  };
147
147
  }
148
+
149
+ // Re-export validation utilities
150
+ export { validateFile } from './validator.js';
151
+ export type { ValidationResult, ValidationDiagnostic, ValidationOptions } from './validator.js';