@domainlang/language 0.6.0 → 0.8.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/README.md +1 -1
  2. package/out/domain-lang-module.d.ts +2 -0
  3. package/out/domain-lang-module.js +23 -2
  4. package/out/domain-lang-module.js.map +1 -1
  5. package/out/lsp/domain-lang-completion.d.ts +142 -1
  6. package/out/lsp/domain-lang-completion.js +620 -22
  7. package/out/lsp/domain-lang-completion.js.map +1 -1
  8. package/out/lsp/domain-lang-document-symbol-provider.d.ts +79 -0
  9. package/out/lsp/domain-lang-document-symbol-provider.js +210 -0
  10. package/out/lsp/domain-lang-document-symbol-provider.js.map +1 -0
  11. package/out/lsp/domain-lang-index-manager.d.ts +98 -1
  12. package/out/lsp/domain-lang-index-manager.js +214 -7
  13. package/out/lsp/domain-lang-index-manager.js.map +1 -1
  14. package/out/lsp/domain-lang-node-kind-provider.d.ts +27 -0
  15. package/out/lsp/domain-lang-node-kind-provider.js +87 -0
  16. package/out/lsp/domain-lang-node-kind-provider.js.map +1 -0
  17. package/out/lsp/domain-lang-scope-provider.d.ts +100 -0
  18. package/out/lsp/domain-lang-scope-provider.js +170 -0
  19. package/out/lsp/domain-lang-scope-provider.js.map +1 -0
  20. package/out/lsp/domain-lang-workspace-manager.d.ts +46 -0
  21. package/out/lsp/domain-lang-workspace-manager.js +148 -4
  22. package/out/lsp/domain-lang-workspace-manager.js.map +1 -1
  23. package/out/lsp/hover/domain-lang-hover.d.ts +16 -6
  24. package/out/lsp/hover/domain-lang-hover.js +160 -134
  25. package/out/lsp/hover/domain-lang-hover.js.map +1 -1
  26. package/out/lsp/hover/hover-builders.d.ts +57 -0
  27. package/out/lsp/hover/hover-builders.js +171 -0
  28. package/out/lsp/hover/hover-builders.js.map +1 -0
  29. package/out/main.js +116 -20
  30. package/out/main.js.map +1 -1
  31. package/out/sdk/index.d.ts +2 -1
  32. package/out/sdk/index.js +1 -1
  33. package/out/sdk/index.js.map +1 -1
  34. package/out/sdk/loader-node.js +1 -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/services/import-resolver.d.ts +29 -6
  42. package/out/services/import-resolver.js +48 -9
  43. package/out/services/import-resolver.js.map +1 -1
  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 +20 -0
  58. package/out/validation/constants.js +39 -3
  59. package/out/validation/constants.js.map +1 -1
  60. package/out/validation/import.d.ts +22 -1
  61. package/out/validation/import.js +104 -16
  62. package/out/validation/import.js.map +1 -1
  63. package/out/validation/maps.js +101 -3
  64. package/out/validation/maps.js.map +1 -1
  65. package/package.json +5 -5
  66. package/src/domain-lang-module.ts +26 -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 +250 -7
  70. package/src/lsp/domain-lang-node-kind-provider.ts +119 -0
  71. package/src/lsp/domain-lang-scope-provider.ts +250 -0
  72. package/src/lsp/domain-lang-workspace-manager.ts +187 -4
  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 +156 -23
  76. package/src/sdk/index.ts +2 -1
  77. package/src/sdk/loader-node.ts +2 -1
  78. package/src/sdk/loader.ts +125 -34
  79. package/src/sdk/query.ts +15 -11
  80. package/src/services/import-resolver.ts +60 -9
  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 +47 -6
  87. package/src/validation/import.ts +124 -16
  88. package/src/validation/maps.ts +118 -4
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Standalone hover content builder functions.
3
+ *
4
+ * Extracted from DomainLangHoverProvider to reduce class complexity
5
+ * and enable independent testing of hover content generation.
6
+ *
7
+ * Each builder takes typed AST nodes (not generic AstNode) and the helper
8
+ * functions needed for formatting, keeping them pure and testable.
9
+ *
10
+ * @module lsp/hover/hover-builders
11
+ */
12
+
13
+ import type {
14
+ BoundedContext,
15
+ Classification,
16
+ Domain,
17
+ Relationship,
18
+ Team,
19
+ Type,
20
+ } from '../../generated/ast.js';
21
+ import { effectiveClassification, effectiveTeam } from '../../sdk/resolution.js';
22
+
23
+ // ============================================================================
24
+ // Shared formatting utilities
25
+ // ============================================================================
26
+
27
+ /**
28
+ * Wraps text in a domain-lang fenced code block.
29
+ */
30
+ export function codeBlock(text: string): string {
31
+ return `\`\`\`domain-lang\n${text}\n\`\`\``;
32
+ }
33
+
34
+ /**
35
+ * Formats hover output with a consistent header/body structure.
36
+ *
37
+ * @param commentBlock - Documentation comment prefix (or empty)
38
+ * @param emoji - Emoji icon for the type
39
+ * @param typeName - Lowercase type name
40
+ * @param name - Element name (optional)
41
+ * @param fields - Body content fields
42
+ */
43
+ export function formatHoverContent(
44
+ commentBlock: string,
45
+ emoji: string,
46
+ typeName: string,
47
+ name: string | undefined,
48
+ fields: string[]
49
+ ): string {
50
+ const separator = commentBlock ? `${commentBlock}\n\n---\n\n` : '';
51
+ const nameDisplay = name ? ` ${name}` : '';
52
+ const header = `${emoji} **\`(${typeName})\`${nameDisplay}**`;
53
+ const body = fields.length > 0 ? `\n\n${fields.join('\n\n')}` : '';
54
+ return `${separator}${header}${body}`;
55
+ }
56
+
57
+ /**
58
+ * Callback for creating reference links.
59
+ * Provided by the hover provider which has access to the qualified name provider.
60
+ */
61
+ export type RefLinkFn = (ref: Type | undefined, label?: string) => string;
62
+
63
+ // ============================================================================
64
+ // Domain hover builder
65
+ // ============================================================================
66
+
67
+ /**
68
+ * Builds a signature string for a domain (e.g., "Domain Sales in Commerce").
69
+ */
70
+ export function buildDomainSignature(domain: Domain): string {
71
+ const parts = ['Domain', domain.name];
72
+ if (domain.parent?.ref?.name) {
73
+ parts.push('in', domain.parent.ref.name);
74
+ }
75
+ return parts.join(' ');
76
+ }
77
+
78
+ /**
79
+ * Builds hover fields for a Domain node.
80
+ *
81
+ * @param domain - The domain AST node
82
+ * @param refLink - Function to create reference links
83
+ * @returns Array of formatted field strings
84
+ */
85
+ export function buildDomainFields(domain: Domain, refLink: RefLinkFn): string[] {
86
+ const description = domain.description ?? '';
87
+ const vision = domain.vision ?? '';
88
+ const typeRef = domain.type?.ref;
89
+
90
+ const signature = codeBlock(buildDomainSignature(domain));
91
+ const fields: string[] = [signature];
92
+
93
+ if (description) fields.push(description);
94
+ if (vision || typeRef || domain.parent) fields.push('---');
95
+ if (vision) fields.push(`**Vision:** ${vision}`);
96
+ if (typeRef) fields.push(`**Type:** ${refLink(typeRef)}`);
97
+ if (domain.parent?.ref) fields.push(`**Parent:** ${refLink(domain.parent.ref)}`);
98
+
99
+ return fields;
100
+ }
101
+
102
+ // ============================================================================
103
+ // Bounded context hover builder
104
+ // ============================================================================
105
+
106
+ /**
107
+ * Builds a signature string for a bounded context
108
+ * (e.g., "boundedcontext OrderManagement for Sales as Core by SalesTeam").
109
+ */
110
+ export function buildBcSignature(bc: BoundedContext): string {
111
+ const classification = effectiveClassification(bc);
112
+ const team = effectiveTeam(bc);
113
+
114
+ const parts = ['BoundedContext', bc.name];
115
+ if (bc.domain?.ref?.name) parts.push('for', bc.domain.ref.name);
116
+ if (classification?.name) parts.push('as', classification.name);
117
+ if (team?.name) parts.push('by', team.name);
118
+ return parts.join(' ');
119
+ }
120
+
121
+ /**
122
+ * Builds the properties section (domain, classification, team, businessModel, evolution).
123
+ */
124
+ function buildBcPropertyFields(
125
+ bc: BoundedContext,
126
+ classification: Classification | undefined,
127
+ team: Team | undefined,
128
+ refLink: RefLinkFn
129
+ ): string[] {
130
+ const fields: string[] = [];
131
+ const domain = bc.domain?.ref;
132
+ const businessModel = bc.businessModel?.ref;
133
+ const evolution = bc.evolution?.ref;
134
+
135
+ if (domain || classification || team || businessModel || evolution) fields.push('---');
136
+ if (domain) fields.push(`📁 **Domain:** ${refLink(domain)}`);
137
+ if (classification) fields.push(`🔖 **Classification:** ${refLink(classification)}`);
138
+ if (team) fields.push(`👥 **Team:** ${refLink(team)}`);
139
+ if (businessModel) fields.push(`💼 **Business Model:** ${refLink(businessModel)}`);
140
+ if (evolution) fields.push(`🔄 **Evolution:** ${refLink(evolution)}`);
141
+
142
+ return fields;
143
+ }
144
+
145
+ /**
146
+ * Builds the relationships section for a bounded context hover.
147
+ */
148
+ function buildBcRelationshipsSection(
149
+ relationships: readonly Relationship[],
150
+ formatRelationshipLine: (rel: Relationship) => string
151
+ ): string[] {
152
+ if (relationships.length === 0) return [];
153
+ const lines = relationships.map(formatRelationshipLine);
154
+ return [`**Relationships:**\n${lines.join('\n')}`];
155
+ }
156
+
157
+ /**
158
+ * Builds the terminology section for a bounded context hover.
159
+ */
160
+ function buildBcTerminologySection(bc: BoundedContext): string[] {
161
+ const terminology = bc.terminology ?? [];
162
+ if (terminology.length === 0) return [];
163
+ const lines = terminology.map(t => `- \`${t.name}\`: ${t.meaning ?? ''}`);
164
+ return [`**Terminology:**\n${lines.join('\n')}`];
165
+ }
166
+
167
+ /**
168
+ * Builds the decisions section for a bounded context hover.
169
+ */
170
+ function buildBcDecisionsSection(bc: BoundedContext): string[] {
171
+ const decisions = bc.decisions ?? [];
172
+ if (decisions.length === 0) return [];
173
+ const lines = decisions.map(d => `- \`${d.name}\`: ${d.value ?? ''}`);
174
+ return [`**Decisions:**\n${lines.join('\n')}`];
175
+ }
176
+
177
+ /**
178
+ * Builds hover fields for a BoundedContext node.
179
+ *
180
+ * @param bc - The bounded context AST node
181
+ * @param refLink - Function to create reference links
182
+ * @param formatRelationshipLine - Function to format a relationship line
183
+ * @returns Array of formatted field strings
184
+ */
185
+ export function buildBcFields(
186
+ bc: BoundedContext,
187
+ refLink: RefLinkFn,
188
+ formatRelationshipLine: (rel: Relationship) => string
189
+ ): string[] {
190
+ const description = bc.description ?? '';
191
+ const classification = effectiveClassification(bc);
192
+ const team = effectiveTeam(bc);
193
+
194
+ const signature = codeBlock(buildBcSignature(bc));
195
+ const fields: string[] = [signature];
196
+
197
+ if (description) fields.push(description);
198
+
199
+ const sections = [
200
+ ...buildBcPropertyFields(bc, classification, team, refLink),
201
+ ...buildBcRelationshipsSection(bc.relationships ?? [], formatRelationshipLine),
202
+ ...buildBcTerminologySection(bc),
203
+ ...buildBcDecisionsSection(bc),
204
+ ];
205
+ fields.push(...sections);
206
+
207
+ return fields;
208
+ }
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,175 @@ 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
+ indexManager.clearImportDependencies();
92
+ result.lockFileChanged = true;
93
+ } else if (fileName.endsWith('.dlang')) {
94
+ if (change.type === FileChangeType.Deleted) {
95
+ result.deletedDlangUris.add(uriString);
96
+ console.warn(`DomainLang file deleted: ${uriString}`);
97
+ } else if (change.type === FileChangeType.Created) {
98
+ result.createdDlangUris.add(uriString);
99
+ console.warn(`DomainLang file created: ${uriString}`);
100
+ } else {
101
+ result.changedDlangUris.add(uriString);
102
+ console.warn(`DomainLang file changed: ${uriString}`);
103
+ }
104
+ }
105
+ }
106
+
107
+ return result;
108
+ }
109
+
110
+ /**
111
+ * Rebuilds documents that depend on changed/deleted/created .dlang files.
112
+ */
113
+ async function rebuildAffectedDocuments(
114
+ changes: CategorizedChanges,
115
+ indexManager: DomainLangIndexManager,
116
+ sharedServices: typeof shared,
117
+ langServices: typeof DomainLang
118
+ ): Promise<void> {
119
+ const hasChanges = changes.changedDlangUris.size > 0 ||
120
+ changes.deletedDlangUris.size > 0 ||
121
+ changes.createdDlangUris.size > 0;
122
+ if (!hasChanges) {
123
+ return;
124
+ }
125
+
126
+ // CRITICAL: Clear ImportResolver cache BEFORE rebuilding.
127
+ // The WorkspaceCache only clears AFTER linking, but resolution happens
128
+ // DURING linking. Without this, stale cached resolutions would be used.
129
+ langServices.imports.ImportResolver.clearCache();
130
+
131
+ const affectedUris = collectAffectedDocuments(changes, indexManager);
132
+
133
+ if (affectedUris.size === 0) {
134
+ return;
135
+ }
136
+
137
+ console.warn(`Rebuilding ${affectedUris.size} documents affected by file changes`);
138
+
139
+ const langiumDocuments = sharedServices.workspace.LangiumDocuments;
140
+ const affectedDocs: URI[] = [];
141
+
142
+ for (const uriString of affectedUris) {
143
+ const uri = URI.parse(uriString);
144
+ if (langiumDocuments.hasDocument(uri)) {
145
+ affectedDocs.push(uri);
146
+ indexManager.markForReprocessing(uriString);
147
+ }
148
+ }
149
+
150
+ const deletedUriObjects = [...changes.deletedDlangUris].map(u => URI.parse(u));
151
+ if (affectedDocs.length > 0 || deletedUriObjects.length > 0) {
152
+ await sharedServices.workspace.DocumentBuilder.update(affectedDocs, deletedUriObjects);
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Collects all document URIs that should be rebuilt based on the changes.
158
+ *
159
+ * Uses targeted matching to avoid expensive full rebuilds:
160
+ * - For edits: rebuild documents that import the changed file (by resolved URI)
161
+ * - For all changes: rebuild documents whose import specifiers match the path
162
+ *
163
+ * The specifier matching handles renamed/moved/created files by comparing
164
+ * import specifiers against path segments (filename, parent/filename, etc.).
165
+ */
166
+ function collectAffectedDocuments(
167
+ changes: CategorizedChanges,
168
+ indexManager: DomainLangIndexManager
169
+ ): Set<string> {
170
+ const allChangedUris = new Set([
171
+ ...changes.changedDlangUris,
172
+ ...changes.deletedDlangUris,
173
+ ...changes.createdDlangUris
174
+ ]);
175
+
176
+ // Get documents affected by resolved URI changes (edits to imported files)
177
+ const affectedByUri = indexManager.getAllAffectedDocuments(allChangedUris);
178
+
179
+ // Get documents with import specifiers that match changed paths
180
+ // This catches:
181
+ // - File moves/renames: specifiers that previously resolved but now won't
182
+ // - File creations: specifiers that previously failed but might now resolve
183
+ // Uses fuzzy matching on path segments rather than rebuilding all imports
184
+ const affectedBySpecifier = indexManager.getDocumentsWithPotentiallyAffectedImports(allChangedUris);
185
+
186
+ return new Set([...affectedByUri, ...affectedBySpecifier]);
187
+ }
188
+
189
+ /**
190
+ * Handles all file changes including .dlang files, model.yaml, and model.lock.
191
+ *
192
+ * For .dlang files: rebuilds all documents that import the changed file.
193
+ * For config files: invalidates caches and rebuilds workspace as needed.
194
+ */
195
+ async function handleFileChanges(
196
+ params: { changes: Array<{ uri: string; type: number }> },
197
+ workspaceManager: typeof DomainLang.imports.WorkspaceManager,
198
+ sharedServices: typeof shared,
199
+ langServices: typeof DomainLang
200
+ ): Promise<void> {
201
+ const indexManager = sharedServices.workspace.IndexManager as DomainLangIndexManager;
202
+
203
+ // Categorize and process changes
204
+ const changes = categorizeChanges(params, workspaceManager, langServices, indexManager);
205
+
206
+ // Handle lock file changes
207
+ if (changes.lockFileChanged) {
208
+ const lockChange = params.changes.find(c => c.uri.endsWith('model.lock'));
209
+ if (lockChange) {
210
+ await handleLockFileChange(lockChange, workspaceManager);
80
211
  }
81
212
  }
82
213
 
83
- // Only rebuild if dependencies changed, not just any manifest change
84
- if (manifestChanged || lockFileChanged) {
85
- await rebuildWorkspace(sharedServices, workspaceManager, manifestChanged);
214
+ // Rebuild documents affected by .dlang file changes
215
+ await rebuildAffectedDocuments(changes, indexManager, sharedServices, langServices);
216
+
217
+ // Handle config file changes
218
+ if (changes.manifestChanged || changes.lockFileChanged) {
219
+ await rebuildWorkspace(sharedServices, workspaceManager, changes.manifestChanged);
86
220
  }
87
221
  }
88
222
 
@@ -93,8 +227,6 @@ async function handleLockFileChange(
93
227
  change: { uri: string; type: number },
94
228
  workspaceManager: typeof DomainLang.imports.WorkspaceManager
95
229
  ): Promise<void> {
96
- console.warn(`model.lock changed: ${change.uri}`);
97
-
98
230
  if (change.type === FileChangeType.Changed || change.type === FileChangeType.Created) {
99
231
  await workspaceManager.refreshLockFile();
100
232
  } else if (change.type === FileChangeType.Deleted) {
@@ -159,7 +291,8 @@ if (entryFile) {
159
291
  try {
160
292
  currentGraph = await ensureImportGraphFromEntryFile(
161
293
  entryFile,
162
- shared.workspace.LangiumDocuments
294
+ shared.workspace.LangiumDocuments,
295
+ DomainLang.imports.ImportResolver
163
296
  );
164
297
  console.warn(`Successfully loaded import graph from ${entryFile}`);
165
298
  } catch (error) {
package/src/sdk/index.ts CHANGED
@@ -89,7 +89,8 @@
89
89
  */
90
90
 
91
91
  // Browser-safe entry points
92
- export { loadModelFromText } from './loader.js';
92
+ export { loadModelFromText, createModelLoader } from './loader.js';
93
+ export type { ModelLoader } from './loader.js';
93
94
  export { fromModel, fromDocument, fromServices, augmentModel } from './query.js';
94
95
 
95
96
  // Note: loadModel() is NOT exported here - it requires Node.js filesystem
@@ -99,7 +99,8 @@ export async function loadModel(
99
99
  // Traverse import graph to load all imported files
100
100
  const importedUris = await ensureImportGraphFromDocument(
101
101
  document,
102
- shared.workspace.LangiumDocuments
102
+ shared.workspace.LangiumDocuments,
103
+ services.imports.ImportResolver
103
104
  );
104
105
 
105
106
  // Build all imported documents with validation
package/src/sdk/loader.ts CHANGED
@@ -4,6 +4,14 @@
4
4
  * This module provides `loadModelFromText()` which works in both
5
5
  * browser and Node.js environments by using Langium's EmptyFileSystem.
6
6
  *
7
+ * For repeated parsing (e.g., web playgrounds, REPLs), use `createModelLoader()`
8
+ * to reuse Langium services across multiple parse calls:
9
+ * ```typescript
10
+ * const loader = createModelLoader();
11
+ * const result1 = await loader.loadFromText('Domain A {}');
12
+ * const result2 = await loader.loadFromText('Domain B {}');
13
+ * ```
14
+ *
7
15
  * For file-based loading in Node.js CLI tools, use:
8
16
  * ```typescript
9
17
  * import { loadModel } from '@domainlang/language/sdk/loader-node';
@@ -18,21 +26,134 @@
18
26
  */
19
27
 
20
28
  import { EmptyFileSystem, URI } from 'langium';
29
+ import type { LangiumSharedServices } from 'langium/lsp';
21
30
  import type { Model } from '../generated/ast.js';
22
31
  import { isModel } from '../generated/ast.js';
23
32
  import { createDomainLangServices } from '../domain-lang-module.js';
33
+ import type { DomainLangServices } from '../domain-lang-module.js';
24
34
  import type { LoadOptions, QueryContext } from './types.js';
25
35
  import { augmentModel, fromModel } from './query.js';
26
36
 
37
+ /**
38
+ * A reusable model loader that maintains Langium services across multiple parse calls.
39
+ *
40
+ * Use this when calling `loadFromText()` repeatedly (e.g., web playgrounds, REPLs,
41
+ * batch processing) to avoid the overhead of recreating Langium services each time.
42
+ */
43
+ export interface ModelLoader {
44
+ /**
45
+ * Loads a DomainLang model from a text string, reusing internal services.
46
+ *
47
+ * Each call creates a fresh document but shares the underlying parser,
48
+ * linker, and validator infrastructure.
49
+ *
50
+ * @param text - DomainLang source code
51
+ * @returns QueryContext with model and query API
52
+ * @throws Error if parsing fails
53
+ */
54
+ loadFromText(text: string): Promise<QueryContext>;
55
+
56
+ /** The underlying DomainLang services (for advanced use). */
57
+ readonly services: DomainLangServices;
58
+ }
59
+
60
+ /** Internal counter for unique document URIs within a loader. */
61
+ let documentCounter = 0;
62
+
63
+ /**
64
+ * Parses text into a QueryContext using the provided services.
65
+ * Shared implementation for both `loadModelFromText` and `ModelLoader.loadFromText`.
66
+ */
67
+ async function parseTextToContext(
68
+ text: string,
69
+ langServices: DomainLangServices,
70
+ shared: LangiumSharedServices
71
+ ): Promise<QueryContext> {
72
+ // Use unique URI per parse to avoid document conflicts
73
+ const uri = URI.parse(`memory:///model-${documentCounter++}.dlang`);
74
+ const document = shared.workspace.LangiumDocumentFactory.fromString<Model>(text, uri);
75
+
76
+ // Register and build document
77
+ shared.workspace.LangiumDocuments.addDocument(document);
78
+ try {
79
+ await shared.workspace.DocumentBuilder.build([document], { validation: true });
80
+
81
+ // Check for parsing errors
82
+ if (document.parseResult.lexerErrors.length > 0) {
83
+ const errors = document.parseResult.lexerErrors.map(e => e.message).join('\n ');
84
+ throw new Error(`Lexer errors:\n ${errors}`);
85
+ }
86
+
87
+ if (document.parseResult.parserErrors.length > 0) {
88
+ const errors = document.parseResult.parserErrors.map(e => e.message).join('\n ');
89
+ throw new Error(`Parser errors:\n ${errors}`);
90
+ }
91
+
92
+ const model = document.parseResult.value;
93
+ if (!isModel(model)) {
94
+ throw new Error(`Document root is not a Model`);
95
+ }
96
+
97
+ // Augment AST nodes with SDK properties
98
+ augmentModel(model);
99
+
100
+ return {
101
+ model,
102
+ documents: [document.uri],
103
+ query: fromModel(model),
104
+ };
105
+ } finally {
106
+ // Clean up the document to prevent memory leaks across repeated calls
107
+ shared.workspace.LangiumDocuments.deleteDocument(uri);
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Creates a reusable model loader that shares Langium services across parse calls.
113
+ *
114
+ * **Browser-safe** - uses in-memory file system (EmptyFileSystem).
115
+ *
116
+ * For applications that parse multiple texts (web playgrounds, REPLs, batch tools),
117
+ * this avoids the overhead of creating new Langium services for each parse call.
118
+ *
119
+ * @returns A ModelLoader instance that can be used repeatedly
120
+ *
121
+ * @example
122
+ * ```typescript
123
+ * import { createModelLoader } from '@domainlang/language/sdk';
124
+ *
125
+ * const loader = createModelLoader();
126
+ *
127
+ * // Parse multiple texts efficiently - services are reused
128
+ * const result1 = await loader.loadFromText('Domain Sales { vision: "Sales" }');
129
+ * const result2 = await loader.loadFromText('Domain Billing { vision: "Billing" }');
130
+ * ```
131
+ */
132
+ export function createModelLoader(): ModelLoader {
133
+ const servicesObj = createDomainLangServices(EmptyFileSystem);
134
+ const shared = servicesObj.shared;
135
+ const langServices = servicesObj.DomainLang;
136
+
137
+ return {
138
+ async loadFromText(text: string): Promise<QueryContext> {
139
+ return parseTextToContext(text, langServices, shared);
140
+ },
141
+ get services(): DomainLangServices {
142
+ return langServices;
143
+ }
144
+ };
145
+ }
146
+
27
147
  /**
28
148
  * Loads a DomainLang model from a text string.
29
149
  *
30
150
  * **Browser-safe** - uses in-memory file system (EmptyFileSystem).
31
151
  *
152
+ * For repeated parsing, prefer {@link createModelLoader} to reuse services.
153
+ *
32
154
  * Useful for:
33
155
  * - Testing
34
- * - REPL environments
35
- * - Web-based editors
156
+ * - One-off parsing
36
157
  * - Any environment without file system access
37
158
  *
38
159
  * @param text - DomainLang source code
@@ -63,37 +184,7 @@ export async function loadModelFromText(
63
184
  : createDomainLangServices(EmptyFileSystem);
64
185
 
65
186
  const shared = servicesObj.shared;
187
+ const langServices = servicesObj.DomainLang;
66
188
 
67
- // Create document from text with a virtual URI
68
- const uri = URI.parse('memory:///model.dlang');
69
- const document = shared.workspace.LangiumDocumentFactory.fromString<Model>(text, uri);
70
-
71
- // Register and build document
72
- shared.workspace.LangiumDocuments.addDocument(document);
73
- await shared.workspace.DocumentBuilder.build([document], { validation: true });
74
-
75
- // Check for parsing errors
76
- if (document.parseResult.lexerErrors.length > 0) {
77
- const errors = document.parseResult.lexerErrors.map(e => e.message).join('\n ');
78
- throw new Error(`Lexer errors:\n ${errors}`);
79
- }
80
-
81
- if (document.parseResult.parserErrors.length > 0) {
82
- const errors = document.parseResult.parserErrors.map(e => e.message).join('\n ');
83
- throw new Error(`Parser errors:\n ${errors}`);
84
- }
85
-
86
- const model = document.parseResult.value;
87
- if (!isModel(model)) {
88
- throw new Error(`Document root is not a Model`);
89
- }
90
-
91
- // Augment AST nodes with SDK properties
92
- augmentModel(model);
93
-
94
- return {
95
- model,
96
- documents: [document.uri],
97
- query: fromModel(model),
98
- };
189
+ return parseTextToContext(text, langServices, shared);
99
190
  }