@domainlang/language 0.7.0 → 0.9.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 (88) hide show
  1. package/out/domain-lang-module.d.ts +2 -0
  2. package/out/domain-lang-module.js +21 -2
  3. package/out/domain-lang-module.js.map +1 -1
  4. package/out/lsp/domain-lang-completion.d.ts +142 -1
  5. package/out/lsp/domain-lang-completion.js +620 -22
  6. package/out/lsp/domain-lang-completion.js.map +1 -1
  7. package/out/lsp/domain-lang-document-symbol-provider.d.ts +79 -0
  8. package/out/lsp/domain-lang-document-symbol-provider.js +210 -0
  9. package/out/lsp/domain-lang-document-symbol-provider.js.map +1 -0
  10. package/out/lsp/domain-lang-index-manager.d.ts +34 -5
  11. package/out/lsp/domain-lang-index-manager.js +66 -27
  12. package/out/lsp/domain-lang-index-manager.js.map +1 -1
  13. package/out/lsp/domain-lang-node-kind-provider.d.ts +27 -0
  14. package/out/lsp/domain-lang-node-kind-provider.js +87 -0
  15. package/out/lsp/domain-lang-node-kind-provider.js.map +1 -0
  16. package/out/lsp/domain-lang-scope-provider.d.ts +53 -20
  17. package/out/lsp/domain-lang-scope-provider.js +119 -44
  18. package/out/lsp/domain-lang-scope-provider.js.map +1 -1
  19. package/out/lsp/domain-lang-workspace-manager.d.ts +23 -2
  20. package/out/lsp/domain-lang-workspace-manager.js +51 -6
  21. package/out/lsp/domain-lang-workspace-manager.js.map +1 -1
  22. package/out/lsp/hover/domain-lang-hover.d.ts +16 -6
  23. package/out/lsp/hover/domain-lang-hover.js +160 -134
  24. package/out/lsp/hover/domain-lang-hover.js.map +1 -1
  25. package/out/lsp/hover/hover-builders.d.ts +57 -0
  26. package/out/lsp/hover/hover-builders.js +171 -0
  27. package/out/lsp/hover/hover-builders.js.map +1 -0
  28. package/out/main.js +2 -1
  29. package/out/main.js.map +1 -1
  30. package/out/sdk/index.d.ts +31 -11
  31. package/out/sdk/index.js +30 -11
  32. package/out/sdk/index.js.map +1 -1
  33. package/out/sdk/loader-node.d.ts +2 -0
  34. package/out/sdk/loader-node.js +3 -1
  35. package/out/sdk/loader-node.js.map +1 -1
  36. package/out/sdk/loader.d.ts +55 -2
  37. package/out/sdk/loader.js +87 -28
  38. package/out/sdk/loader.js.map +1 -1
  39. package/out/sdk/query.js +14 -11
  40. package/out/sdk/query.js.map +1 -1
  41. package/out/sdk/validator.d.ts +134 -0
  42. package/out/sdk/validator.js +249 -0
  43. package/out/sdk/validator.js.map +1 -0
  44. package/out/services/package-boundary-detector.d.ts +101 -0
  45. package/out/services/package-boundary-detector.js +211 -0
  46. package/out/services/package-boundary-detector.js.map +1 -0
  47. package/out/services/performance-optimizer.js +6 -2
  48. package/out/services/performance-optimizer.js.map +1 -1
  49. package/out/services/types.d.ts +24 -0
  50. package/out/services/types.js.map +1 -1
  51. package/out/services/workspace-manager.d.ts +73 -6
  52. package/out/services/workspace-manager.js +210 -57
  53. package/out/services/workspace-manager.js.map +1 -1
  54. package/out/utils/import-utils.d.ts +9 -6
  55. package/out/utils/import-utils.js +26 -15
  56. package/out/utils/import-utils.js.map +1 -1
  57. package/out/validation/constants.d.ts +7 -0
  58. package/out/validation/constants.js +21 -3
  59. package/out/validation/constants.js.map +1 -1
  60. package/out/validation/import.d.ts +11 -1
  61. package/out/validation/import.js +42 -14
  62. package/out/validation/import.js.map +1 -1
  63. package/out/validation/maps.js +50 -1
  64. package/out/validation/maps.js.map +1 -1
  65. package/package.json +8 -9
  66. package/src/domain-lang-module.ts +24 -3
  67. package/src/lsp/domain-lang-completion.ts +736 -27
  68. package/src/lsp/domain-lang-document-symbol-provider.ts +254 -0
  69. package/src/lsp/domain-lang-index-manager.ts +79 -27
  70. package/src/lsp/domain-lang-node-kind-provider.ts +119 -0
  71. package/src/lsp/domain-lang-scope-provider.ts +171 -55
  72. package/src/lsp/domain-lang-workspace-manager.ts +64 -6
  73. package/src/lsp/hover/domain-lang-hover.ts +189 -131
  74. package/src/lsp/hover/hover-builders.ts +208 -0
  75. package/src/main.ts +3 -1
  76. package/src/sdk/index.ts +33 -11
  77. package/src/sdk/loader-node.ts +6 -1
  78. package/src/sdk/loader.ts +125 -34
  79. package/src/sdk/query.ts +15 -11
  80. package/src/sdk/validator.ts +358 -0
  81. package/src/services/package-boundary-detector.ts +238 -0
  82. package/src/services/performance-optimizer.ts +6 -2
  83. package/src/services/types.ts +25 -0
  84. package/src/services/workspace-manager.ts +259 -62
  85. package/src/utils/import-utils.ts +27 -15
  86. package/src/validation/constants.ts +23 -6
  87. package/src/validation/import.ts +49 -14
  88. package/src/validation/maps.ts +59 -2
