@domainlang/language 0.6.0 → 0.7.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 (35) hide show
  1. package/README.md +1 -1
  2. package/out/domain-lang-module.js +2 -0
  3. package/out/domain-lang-module.js.map +1 -1
  4. package/out/lsp/domain-lang-index-manager.d.ts +69 -1
  5. package/out/lsp/domain-lang-index-manager.js +173 -5
  6. package/out/lsp/domain-lang-index-manager.js.map +1 -1
  7. package/out/lsp/domain-lang-scope-provider.d.ts +67 -0
  8. package/out/lsp/domain-lang-scope-provider.js +95 -0
  9. package/out/lsp/domain-lang-scope-provider.js.map +1 -0
  10. package/out/lsp/domain-lang-workspace-manager.d.ts +25 -0
  11. package/out/lsp/domain-lang-workspace-manager.js +102 -3
  12. package/out/lsp/domain-lang-workspace-manager.js.map +1 -1
  13. package/out/main.js +114 -19
  14. package/out/main.js.map +1 -1
  15. package/out/services/import-resolver.d.ts +29 -6
  16. package/out/services/import-resolver.js +48 -9
  17. package/out/services/import-resolver.js.map +1 -1
  18. package/out/validation/constants.d.ts +13 -0
  19. package/out/validation/constants.js +18 -0
  20. package/out/validation/constants.js.map +1 -1
  21. package/out/validation/import.d.ts +11 -0
  22. package/out/validation/import.js +62 -2
  23. package/out/validation/import.js.map +1 -1
  24. package/out/validation/maps.js +51 -2
  25. package/out/validation/maps.js.map +1 -1
  26. package/package.json +1 -1
  27. package/src/domain-lang-module.ts +2 -0
  28. package/src/lsp/domain-lang-index-manager.ts +196 -5
  29. package/src/lsp/domain-lang-scope-provider.ts +134 -0
  30. package/src/lsp/domain-lang-workspace-manager.ts +128 -3
  31. package/src/main.ts +153 -22
  32. package/src/services/import-resolver.ts +60 -9
  33. package/src/validation/constants.ts +24 -0
  34. package/src/validation/import.ts +75 -2
  35. package/src/validation/maps.ts +59 -2
