@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.
- package/README.md +1 -1
- package/out/domain-lang-module.js +2 -0
- package/out/domain-lang-module.js.map +1 -1
- package/out/lsp/domain-lang-index-manager.d.ts +69 -1
- package/out/lsp/domain-lang-index-manager.js +173 -5
- package/out/lsp/domain-lang-index-manager.js.map +1 -1
- package/out/lsp/domain-lang-scope-provider.d.ts +67 -0
- package/out/lsp/domain-lang-scope-provider.js +95 -0
- package/out/lsp/domain-lang-scope-provider.js.map +1 -0
- package/out/lsp/domain-lang-workspace-manager.d.ts +25 -0
- package/out/lsp/domain-lang-workspace-manager.js +102 -3
- package/out/lsp/domain-lang-workspace-manager.js.map +1 -1
- package/out/main.js +114 -19
- package/out/main.js.map +1 -1
- package/out/services/import-resolver.d.ts +29 -6
- package/out/services/import-resolver.js +48 -9
- package/out/services/import-resolver.js.map +1 -1
- package/out/validation/constants.d.ts +13 -0
- package/out/validation/constants.js +18 -0
- package/out/validation/constants.js.map +1 -1
- package/out/validation/import.d.ts +11 -0
- package/out/validation/import.js +62 -2
- package/out/validation/import.js.map +1 -1
- package/out/validation/maps.js +51 -2
- package/out/validation/maps.js.map +1 -1
- package/package.json +1 -1
- package/src/domain-lang-module.ts +2 -0
- package/src/lsp/domain-lang-index-manager.ts +196 -5
- package/src/lsp/domain-lang-scope-provider.ts +134 -0
- package/src/lsp/domain-lang-workspace-manager.ts +128 -3
- package/src/main.ts +153 -22
- package/src/services/import-resolver.ts +60 -9
- package/src/validation/constants.ts +24 -0
- package/src/validation/import.ts +75 -2
- 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
|
-
|
|
78
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
63
|
+
function categorizeChanges(
|
|
57
64
|
params: { changes: Array<{ uri: string; type: number }> },
|
|
58
65
|
workspaceManager: typeof DomainLang.imports.WorkspaceManager,
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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: ${
|
|
83
|
+
console.warn(`model.yaml changed: ${uriString}`);
|
|
70
84
|
workspaceManager.invalidateManifestCache();
|
|
71
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
84
|
-
if (
|
|
85
|
-
await
|
|
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
|
-
*
|
|
21
|
-
* -
|
|
22
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
93
|
+
* Results are cached using WorkspaceCache (clears on any workspace change).
|
|
43
94
|
*/
|
|
44
95
|
async resolveForDocument(document: LangiumDocument, specifier: string): Promise<URI> {
|
|
45
|
-
//
|
|
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
|
// ========================================================================
|