@@ -32,9 +32,29 @@ interface LoadedLockFile {
32
32
  readonly filePath: string;
33
33
  }
34
34
 
35
+ /**
36
+ * Cached context for a single workspace (directory containing model.yaml).
37
+ * Each workspace root has its own independent state.
38
+ */
39
+ interface WorkspaceContext {
40
+ /** The resolved workspace root path */
41
+ readonly root: string;
42
+ /** Cached lock file for this workspace */
43
+ lockFile: LockFile | undefined;
44
+ /** Cached manifest for this workspace */
45
+ manifestCache: ManifestCache | undefined;
46
+ /** Initialization promise for this context */
47
+ initPromise: Promise<void> | undefined;
48
+ }
49
+
35
50
  /**
36
51
  * Coordinates workspace discovery and manifest/lock file reading.
37
52
  *
53
+ * **Multi-Root Support:**
54
+ * Maintains separate contexts for each workspace root (directory with model.yaml).
55
+ * This enables correct resolution in multi-project setups where sub-projects
56
+ * have their own model.yaml files.
57
+ *
38
58
  * This is a read-only service for the LSP - it does NOT:
39
59
  * - Generate lock files (use CLI: `dlang install`)
40
60
  * - Download packages (use CLI: `dlang install`)
@@ -48,23 +68,88 @@ interface LoadedLockFile {
48
68
  export class WorkspaceManager {
49
69
  private readonly manifestFiles: readonly string[];
50
70
  private readonly lockFiles: readonly string[];
51
- private workspaceRoot: string | undefined;
52
- private lockFile: LockFile | undefined;
53
- private initializePromise: Promise<void> | undefined;
54
- private manifestCache: ManifestCache | undefined;
71
+
72
+ /**
73
+ * Cache of workspace contexts by resolved workspace root path.
74
+ * Supports multiple independent workspaces in a single session.
75
+ */
76
+ private readonly workspaceContexts = new Map<string, WorkspaceContext>();
77
+
78
+ /**
79
+ * Cache mapping start paths to their resolved workspace roots.
80
+ * Avoids repeated directory tree walking for the same paths.
81
+ */
82
+ private readonly pathToRootCache = new Map<string, string>();
83
+
84
+ /**
85
+ * The currently active workspace root (set by last initialize() call).
86
+ * Used by methods like getWorkspaceRoot(), getManifest(), etc.
87
+ */
88
+ private activeRoot: string | undefined;
55
89
 
56
90
  constructor(options: WorkspaceManagerOptions = {}) {
57
91
  this.manifestFiles = options.manifestFiles ?? [...DEFAULT_MANIFEST_FILES];
58
92
  this.lockFiles = options.lockFiles ?? [...DEFAULT_LOCK_FILES];
59
93
  }
60
94
 