@@ -0,0 +1,134 @@
1
+ /**
2
+ * DomainLang Scope Provider
3
+ *
4
+ * Implements import-based scoping for the DomainLang DSL.
5
+ *
6
+ * **Key Concept:**
7
+ * Unlike languages with global namespaces, DomainLang enforces strict import-based scoping:
8
+ * - Elements are only visible if they are defined in the current document OR explicitly imported
9
+ * - The global scope is restricted to imported documents only
10
+ * - Transitive imports do NOT provide scope (only direct imports)
11
+ *
12
+ * **Why this matters:**
13
+ * Without this, Langium's DefaultScopeProvider would make ALL indexed documents visible
14
+ * in the global scope, which would:
15
+ * 1. Allow referencing elements that haven't been imported
16
+ * 2. Make the import system meaningless
17
+ * 3. Create confusion about dependencies between files
18
+ *
19
+ * @see https://langium.org/docs/recipes/scoping/ for Langium scoping patterns
20
+ */
21
+
22
+ import type {
23
+ AstNodeDescription,
24
+ LangiumDocument,
25
+ ReferenceInfo,
26
+ Scope,
27
+ Stream
28
+ } from 'langium';
29
+ import {
30
+ AstUtils,
31
+ DefaultScopeProvider,
32
+ EMPTY_SCOPE,
33
+ MapScope
34
+ } from 'langium';
35
+ import type { DomainLangServices } from '../domain-lang-module.js';
36
+ import type { DomainLangIndexManager } from './domain-lang-index-manager.js';
37
+
38
+ /**
39
+ * Custom scope provider that restricts cross-file references to imported documents only.
40
+ *
41
+ * Extends Langium's DefaultScopeProvider to override the global scope computation.
42
+ */
43
+ export class DomainLangScopeProvider extends DefaultScopeProvider {
44
+ /**
45
+ * Reference to IndexManager for getting resolved imports.
46
+ */
47
+ private readonly domainLangIndexManager: DomainLangIndexManager;
48
+
49
+ constructor(services: DomainLangServices) {
50
+ super(services);
51
+ this.domainLangIndexManager = services.shared.workspace.IndexManager as DomainLangIndexManager;
52
+ }
53
+
54
+ /**
55
+ * Override getGlobalScope to restrict it to imported documents only.
56
+ *
57
+ * The default Langium behavior includes ALL documents in the workspace.
58
+ * We restrict this to:
59
+ * 1. The current document's own exported symbols
60
+ * 2. Exported symbols from directly imported documents
61
+ *
62
+ * @param referenceType - The AST type being referenced
63
+ * @param context - Information about the reference
64
+ * @returns A scope containing only visible elements
65
+ */
66
+ protected override getGlobalScope(referenceType: string, context: ReferenceInfo): Scope {
67
+ const document = AstUtils.getDocument(context.container);
68
+ if (!document) {
69
+ return EMPTY_SCOPE;
70
+ }
71
+
72
+ // Get the set of URIs that are in scope for this document
73
+ const importedUris = this.getImportedDocumentUris(document);
74
+
75
+ // Filter the global index to only include descriptions from imported documents
76
+ const filteredDescriptions = this.filterDescriptionsByImports(
77
+ referenceType,
78
+ document,
79
+ importedUris
80
+ );
81
+
82
+ // Create a scope from the filtered descriptions
83
+ return new MapScope(filteredDescriptions);
84
+ }
85
+
86
+ /**
87
+ * Gets the set of document URIs that are directly imported by the given document.
88
+ *
89
+ * Uses the resolved imports tracked by DomainLangIndexManager during indexing.
90
+ * This ensures accurate resolution including path aliases.
91
+ *
92
+ * @param document - The document to get imports for
93
+ * @returns Set of imported document URIs (as strings)
94
+ */
95
+ private getImportedDocumentUris(document: LangiumDocument): Set<string> {
96
+ const docUri = document.uri.toString();
97
+
98
+ // Get resolved imports from the index manager (tracked during indexing)
99
+ const resolvedImports = this.domainLangIndexManager.getResolvedImports(docUri);
100
+
101
+ // Always include the current document itself
102
+ const importedUris = new Set<string>([docUri]);
103
+
104
+ // Add all resolved import URIs
105
+ for (const resolvedUri of resolvedImports) {
106
+ importedUris.add(resolvedUri);
107
+ }
108
+
109
+ return importedUris;
110
+ }
111
+
112
+ /**
113
+ * Filters the global index to only include descriptions from imported documents.
114
+ *
115
+ * @param referenceType - The AST type being referenced
116
+ * @param currentDocument - The document making the reference
117
+ * @param importedUris - Set of URIs that are in scope
118
+ * @returns Stream of filtered descriptions
119
+ */
120
+ private filterDescriptionsByImports(
121
+ referenceType: string,
122
+ currentDocument: LangiumDocument,
123
+ importedUris: Set<string>
124
+ ): Stream<AstNodeDescription> {
125
+ // Get all descriptions of the reference type from the index
126
+ const allDescriptions = this.indexManager.allElements(referenceType);
127
+
128
+ // Filter to only those from imported documents
129
+ return allDescriptions.filter(desc => {
130
+ const descDocUri = desc.documentUri.toString();
131
+ return importedUris.has(descDocUri);
132
+ });
133
+ }
134
+ }
@@ -1,3 +1,5 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
1
3
  import { DefaultWorkspaceManager, URI, UriUtils, type FileSystemNode, type LangiumDocument, type LangiumSharedCoreServices, type WorkspaceFolder } from 'langium';
2
4
  import type { CancellationToken } from 'vscode-languageserver-protocol';
3
5
  import { ensureImportGraphFromDocument } from '../utils/import-utils.js';
