@domainlang/language 0.9.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
@@ -142,6 +142,17 @@ export type {
142
142
  RelationshipView,
143
143
  } from './types.js';
144
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
+
145
156
  // Node.js-specific exports (will fail in browser environments)
146
157
  export { loadModel } from './loader-node.js';
147
158
  export { validateFile, validateWorkspace } from './validator.js';
@@ -0,0 +1,213 @@
1
+ /**
2
+ * AST Serialization Utilities
3
+ *
4
+ * Converts Langium AST nodes to plain JSON objects suitable for:
5
+ * - LSP custom requests (JSON-RPC transport)
6
+ * - MCP tool responses (stdio JSON)
7
+ * - CLI output (JSON/YAML formats)
8
+ *
9
+ * ## Strategy
10
+ *
11
+ * Rather than maintaining a parallel DTO type hierarchy (DomainDto, BoundedContextDto, etc.),
12
+ * we use a **generic serializer** that:
13
+ * - Strips Langium internal properties ($container, $cstNode, $document)
14
+ * - Preserves $type for discriminated output
15
+ * - Resolves Reference<T> to referenced name strings
16
+ * - Resolves MultiReference<T> to arrays of names
17
+ * - Recursively serializes child AstNodes
18
+ * - Adds FQN for named elements via Query
19
+ *
20
+ * For types with SDK-augmented properties (computed values not on raw AST),
21
+ * use augmentation functions that enrich the generic output.
22
+ *
23
+ * @packageDocumentation
24
+ */
25
+
26
+ import type { AstNode, Reference } from 'langium';
27
+ import { isAstNode, isReference } from 'langium';
28
+ import type { Query, RelationshipView } from './types.js';
29
+
30
+ // ─────────────────────────────────────────────────────────────────────────────
31
+ // Types
32
+ // ─────────────────────────────────────────────────────────────────────────────
33
+
34
+ /**
35
+ * Canonical entity types that can be queried.
36
+ * Moved from CLI to SDK for sharing with LSP tools.
37
+ */
38
+ export type QueryEntityType =
39
+ | 'domains'
40
+ | 'bcs'
41
+ | 'teams'
42
+ | 'classifications'
43
+ | 'relationships'
44
+ | 'context-maps'
45
+ | 'domain-maps';
46
+
47
+ /**
48
+ * All accepted entity type names, including aliases.
49
+ * Aliases are normalized to canonical types before query execution.
50
+ */
51
+ export type QueryEntityInput = QueryEntityType
52
+ | 'bounded-contexts' | 'contexts'
53
+ | 'rels'
54
+ | 'cmaps'
55
+ | 'dmaps';
56
+
57
+ /**
58
+ * Query filter options.
59
+ * Moved from CLI to SDK for sharing with LSP tools.
60
+ */
61
+ export interface QueryFilters {
62
+ /** Filter by name (string or regex) */
63
+ name?: string;
64
+ /** Filter by fully qualified name */
65
+ fqn?: string;
66
+ /** Filter BCs by domain */
67
+ domain?: string;
68
+ /** Filter BCs by team */
69
+ team?: string;
70
+ /** Filter BCs by classification */
71
+ classification?: string;
72
+ /** Filter BCs by metadata key=value */
73
+ metadata?: string;
74
+ }
75
+
76
+ /**
77
+ * Map of entity type aliases to their canonical form.
78
+ */
79
+ export const ENTITY_ALIASES: Record<string, QueryEntityType> = {
80
+ 'bounded-contexts': 'bcs',
81
+ 'contexts': 'bcs',
82
+ 'rels': 'relationships',
83
+ 'cmaps': 'context-maps',
84
+ 'dmaps': 'domain-maps',
85
+ };
86
+
87
+ /**
88
+ * Normalize an entity type input (which may be an alias) to its canonical form.
89
+ */
90
+ export function normalizeEntityType(input: string): QueryEntityType {
91
+ if (input in ENTITY_ALIASES) {
92
+ return ENTITY_ALIASES[input];
93
+ }
94
+ return input as QueryEntityType;
95
+ }
96
+
97
+ // ─────────────────────────────────────────────────────────────────────────────
98
+ // Generic AST Serialization
99
+ // ─────────────────────────────────────────────────────────────────────────────
100
+
101
+ /**
102
+ * Serialize any Langium AST node to a plain JSON object.
103
+ *
104
+ * - Strips $-prefixed internal properties ($container, $cstNode, $document)
105
+ * - Preserves $type for discriminated output
106
+ * - Resolves Reference<T> to the referenced name (string)
107
+ * - Resolves MultiReference<T> to an array of names
108
+ * - Recursively serializes child AstNode properties
109
+ * - Serializes arrays of AstNodes/values
110
+ * - Adds FQN for named elements
111
+ *
112
+ * @param node - AST node to serialize
113
+ * @param query - Query instance for FQN resolution
114
+ * @returns Plain JSON object
115
+ */
116
+ export function serializeNode(node: AstNode, query: Query): Record<string, unknown> {
117
+ const result: Record<string, unknown> = { $type: node.$type };
118
+
119
+ for (const [key, value] of Object.entries(node)) {
120
+ // Skip Langium internals (but preserve $type)
121
+ if (key.startsWith('$') && key !== '$type') {
122
+ continue;
123
+ }
124
+
125
+ if (isReference(value)) {
126
+ // Reference<T> → name string
127
+ const ref = value.ref;
128
+ result[key] = (ref && 'name' in ref) ? (ref as { name?: string }).name : value.$refText;
129
+ } else if (isAstNode(value)) {
130
+ // Nested AstNode → recurse
131
+ result[key] = serializeNode(value, query);
132
+ } else if (Array.isArray(value)) {
133
+ // Array → map each item
134
+ result[key] = value.map(item => {
135
+ if (isReference(item)) {
136
+ const itemRef = item.ref;
137
+ return (itemRef && 'name' in itemRef) ? (itemRef as { name?: string }).name : item.$refText;
138
+ } else if (isAstNode(item)) {
139
+ return serializeNode(item, query);
140
+ } else {
141
+ return item; // primitive
142
+ }
143
+ });
144
+ } else {
145
+ // Primitives pass through
146
+ result[key] = value;
147
+ }
148
+ }
149
+
150
+ // Always include FQN for named elements
151
+ if ('name' in node && typeof (node as { name?: unknown }).name === 'string') {
152
+ result.fqn = query.fqn(node);
153
+ }
154
+
155
+ return result;
156
+ }
157
+
158
+ /**
159
+ * Augment a serialized RelationshipView with computed properties.
160
+ *
161
+ * RelationshipView is already a clean DTO (not an AstNode), but we format it
162
+ * consistently with other serialized types.
163
+ *
164
+ * @param view - RelationshipView from query.relationships()
165
+ * @returns Serialized relationship object
166
+ */
167
+ export function serializeRelationship(view: RelationshipView): Record<string, unknown> {
168
+ // RelationshipView.left and .right are BoundedContext (which have name property)
169
+ const leftName = view.left.name;
170
+ const rightName = view.right.name;
171
+ return {
172
+ $type: 'Relationship',
173
+ name: `${leftName} ${view.arrow} ${rightName}`,
174
+ left: leftName,
175
+ right: rightName,
176
+ arrow: view.arrow,
177
+ leftPatterns: view.leftPatterns,
178
+ rightPatterns: view.rightPatterns,
179
+ inferredType: view.inferredType,
180
+ };
181
+ }
182
+
183
+ // ─────────────────────────────────────────────────────────────────────────────
184
+ // Helper: Resolve Reference
185
+ // ─────────────────────────────────────────────────────────────────────────────
186
+
187
+ /**
188
+ * Resolve a Reference<T> to its name string.
189
+ * Returns undefined if reference is unresolved.
190
+ *
191
+ * @param ref - Reference to resolve
192
+ * @returns Referenced name or undefined
193
+ */
194
+ export function resolveName<T extends AstNode & { name?: string }>(ref: Reference<T> | undefined): string | undefined {
195
+ if (!ref) return undefined;
196
+ return ref.ref?.name ?? ref.$refText;
197
+ }
198
+
199
+ /**
200
+ * Resolve a MultiReference (array of items with refs) to an array of names.
201
+ * Filters out unresolved references.
202
+ *
203
+ * @param multiRef - Array of items with ref property
204
+ * @returns Array of resolved names
205
+ */
206
+ export function resolveMultiReference<T extends { ref?: Reference<AstNode & { name?: string }> }>(
207
+ multiRef: T[] | undefined
208
+ ): string[] {
209
+ if (!multiRef) return [];
210
+ return multiRef
211
+ .map(item => item.ref?.ref?.name)
212
+ .filter((name): name is string => name !== undefined);
213
+ }