95
+ /**
96
+ * Returns the active workspace context, or undefined if not initialized.
97
+ * All methods that need context should call this after ensureInitialized().
98
+ */
99
+ private getActiveContext(): WorkspaceContext | undefined {
100
+ if (!this.activeRoot) return undefined;
101
+ return this.workspaceContexts.get(this.activeRoot);
102
+ }
103
+
61
104
  /**
62
105
  * Finds the workspace root and loads any existing lock file.
63
- * Repeated calls await the same initialization work.
106
+ *
107
+ * **Multi-Root Support:**
108
+ * Each call may switch to a different workspace context based on the startPath.
109
+ * The workspace root is the nearest ancestor directory containing model.yaml.
110
+ *
111
+ * @param startPath - Directory to start searching from (usually document directory)
64
112
  */
65
113
  async initialize(startPath: string): Promise<void> {
66
- this.initializePromise ??= this.performInitialization(startPath);
67
- await this.initializePromise;
114
+ const normalizedStart = path.resolve(startPath);
115
+
116
+ // Fast path: check if we've already resolved this path
117
+ let workspaceRoot = this.pathToRootCache.get(normalizedStart);
118
+
119
+ if (!workspaceRoot) {
120
+ // Find workspace root by walking up directory tree
121
+ workspaceRoot = await this.findWorkspaceRoot(normalizedStart) ?? normalizedStart;
122
+ this.pathToRootCache.set(normalizedStart, workspaceRoot);
123
+ }
124
+
125
+ // Switch to this workspace's context
126
+ this.activeRoot = workspaceRoot;
127
+
128
+ // Get or create context for this workspace
129
+ let context = this.workspaceContexts.get(workspaceRoot);
130
+ if (!context) {
131
+ context = {
132
+ root: workspaceRoot,
133
+ lockFile: undefined,
134
+ manifestCache: undefined,
135
+ initPromise: undefined
136
+ };
137
+ this.workspaceContexts.set(workspaceRoot, context);
138
+ }
139
+
140
+ // Initialize this context (lazy, once per context)
141
+ context.initPromise ??= this.initializeContext(context);
142
+ await context.initPromise;
143
+ }
144
+
145
+ /**
146
+ * Initializes a workspace context by loading its lock file.
147
+ */
148
+ private async initializeContext(context: WorkspaceContext): Promise<void> {
149
+ const loaded = await this.loadLockFileFromDisk(context.root);
150
+ if (loaded) {
151
+ context.lockFile = loaded.lockFile;
152
+ }
68
153
  }
69
154
 
70
155
  /**
@@ -72,21 +157,53 @@ export class WorkspaceManager {
72
157
  * @throws Error if {@link initialize} has not completed successfully.
73
158
  */