@@ -74,9 +76,10 @@ export class DomainLangWorkspaceManager extends DefaultWorkspaceManager {
74
76
  // Find ALL model.yaml files in workspace (supports mixed mode)
75
77
  const manifestInfos = await this.findAllManifestsInFolders(folders);
76
78
 
77
- if (manifestInfos.length === 0) {
78
- return; // Pure Mode B: no manifests, all files loaded on-demand
79
- }
79
+ // Track directories covered by manifests to avoid loading their files as standalone
80
+ const moduleDirectories = new Set(
81
+ manifestInfos.map(m => path.dirname(m.manifestPath))
82
+ );
80
83
 
81
84
  // Mode A or Mode C: Load each module's entry + import graph
82
85
  for (const manifestInfo of manifestInfos) {
@@ -111,6 +114,128 @@ export class DomainLangWorkspaceManager extends DefaultWorkspaceManager {
111
114
  // Continue with other modules - partial failure is acceptable
112
115
  }
113
116
  }
117
+
118
+ // Load standalone .dlang files in workspace root folders
119
+ // These are files NOT covered by any module's import graph
120
+ await this.loadStandaloneFiles(folders, moduleDirectories, collector);
121
+ }
122
+
123
+ /**
124
+ * Loads standalone .dlang files from workspace folders recursively.
125
+ *
126
+ * Skips:
127
+ * - Module directories (directories with model.yaml) - loaded via import graph
128
+ * - `.dlang/cache` directory - package cache managed by CLI
129
+ *
130
+ * @param folders - Workspace folders to scan
131
+ * @param moduleDirectories - Set of directories containing model.yaml (to skip)
132
+ * @param collector - Document collector callback
133
+ */
134
+ private async loadStandaloneFiles(
135
+ folders: WorkspaceFolder[],
136
+ moduleDirectories: Set<string>,
137
+ collector: (document: LangiumDocument) => void
138
+ ): Promise<void> {
139
+ const standaloneDocs: LangiumDocument[] = [];
140
+
141
+ for (const folder of folders) {
142
+ const folderPath = URI.parse(folder.uri).fsPath;
143
+ const docs = await this.loadDlangFilesRecursively(folderPath, moduleDirectories, collector);
144
+ standaloneDocs.push(...docs);
145
+ }
146
+
147
+ // Build all standalone documents in batch for performance
148
+ if (standaloneDocs.length > 0) {
149
+ await this.sharedServices.workspace.DocumentBuilder.build(standaloneDocs, {
150
+ validation: true
151
+ });
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Recursively loads .dlang files from a directory.
157
+ * Skips module directories and the .dlang/cache package cache.
158
+ */
159
+ private async loadDlangFilesRecursively(
160
+ dirPath: string,
161
+ moduleDirectories: Set<string>,
162
+ collector: (document: LangiumDocument) => void
163
+ ): Promise<LangiumDocument[]> {
164
+ // Skip module directories - they're loaded via import graph
165
+ if (moduleDirectories.has(dirPath)) {
166
+ return [];
167
+ }
168
+
169
+ // Skip .dlang/cache - package cache managed by CLI
170
+ const baseName = path.basename(dirPath);
171
+ const parentName = path.basename(path.dirname(dirPath));
172
+ if (baseName === 'cache' && parentName === '.dlang') {
173
+ return [];
174
+ }
175
+ // Also skip the .dlang directory itself if it contains cache
176
+ if (baseName === '.dlang') {
177
+ return [];
178
+ }
179
+
180
+ const docs: LangiumDocument[] = [];
181
+
182
+ try {
183
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
184
+
185
+ for (const entry of entries) {
186
+ const entryPath = path.join(dirPath, entry.name);
187
+
188
+ if (entry.isDirectory()) {
189
+ // Recurse into subdirectories
190
+ const subDocs = await this.loadDlangFilesRecursively(entryPath, moduleDirectories, collector);
191
+ docs.push(...subDocs);
192
+ } else if (this.isDlangFile(entry)) {
193
+ const doc = await this.tryLoadDocument(dirPath, entry.name, collector);
194
+ if (doc) {
195
+ docs.push(doc);
196
+ }
197
+ }
198
+ }
199
+ } catch (error) {
200
+ const message = error instanceof Error ? error.message : String(error);
201
+ console.warn(`Failed to read directory ${dirPath}: ${message}`);
202
+ }
203
+
204
+ return docs;
205
+ }
206
+
207
+ /**
208
+ * Checks if a directory entry is a .dlang file.
209
+ */
210
+ private isDlangFile(entry: { isFile(): boolean; name: string }): boolean {
211
+ return entry.isFile() && entry.name.toLowerCase().endsWith('.dlang');
212
+ }
213
+
214
+ /**
215
+ * Attempts to load a document, returning undefined on failure.
216
+ */
217
+ private async tryLoadDocument(
218
+ folderPath: string,
219
+ fileName: string,
220
+ collector: (document: LangiumDocument) => void
221
+ ): Promise<LangiumDocument | undefined> {
222
+ const filePath = path.join(folderPath, fileName);
223
+ const uri = URI.file(filePath);
224
+
225
+ // Skip if already loaded (e.g., through imports)
226
+ if (this.langiumDocuments.hasDocument(uri)) {
227
+ return undefined;
228
+ }
229
+
230
+ try {
231
+ const doc = await this.langiumDocuments.getOrCreateDocument(uri);
232
+ collector(doc);
233
+ return doc;
234
+ } catch (error) {
235
+ const message = error instanceof Error ? error.message : String(error);
236
+ console.warn(`Failed to load standalone file ${filePath}: ${message}`);
237
+ return undefined;
238
+ }
114
239
  }
115
240
 
116
241
  /**
package/src/main.ts CHANGED
@@ -40,7 +40,7 @@ shared.lsp.LanguageServer.onInitialize((params) => {
40
40
  // This invalidates caches when config files change externally
41
41
  shared.lsp.DocumentUpdateHandler?.onWatchedFilesChange(async (params) => {
42
42
  try {
43
- await handleConfigFileChanges(params, DomainLang.imports.WorkspaceManager, shared);
43
+ await handleFileChanges(params, DomainLang.imports.WorkspaceManager, shared, DomainLang);
44
44
  } catch (error) {
45
45
  const message = error instanceof Error ? error.message : String(error);
46
46
  console.error(`Error handling file change notification: ${message}`);
@@ -48,41 +48,174 @@ shared.lsp.DocumentUpdateHandler?.onWatchedFilesChange(async (params) => {
48
48
  }
49
49
  });
50
50
 
51
+ /** Categorized file changes */
52
+ interface CategorizedChanges {
53
+ manifestChanged: boolean;
54
+ lockFileChanged: boolean;
55
+ changedDlangUris: Set<string>;
56
+ deletedDlangUris: Set<string>;
57
+ createdDlangUris: Set<string>;
58
+ }
59
+
51
60
  /**
52
- * Handles changes to model.yaml and model.lock files.
53
- * Invalidates caches and rebuilds workspace as needed.
54
- * Uses incremental updates: only rebuilds if dependencies actually changed.
61
+ * Categorizes file changes by type.
55
62
  */
56
- async function handleConfigFileChanges(
63
+ function categorizeChanges(
57
64
  params: { changes: Array<{ uri: string; type: number }> },
58
65
  workspaceManager: typeof DomainLang.imports.WorkspaceManager,
59
- sharedServices: typeof shared
60
- ): Promise<void> {
61
- let manifestChanged = false;
62
- let lockFileChanged = false;
66
+ langServices: typeof DomainLang,
67
+ indexManager: DomainLangIndexManager
68
+ ): CategorizedChanges {
69
+ const result: CategorizedChanges = {
70
+ manifestChanged: false,
71
+ lockFileChanged: false,
72
+ changedDlangUris: new Set(),
73
+ deletedDlangUris: new Set(),
74
+ createdDlangUris: new Set()
75
+ };
63
76
 
64
77
  for (const change of params.changes) {
65
78
  const uri = URI.parse(change.uri);
66
79
  const fileName = uri.path.split('/').pop() ?? '';
80
+ const uriString = change.uri;
67
81
 
68
82
  if (fileName === 'model.yaml') {
69
- console.warn(`model.yaml changed: ${change.uri}`);
83
+ console.warn(`model.yaml changed: ${uriString}`);
70
84
  workspaceManager.invalidateManifestCache();
71
- DomainLang.imports.ImportResolver.clearCache();
72
- // Clear IndexManager import dependencies - resolved paths may have changed
73
- const indexManager = sharedServices.workspace.IndexManager as DomainLangIndexManager;
85
+ langServices.imports.ImportResolver.clearCache();
74
86
  indexManager.clearImportDependencies();
75
- manifestChanged = true;
87
+ result.manifestChanged = true;
76
88
  } else if (fileName === 'model.lock') {
77
- await handleLockFileChange(change, workspaceManager);
78
- DomainLang.imports.ImportResolver.clearCache();
79
- lockFileChanged = true;
89
+ console.warn(`model.lock changed: ${uriString}`);
90
+ langServices.imports.ImportResolver.clearCache();
91
+ result.lockFileChanged = true;
92
+ } else if (fileName.endsWith('.dlang')) {
93
+ if (change.type === FileChangeType.Deleted) {
94
+ result.deletedDlangUris.add(uriString);
95
+ console.warn(`DomainLang file deleted: ${uriString}`);
96
+ } else if (change.type === FileChangeType.Created) {
97
+ result.createdDlangUris.add(uriString);
98
+ console.warn(`DomainLang file created: ${uriString}`);
99
+ } else {
100
+ result.changedDlangUris.add(uriString);
101
+ console.warn(`DomainLang file changed: ${uriString}`);
102
+ }
103
+ }
104
+ }
105
+
106
+ return result;
107
+ }
108
+
109
+ /**
110
+ * Rebuilds documents that depend on changed/deleted/created .dlang files.
111
+ */
112
+ async function rebuildAffectedDocuments(
113
+ changes: CategorizedChanges,
114
+ indexManager: DomainLangIndexManager,
115
+ sharedServices: typeof shared,
116
+ langServices: typeof DomainLang
117
+ ): Promise<void> {
118
+ const hasChanges = changes.changedDlangUris.size > 0 ||
119
+ changes.deletedDlangUris.size > 0 ||
120
+ changes.createdDlangUris.size > 0;
121
+ if (!hasChanges) {
122
+ return;
123
+ }
124
+
125
+ // CRITICAL: Clear ImportResolver cache BEFORE rebuilding.
126
+ // The WorkspaceCache only clears AFTER linking, but resolution happens
127
+ // DURING linking. Without this, stale cached resolutions would be used.
128
+ langServices.imports.ImportResolver.clearCache();
129
+
130
+ const affectedUris = collectAffectedDocuments(changes, indexManager);
131
+
132
+ if (affectedUris.size === 0) {
133
+ return;
134
+ }
135
+
136
+ console.warn(`Rebuilding ${affectedUris.size} documents affected by file changes`);
137
+
138
+ const langiumDocuments = sharedServices.workspace.LangiumDocuments;
139
+ const affectedDocs: URI[] = [];
140
+
141
+ for (const uriString of affectedUris) {
142
+ const uri = URI.parse(uriString);
143
+ if (langiumDocuments.hasDocument(uri)) {
144
+ affectedDocs.push(uri);
145
+ indexManager.markForReprocessing(uriString);
80
146
  }
81
147
  }
82
148
 
83
- // Only rebuild if dependencies changed, not just any manifest change
84
- if (manifestChanged || lockFileChanged) {
85
- await rebuildWorkspace(sharedServices, workspaceManager, manifestChanged);
149
+ const deletedUriObjects = [...changes.deletedDlangUris].map(u => URI.parse(u));
150
+ if (affectedDocs.length > 0 || deletedUriObjects.length > 0) {
151
+ await sharedServices.workspace.DocumentBuilder.update(affectedDocs, deletedUriObjects);
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Collects all document URIs that should be rebuilt based on the changes.
157
+ *
158
+ * Uses targeted matching to avoid expensive full rebuilds:
159
+ * - For edits: rebuild documents that import the changed file (by resolved URI)
160
+ * - For all changes: rebuild documents whose import specifiers match the path
161
+ *
162
+ * The specifier matching handles renamed/moved/created files by comparing
163
+ * import specifiers against path segments (filename, parent/filename, etc.).
164
+ */
165
+ function collectAffectedDocuments(
166
+ changes: CategorizedChanges,
167
+ indexManager: DomainLangIndexManager
168
+ ): Set<string> {
169
+ const allChangedUris = new Set([
170
+ ...changes.changedDlangUris,
171
+ ...changes.deletedDlangUris,
172
+ ...changes.createdDlangUris
173
+ ]);
174
+
175
+ // Get documents affected by resolved URI changes (edits to imported files)
176
+ const affectedByUri = indexManager.getAllAffectedDocuments(allChangedUris);
177
+
178
+ // Get documents with import specifiers that match changed paths
179
+ // This catches:
180
+ // - File moves/renames: specifiers that previously resolved but now won't
181
+ // - File creations: specifiers that previously failed but might now resolve
182
+ // Uses fuzzy matching on path segments rather than rebuilding all imports
183
+ const affectedBySpecifier = indexManager.getDocumentsWithPotentiallyAffectedImports(allChangedUris);
184
+
185
+ return new Set([...affectedByUri, ...affectedBySpecifier]);
186
+ }
187
+
188
+ /**
189
+ * Handles all file changes including .dlang files, model.yaml, and model.lock.
190
+ *
191
+ * For .dlang files: rebuilds all documents that import the changed file.
192
+ * For config files: invalidates caches and rebuilds workspace as needed.
193
+ */
194
+ async function handleFileChanges(
195
+ params: { changes: Array<{ uri: string; type: number }> },
196
+ workspaceManager: typeof DomainLang.imports.WorkspaceManager,
197
+ sharedServices: typeof shared,
198
+ langServices: typeof DomainLang
199
+ ): Promise<void> {
200
+ const indexManager = sharedServices.workspace.IndexManager as DomainLangIndexManager;
201
+
202
+ // Categorize and process changes
203
+ const changes = categorizeChanges(params, workspaceManager, langServices, indexManager);
204
+
205
+ // Handle lock file changes
206
+ if (changes.lockFileChanged) {
207
+ const lockChange = params.changes.find(c => c.uri.endsWith('model.lock'));
208
+ if (lockChange) {
209
+ await handleLockFileChange(lockChange, workspaceManager);
210
+ }
211
+ }
212
+
213
+ // Rebuild documents affected by .dlang file changes
214
+ await rebuildAffectedDocuments(changes, indexManager, sharedServices, langServices);
215
+
216
+ // Handle config file changes
217
+ if (changes.manifestChanged || changes.lockFileChanged) {
218
+ await rebuildWorkspace(sharedServices, workspaceManager, changes.manifestChanged);
86
219
  }
87
220
  }
88
221
 
@@ -93,8 +226,6 @@ async function handleLockFileChange(
93
226
  change: { uri: string; type: number },
94
227
  workspaceManager: typeof DomainLang.imports.WorkspaceManager
95
228
  ): Promise<void> {
96
- console.warn(`model.lock changed: ${change.uri}`);
97
-
98
229
  if (change.type === FileChangeType.Changed || change.type === FileChangeType.Created) {
99
230
  await workspaceManager.refreshLockFile();
100
231
  } else if (change.type === FileChangeType.Deleted) {
@@ -1,10 +1,16 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { URI, type LangiumDocument } from 'langium';
3
+ import { DocumentState, SimpleCache, WorkspaceCache, URI, type LangiumDocument, type LangiumSharedCoreServices } from 'langium';
4
4
  import { WorkspaceManager } from './workspace-manager.js';
5
5
  import type { DomainLangServices } from '../domain-lang-module.js';
6
6
  import type { LockFile } from './types.js';
7
7
 
8
+ /**
9
+ * Cache interface for import resolution.
10
+ * Uses WorkspaceCache in LSP mode (clears on ANY document change) or SimpleCache in standalone mode.
11
+ */
12
+ type ResolverCache = WorkspaceCache<string, URI> | SimpleCache<string, URI>;
13
+
8
14
  /**
9
15
  * ImportResolver resolves import statements using manifest-centric rules (PRS-010).
10
16
  *
@@ -17,21 +23,66 @@ import type { LockFile } from './types.js';
17
23
  * - ./types → ./types/index.dlang → ./types.dlang
18
24
  * - Module entry defaults to index.dlang (no model.yaml required)
19
25
  *
20
- * Performance:
21
- * - Resolution results are cached per (document URI + specifier) pair
22
- * - Cache is invalidated when model.yaml or model.lock changes
26
+ * Caching Strategy (uses Langium standard infrastructure):
27
+ * - LSP mode: Uses `WorkspaceCache` - clears on ANY document change in workspace
28
+ * This is necessary because file moves/deletes affect resolution of OTHER documents
29
+ * - Standalone mode: Uses `SimpleCache` - manual invalidation via clearCache()
30
+ *
31
+ * Why WorkspaceCache (not DocumentCache)?
32
+ * - DocumentCache only invalidates when the KEYED document changes
33
+ * - But import resolution can break when IMPORTED files are moved/deleted
34
+ * - Example: index.dlang imports @domains → domains/index.dlang
35
+ * If domains/index.dlang is moved, index.dlang's cache entry must be cleared
36
+ * DocumentCache wouldn't clear it (index.dlang didn't change)
37
+ * WorkspaceCache clears on ANY change, ensuring correct re-resolution
38
+ *
39
+ * @see https://langium.org/docs/recipes/caching/ for Langium caching patterns
23
40
  */
24
41
  export class ImportResolver {
25
42
  private readonly workspaceManager: WorkspaceManager;
26
- private readonly resolverCache = new Map<string, URI>();
43
+ /**
44
+ * Workspace-level cache for resolved import URIs.
45
+ * In LSP mode: WorkspaceCache - clears when ANY document changes (correct for imports)
46
+ * In standalone mode: SimpleCache - manual invalidation via clearCache()
47
+ */
48
+ private readonly resolverCache: ResolverCache;
27
49
 
50
+ /**
51
+ * Creates an ImportResolver.
52
+ *
53
+ * @param services - DomainLang services. If `services.shared` is present, uses WorkspaceCache
54
+ * for automatic invalidation. Otherwise uses SimpleCache for standalone mode.
55
+ */
28
56
  constructor(services: DomainLangServices) {
29
57
  this.workspaceManager = services.imports.WorkspaceManager;
58
+
59
+ // Use Langium's WorkspaceCache when shared services are available (LSP mode)
60
+ // Fall back to SimpleCache for standalone utilities (SDK, CLI)
61
+ const shared = (services as DomainLangServices & { shared?: LangiumSharedCoreServices }).shared;
62
+ if (shared) {
63
+ // LSP mode: WorkspaceCache with DocumentState.Linked
64
+ //
65
+ // This follows the standard pattern used by TypeScript, rust-analyzer, gopls:
66
+ // - Cache is valid for a "workspace snapshot"
67
+ // - Invalidates after a batch of changes completes linking (debounced ~300ms)
68
+ // - Invalidates immediately on file deletion
69
+ // - Does NOT invalidate during typing (would be too expensive)
70
+ //
71
+ // DocumentState.Linked is the right phase because:
72
+ // - Import resolution is needed during linking
73
+ // - By the time linking completes, we know which files exist
74
+ // - File renames appear as delete+create, triggering immediate invalidation
75
+ this.resolverCache = new WorkspaceCache(shared, DocumentState.Linked);
76
+ } else {
77
+ // Standalone mode: simple key-value cache, manual invalidation
78
+ this.resolverCache = new SimpleCache<string, URI>();
79
+ }
30
80
  }
31
81
 
32
82
  /**
33
- * Clears the import resolution cache.
34
- * Call this when model.yaml or model.lock changes.
83
+ * Clears the entire import resolution cache.
84
+ * In LSP mode, this is also triggered automatically by WorkspaceCache on any document change.
85
+ * Call explicitly when model.yaml or model.lock changes.
35
86
  */
36
87
  clearCache(): void {
37
88
  this.resolverCache.clear();
@@ -39,10 +90,10 @@ export class ImportResolver {
39
90
 
40
91
  /**
41
92
  * Resolve an import specifier relative to a Langium document.
42
- * Results are cached for performance.
93
+ * Results are cached using WorkspaceCache (clears on any workspace change).
43
94
  */
44
95
  async resolveForDocument(document: LangiumDocument, specifier: string): Promise<URI> {
45
- // Check cache first (key: document URI + specifier)
96
+ // Cache key combines document URI + specifier for uniqueness
46
97
  const cacheKey = `${document.uri.toString()}|${specifier}`;
47
98
  const cached = this.resolverCache.get(cacheKey);
48
99
  if (cached) {
@@ -34,6 +34,7 @@ export const IssueCodes = {
34
34
  ImportMissingRef: 'import-missing-ref',
35
35
  ImportAbsolutePath: 'import-absolute-path',
36
36
  ImportEscapesWorkspace: 'import-escapes-workspace',
37
+ ImportUnresolved: 'import-unresolved',
37
38
 
38
39
  // Domain Issues
39
40
  DomainNoVision: 'domain-no-vision',
@@ -56,6 +57,9 @@ export const IssueCodes = {
56
57
  ContextMapNoRelationships: 'context-map-no-relationships',
57
58
  DomainMapNoDomains: 'domain-map-no-domains',
58
59
 
60
+ // Reference Issues
61
+ UnresolvedReference: 'unresolved-reference',
62
+
59
63
  // Metadata Issues
60
64
  MetadataMissingName: 'metadata-missing-name',
61
65
 
@@ -262,6 +266,14 @@ export const ValidationMessages = {
262
266
  `Local path dependency '${alias}' escapes workspace boundary.\n` +
263
267
  `Hint: Local dependencies must be within the workspace. Consider moving the dependency or using a git-based source.`,
264
268
 
269
+ /**
270
+ * Error when import path cannot be resolved to a file.
271
+ * @param uri - The import URI that couldn't be resolved
272
+ */
273
+ IMPORT_UNRESOLVED: (uri: string) =>
274
+ `Cannot resolve import '${uri}'.\n` +
275
+ `Hint: Check that the file exists and the path is correct.`,
276
+
265
277
  // ========================================================================
266
278
  // Context Map & Domain Map Validation
267
279
  // ========================================================================
@@ -291,6 +303,18 @@ export const ValidationMessages = {
291
303
  `Domain Map '${name}' contains no domains.\n` +
292
304
  `Hint: Use 'contains DomainA, DomainB' to specify which domains are in the map.`,
293
305
 
306
+ // ========================================================================
307
+ // Reference Resolution Validation
308
+ // ========================================================================
309
+
310
+ /**
311
+ * Error when a reference cannot be resolved (for MultiReferences).
312
+ * @param type - The type being referenced (e.g., 'BoundedContext')
313
+ * @param name - The unresolved name
314
+ */
315
+ UNRESOLVED_REFERENCE: (type: string, name: string) =>
316
+ `Could not resolve reference to ${type} named '${name}'.`,
317
+
294
318
  // ========================================================================
295
319
  // Metadata Validation
296
320
  // ========================================================================