74
159
  getWorkspaceRoot(): string {
75
- if (!this.workspaceRoot) {
160
+ if (!this.activeRoot) {
76
161
  throw new Error('WorkspaceManager not initialized. Call initialize() first.');
77
162
  }
78
- return this.workspaceRoot;
163
+ return this.activeRoot;
79
164
  }
80
165
 
81
166
  /**
82
167
  * Returns the project-local package cache directory.
83
168
  * Per PRS-010: .dlang/packages/
169
+ *
170
+ * If the current workspace root is inside a cached package,
171
+ * walks up to find the actual project root's cache directory.
84
172
  */
85
173
  getCacheDir(): string {
86
- if (!this.workspaceRoot) {
174
+ if (!this.activeRoot) {
87
175
  throw new Error('WorkspaceManager not initialized. Call initialize() first.');
88
176
  }
89
- return path.join(this.workspaceRoot, '.dlang', 'packages');
177
+
178
+ // If workspace root is inside .dlang/packages, find the project root
179
+ const projectRoot = this.findProjectRootFromCache(this.activeRoot);
180
+ return path.join(projectRoot, '.dlang', 'packages');
181
+ }
182
+
183
+ /**
184
+ * Finds the actual project root when inside a cached package.
185
+ *
186
+ * Cached packages are stored in: <project>/.dlang/packages/<owner>/<repo>/<commit>/
187
+ * If workspaceRoot is inside this structure, returns <project>
188
+ * Otherwise returns workspaceRoot unchanged.
189
+ */
190
+ private findProjectRootFromCache(currentRoot: string): string {
191
+ // Normalize path for cross-platform compatibility
192
+ const normalized = currentRoot.split(path.sep);
193
+
194
+ // Find last occurrence of .dlang in the path
195
+ const dlangIndex = normalized.lastIndexOf('.dlang');
196
+
197
+ // Check if we're inside .dlang/packages/...
198
+ if (dlangIndex !== -1 &&
199
+ dlangIndex + 1 < normalized.length &&
200
+ normalized[dlangIndex + 1] === 'packages') {
201
+ // Return the directory containing .dlang (the project root)
202
+ return normalized.slice(0, dlangIndex).join(path.sep);
203
+ }
204
+
205
+ // Not in a cached package, return as-is
206
+ return currentRoot;
90
207
  }
91
208
 
92
209
  /**
@@ -94,7 +211,7 @@ export class WorkspaceManager {
94
211
  */
95
212
  async getManifestPath(): Promise<string | undefined> {
96
213
  await this.ensureInitialized();
97
- const root = this.workspaceRoot;
214
+ const root = this.activeRoot;
98
215
  if (!root) {
99
216
  return undefined;
100
217
  }
@@ -118,13 +235,39 @@ export class WorkspaceManager {
118
235
  return this.loadManifest();
119
236
  }
120
237
 
238
+ /**
239
+ * Returns the cached manifest synchronously (if available).
240
+ * Used by LSP features that need synchronous access (like completion).
241
+ * Returns undefined if manifest hasn't been loaded yet.
242
+ */
243
+ getCachedManifest(): ModelManifest | undefined {
244
+ return this.getActiveContext()?.manifestCache?.manifest;
245
+ }
246
+
247
+ /**
248
+ * Ensures the manifest is loaded and returns it.
249
+ * Use this over getCachedManifest() when you need to guarantee the manifest
250
+ * is available (e.g., in async LSP operations like completions).
251
+ *
252
+ * @returns The manifest or undefined if no model.yaml exists
253
+ */
254
+ async ensureManifestLoaded(): Promise<ModelManifest | undefined> {
255
+ // If we already have a cached manifest, return it immediately
256
+ const context = this.getActiveContext();
257
+ if (context?.manifestCache?.manifest) {
258
+ return context.manifestCache.manifest;
259
+ }
260
+ // Otherwise load it (this also populates the cache)
261
+ return this.getManifest();
262
+ }
263
+
121
264
  /**
122
265
  * Gets the currently cached lock file.
123
266
  * Returns undefined if no lock file exists (run `dlang install` to create one).
124
267
  */
125
268
  async getLockFile(): Promise<LockFile | undefined> {
126
269
  await this.ensureInitialized();
127
- return this.lockFile;
270
+ return this.getActiveContext()?.lockFile;
128
271
  }
129
272
 
130
273
  /**
@@ -132,13 +275,12 @@ export class WorkspaceManager {
132
275
  */
133
276
  async refreshLockFile(): Promise<LockFile | undefined> {
134
277
  await this.ensureInitialized();
278
+ const context = this.getActiveContext();
135
279
  const loaded = await this.loadLockFileFromDisk();
136
- if (loaded) {
137
- this.lockFile = loaded.lockFile;
138
- } else {
139
- this.lockFile = undefined;
280
+ if (context) {
281
+ context.lockFile = loaded?.lockFile;
140
282
  }
141
- return this.lockFile;
283
+ return loaded?.lockFile;
142
284
  }
143
285
 
144
286
  /**
@@ -149,8 +291,11 @@ export class WorkspaceManager {
149
291
  * will re-read from disk.
150
292
  */
151
293
  invalidateCache(): void {
152
- this.manifestCache = undefined;
153
- this.lockFile = undefined;
294
+ const context = this.getActiveContext();
295
+ if (context) {
296
+ context.manifestCache = undefined;
297
+ context.lockFile = undefined;
298
+ }
154
299
  }
155
300
 
156
301
  /**
@@ -158,7 +303,10 @@ export class WorkspaceManager {
158
303
  * Call this when model.yaml changes.
159
304
  */
160
305
  invalidateManifestCache(): void {
161
- this.manifestCache = undefined;
306
+ const context = this.getActiveContext();
307
+ if (context) {
308
+ context.manifestCache = undefined;
309
+ }
162
310
  }
163
311
 
164
312
  /**
@@ -166,7 +314,10 @@ export class WorkspaceManager {
166
314
  * Call this when model.lock changes.
167
315
  */
168
316
  invalidateLockCache(): void {
169
- this.lockFile = undefined;
317
+ const context = this.getActiveContext();
318
+ if (context) {
319
+ context.lockFile = undefined;
320
+ }
170
321
  }
171
322
 
172
323
  /**
@@ -209,7 +360,8 @@ export class WorkspaceManager {
209
360
  async resolveDependencyPath(specifier: string): Promise<string | undefined> {
210
361
  await this.ensureInitialized();
211
362
 
212
- if (!this.lockFile) {
363
+ const context = this.getActiveContext();
364
+ if (!context?.lockFile) {
213
365
  return undefined;
214
366
  }
215
367
 
@@ -236,7 +388,7 @@ export class WorkspaceManager {
236
388
  // Match if specifier equals key or starts with key/
237
389
  if (specifier === key || specifier.startsWith(`${key}/`)) {
238
390
  // Find in lock file
239
- const locked = this.lockFile.dependencies[normalized.source];
391
+ const locked = context.lockFile.dependencies[normalized.source];
240
392
  if (!locked) {
241
393
  return undefined;
242
394
  }
@@ -275,36 +427,34 @@ export class WorkspaceManager {
275
427
  }
276
428
  }
277
429
 
278
- private async performInitialization(startPath: string): Promise<void> {
279
- this.workspaceRoot = await this.findWorkspaceRoot(startPath) ?? path.resolve(startPath);
280
- const loaded = await this.loadLockFileFromDisk();
281
- if (loaded) {
282
- this.lockFile = loaded.lockFile;
283
- }
284
- }
285
-
286
430
  private async ensureInitialized(): Promise<void> {
287
- if (this.initializePromise) {
288
- await this.initializePromise;
289
- } else if (!this.workspaceRoot) {
290
- throw new Error('WorkspaceManager not initialized. Call initialize() first.');
431
+ // Check if we have an active workspace context
432
+ if (this.activeRoot) {
433
+ const context = this.workspaceContexts.get(this.activeRoot);
434
+ if (context?.initPromise) {
435
+ await context.initPromise;
436
+ return;
437
+ }
291
438
  }
439
+
440
+ throw new Error('WorkspaceManager not initialized. Call initialize() first.');
292
441
  }
293
442
 
294
- private async loadLockFileFromDisk(): Promise<LoadedLockFile | undefined> {
295
- if (!this.workspaceRoot) {
443
+ private async loadLockFileFromDisk(root?: string): Promise<LoadedLockFile | undefined> {
444
+ const workspaceRoot = root ?? this.activeRoot;
445
+ if (!workspaceRoot) {
296
446
  return undefined;
297
447
  }
298
448
 
299
449
  // Try performance optimizer cache first
300
450
  const optimizer = getGlobalOptimizer();
301
- const cached = await optimizer.getCachedLockFile(this.workspaceRoot);
451
+ const cached = await optimizer.getCachedLockFile(workspaceRoot);
302
452
  if (cached) {
303
- return { lockFile: cached, filePath: path.join(this.workspaceRoot, 'model.lock') };
453
+ return { lockFile: cached, filePath: path.join(workspaceRoot, 'model.lock') };
304
454
  }
305
455
 
306
456
  for (const filename of this.lockFiles) {
307
- const filePath = path.join(this.workspaceRoot, filename);
457
+ const filePath = path.join(workspaceRoot, filename);
308
458
  const lockFile = await this.tryReadLockFile(filePath);
309
459
  if (lockFile) {
310
460
  return { lockFile, filePath };
@@ -327,37 +477,84 @@ export class WorkspaceManager {
327
477
  }
328
478
 
329
479
  private async loadManifest(): Promise<ModelManifest | undefined> {
480
+ const context = this.getActiveContext();
330
481
  const manifestPath = await this.getManifestPath();
331
482
  if (!manifestPath) {
332
- this.manifestCache = undefined;
483
+ this.clearManifestCache(context);
333
484
  return undefined;
334
485
  }
335
486
 
336
487
  try {
337
- const stat = await fs.stat(manifestPath);
338
- if (this.manifestCache?.path === manifestPath &&
339
- this.manifestCache.mtimeMs === stat.mtimeMs) {
340
- return this.manifestCache.manifest;
341
- }
488
+ return await this.readAndCacheManifest(manifestPath, context);
489
+ } catch (error) {
490
+ return this.handleManifestError(error, manifestPath, context);
491
+ }
492
+ }
342
493
 
343
- const content = await fs.readFile(manifestPath, 'utf-8');
344
- const manifest = (YAML.parse(content) ?? {}) as ModelManifest;
345
-
346
- // Validate manifest structure
347
- this.validateManifest(manifest, manifestPath);
348
-
349
- this.manifestCache = {
494
+ /**
495
+ * Reads, validates, and caches a manifest file.
496
+ */
497
+ private async readAndCacheManifest(
498
+ manifestPath: string,
499
+ context: WorkspaceContext | undefined
500
+ ): Promise<ModelManifest> {
501
+ const stat = await fs.stat(manifestPath);
502
+ if (context?.manifestCache?.path === manifestPath &&
503
+ context.manifestCache.mtimeMs === stat.mtimeMs) {
504
+ return context.manifestCache.manifest;
505
+ }
506
+
507
+ const content = await fs.readFile(manifestPath, 'utf-8');
508
+ const manifest = (YAML.parse(content) ?? {}) as ModelManifest;
509
+
510
+ // Validate manifest structure
511
+ this.validateManifest(manifest, manifestPath);
512
+
513
+ if (context) {
514
+ context.manifestCache = {
350
515
  manifest,
351
516
  path: manifestPath,
352
517
  mtimeMs: stat.mtimeMs,
353
518
  };
354
- return manifest;
355
- } catch (error) {
356
- if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
357
- this.manifestCache = undefined;
358
- return undefined;
359
- }
360
- throw error;
519
+ }
520
+ return manifest;
521
+ }
522
+
523
+ /**
524
+ * Handles errors from manifest loading, distinguishing recoverable
525
+ * errors (missing file, parse errors) from unexpected ones.
526
+ */
527
+ private handleManifestError(
528
+ error: unknown,
529
+ manifestPath: string,
530
+ context: WorkspaceContext | undefined
531
+ ): ModelManifest | undefined {
532
+ if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
533
+ this.clearManifestCache(context);
534
+ return undefined;
535
+ }
536
+ // YAML parse errors should not crash the LSP
537
+ if (error instanceof Error &&
538
+ (error.name === 'YAMLParseError' || error.name === 'YAMLSyntaxError')) {
539
+ console.error(`Invalid model.yaml at ${manifestPath}: ${error.message}`);
540
+ this.clearManifestCache(context);
541
+ return undefined;
542
+ }
543
+ // Validation errors from validateManifest should not crash the LSP
544
+ if (error instanceof Error) {
545
+ console.error(`Manifest validation error at ${manifestPath}: ${error.message}`);
546
+ this.clearManifestCache(context);
547
+ return undefined;
548
+ }
549
+ throw error;
550
+ }
551
+
552
+ /**
553
+ * Clears the manifest cache on the given context, if available.
554
+ */
555
+ private clearManifestCache(context: WorkspaceContext | undefined): void {
556
+ if (context) {
557
+ context.manifestCache = undefined;
361
558
  }
362
559
  }
363
560
 
@@ -451,7 +648,7 @@ export class WorkspaceManager {
451
648
  // Resolve path relative to manifest directory
452
649
  const manifestDir = path.dirname(manifestPath);
453
650
  const resolvedPath = path.resolve(manifestDir, localPath);
454
- const workspaceRoot = this.workspaceRoot || manifestDir;
651
+ const workspaceRoot = this.activeRoot || manifestDir;
455
652
 
456
653
  // Check if resolved path is within workspace
457
654
  const relativePath = path.relative(workspaceRoot, resolvedPath);
@@ -7,7 +7,11 @@ import type { DomainLangServices } from '../domain-lang-module.js';
7
7
 
8
8
  /**
9
9
  * Lazily initialized workspace manager for standalone (non-LSP) usage.
10
- * Used by import graph building when services aren't available from DI.
10
+ * Used by import graph building when no DI-injected ImportResolver is available.
11
+ *
12
+ * @deprecated Prefer passing an ImportResolver from the DI container.
13
+ * These singletons exist only for backwards compatibility with callers
14
+ * that haven't been updated to pass through DI services.
11
15
  */
12
16
  let standaloneWorkspaceManager: WorkspaceManager | undefined;
13
17
  let standaloneImportResolver: ImportResolver | undefined;
@@ -17,9 +21,7 @@ let lastInitializedDir: string | undefined;
17
21
  * Gets or creates a standalone import resolver for non-LSP contexts.
18
22
  * Creates its own WorkspaceManager if not previously initialized for this directory.
19
23
  *
20
- * NOTE: In LSP contexts, prefer using services.imports.ImportResolver directly.
21
- * This function exists for utilities that don't have access to the service container.
22
- *
24
+ * @deprecated Prefer using services.imports.ImportResolver directly.
23
25
  * @param startDir - Directory to start workspace search from
24
26
  * @returns Promise resolving to the import resolver
25
27
  */
@@ -44,10 +46,8 @@ async function getStandaloneImportResolver(startDir: string): Promise<ImportReso
44
46
  /**
45
47
  * Resolves an import path to an absolute file URI.
46
48
  *
47
- * Delegates to ImportResolver which implements PRS-010 semantics:
48
- * - File imports (with .dlang extension): Direct file access
49
- * - Module imports (no extension): Requires model.yaml in directory
50
- * - External dependencies: Resolved via manifest and lock file
49
+ * @deprecated Prefer using ImportResolver.resolveForDocument() from the DI container.
50
+ * This function creates standalone instances outside the DI system.
51
51
  *
52
52
  * @param importingDoc - The document containing the import statement
53
53
  * @param rawImportPath - The raw import path from the import statement
@@ -68,16 +68,19 @@ export async function resolveImportPath(
68
68
  *
69
69
  * @param entryFilePath - Absolute or workspace-relative path to entry file
70
70
  * @param langiumDocuments - The Langium documents manager
71
+ * @param importResolver - Optional DI-injected ImportResolver. When provided,
72
+ * uses it instead of creating standalone instances. Recommended for LSP contexts.
71
73
  * @returns Set of URIs (as strings) for all documents in the import graph
72
74
  * @throws {Error} If entry file cannot be resolved or loaded
73
75
  */
74
76
  export async function ensureImportGraphFromEntryFile(
75
77
  entryFilePath: string,
76
- langiumDocuments: LangiumDocuments
78
+ langiumDocuments: LangiumDocuments,
79
+ importResolver?: ImportResolver
77
80
  ): Promise<Set<string>> {
78
81
  const entryUri = URI.file(path.resolve(entryFilePath));
79
82
  const entryDoc = await langiumDocuments.getOrCreateDocument(entryUri);
80
- return ensureImportGraphFromDocument(entryDoc, langiumDocuments);
83
+ return ensureImportGraphFromDocument(entryDoc, langiumDocuments, importResolver);
81
84
  }
82
85
 
83
86
  /**
@@ -85,11 +88,14 @@ export async function ensureImportGraphFromEntryFile(
85
88
  *
86
89
  * @param document - The starting document
87
90
  * @param langiumDocuments - The Langium documents manager
91
+ * @param importResolver - Optional DI-injected ImportResolver. When provided,
92
+ * uses it instead of creating standalone instances. Recommended for LSP contexts.
88
93
  * @returns Set of URIs (as strings) for all documents in the import graph
89
94
  */
90
95
  export async function ensureImportGraphFromDocument(
91
96
  document: LangiumDocument,
92
- langiumDocuments: LangiumDocuments
97
+ langiumDocuments: LangiumDocuments,
98
+ importResolver?: ImportResolver
93
99
  ): Promise<Set<string>> {
94
100
  const visited = new Set<string>();
95
101
 
@@ -102,10 +108,16 @@ export async function ensureImportGraphFromDocument(
102
108
  for (const imp of model.imports ?? []) {
103
109
  if (!imp.uri) continue;
104
110
 
105
- // Use new resolveImportPath that supports external dependencies
106
- const resolvedUri = await resolveImportPath(doc, imp.uri);
107
- const childDoc = await langiumDocuments.getOrCreateDocument(resolvedUri);
108
- await visit(childDoc);
111
+ try {
112
+ // Use DI-injected resolver when available, falling back to standalone
113
+ const resolvedUri = importResolver
114
+ ? await importResolver.resolveForDocument(doc, imp.uri)
115
+ : await resolveImportPath(doc, imp.uri);
116
+ const childDoc = await langiumDocuments.getOrCreateDocument(resolvedUri);
117
+ await visit(childDoc);
118
+ } catch {
119
+ // Import resolution failed — validation will report the error
120
+ }
109
121
  }
110
122
  }
111
123
 
@@ -55,6 +55,7 @@ export const IssueCodes = {
55
55
  // Context/Domain Map Issues
56
56
  ContextMapNoContexts: 'context-map-no-contexts',
57
57
  ContextMapNoRelationships: 'context-map-no-relationships',
58
+ ContextMapDuplicateRelationship: 'context-map-duplicate-relationship',
58
59
  DomainMapNoDomains: 'domain-map-no-domains',
59
60
 
60
61
  // Reference Issues
@@ -81,8 +82,10 @@ const DOCS_BASE = `${REPO_BASE}/dsl/domain-lang/docs`;
81
82
  * @param docPath - Relative path from docs/ folder
82
83
  * @param anchor - Optional section anchor (without #)
83
84
  */
84
- const buildDocLink = (docPath: string, anchor?: string): string =>
85
- `${DOCS_BASE}/${docPath}${anchor ? `#${anchor}` : ''}`;
85
+ const buildDocLink = (docPath: string, anchor?: string): string => {
86
+ const anchorPart = anchor ? `#${anchor}` : '';
87
+ return `${DOCS_BASE}/${docPath}${anchorPart}`;
88
+ };
86
89
 
87
90
  /**
88
91
  * Creates a CodeDescription for clickable documentation links in VS Code.
@@ -133,8 +136,11 @@ export const ValidationMessages = {
133
136
  * @param inlineClassification - The inline classification name (from 'as')
134
137
  * @param blockClassification - The block classification name (from 'classification:')
135
138
  */
136
- BOUNDED_CONTEXT_CLASSIFICATION_CONFLICT: (bcName: string, inlineClassification?: string, blockClassification?: string) =>
137
- `Classification specified both inline${inlineClassification ? ` ('as ${inlineClassification}')` : ''} and in block${blockClassification ? ` ('classification: ${blockClassification}')` : ''}. Inline value takes precedence.`,
139
+ BOUNDED_CONTEXT_CLASSIFICATION_CONFLICT: (bcName: string, inlineClassification?: string, blockClassification?: string) => {
140
+ const inlinePart = inlineClassification ? ` ('as ${inlineClassification}')` : '';
141
+ const blockPart = blockClassification ? ` ('classification: ${blockClassification}')` : '';
142
+ return `Classification specified both inline${inlinePart} and in block${blockPart}. Inline value takes precedence.`;
143
+ },
138
144
 
139
145
  /**
140
146
  * Warning when team is specified both inline and in a block.
@@ -143,8 +149,11 @@ export const ValidationMessages = {
143
149
  * @param inlineTeam - The inline team name (from 'by')
144
150
  * @param blockTeam - The block team name (from 'team:')
145
151
  */
146
- BOUNDED_CONTEXT_TEAM_CONFLICT: (bcName: string, inlineTeam?: string, blockTeam?: string) =>
147
- `Team specified both inline${inlineTeam ? ` ('by ${inlineTeam}')` : ''} and in block${blockTeam ? ` ('team: ${blockTeam}')` : ''}. Inline value takes precedence.`,
152
+ BOUNDED_CONTEXT_TEAM_CONFLICT: (bcName: string, inlineTeam?: string, blockTeam?: string) => {
153
+ const inlinePart = inlineTeam ? ` ('by ${inlineTeam}')` : '';
154
+ const blockPart = blockTeam ? ` ('team: ${blockTeam}')` : '';
155
+ return `Team specified both inline${inlinePart} and in block${blockPart}. Inline value takes precedence.`;
156
+ },
148
157
 
149
158
  /**
150
159
  * Error message when an element is defined multiple times.
@@ -295,6 +304,14 @@ export const ValidationMessages = {
295
304
  `Context Map '${name}' contains ${count} contexts but no documented relationships.\n` +
296
305
  `Hint: Add relationships to show how contexts integrate (e.g., '[OHS] A -> [CF] B').`,
297
306
 
307
+ /**
308
+ * Warning when a context map contains duplicate relationships.
309
+ * @param leftContext - Name of the left context
310
+ * @param rightContext - Name of the right context
311
+ */
312
+ CONTEXT_MAP_DUPLICATE_RELATIONSHIP: (leftContext: string, rightContext: string) =>
313
+ `Duplicate relationship between '${leftContext}' and '${rightContext}' in context map.`,
314
+
298
315
  /**
299
316
  * Warning when domain map contains no domains.
300
317
  * @param name - The domain map name