@domainlang/language 0.10.0 → 0.12.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 (87) hide show
  1. package/README.md +44 -102
  2. package/out/diagram/context-map-diagram-generator.d.ts +65 -0
  3. package/out/diagram/context-map-diagram-generator.js +356 -0
  4. package/out/diagram/context-map-diagram-generator.js.map +1 -0
  5. package/out/diagram/context-map-layout-configurator.d.ts +15 -0
  6. package/out/diagram/context-map-layout-configurator.js +39 -0
  7. package/out/diagram/context-map-layout-configurator.js.map +1 -0
  8. package/out/diagram/elk-layout-factory.d.ts +43 -0
  9. package/out/diagram/elk-layout-factory.js +64 -0
  10. package/out/diagram/elk-layout-factory.js.map +1 -0
  11. package/out/domain-lang-module.d.ts +9 -2
  12. package/out/domain-lang-module.js +13 -4
  13. package/out/domain-lang-module.js.map +1 -1
  14. package/out/index.d.ts +6 -0
  15. package/out/index.js +7 -0
  16. package/out/index.js.map +1 -1
  17. package/out/lsp/domain-lang-code-lens-provider.d.ts +8 -0
  18. package/out/lsp/domain-lang-code-lens-provider.js +48 -0
  19. package/out/lsp/domain-lang-code-lens-provider.js.map +1 -0
  20. package/out/lsp/domain-lang-completion.js +1 -1
  21. package/out/lsp/domain-lang-completion.js.map +1 -1
  22. package/out/lsp/domain-lang-document-symbol-provider.js +5 -5
  23. package/out/lsp/domain-lang-document-symbol-provider.js.map +1 -1
  24. package/out/lsp/domain-lang-index-manager.d.ts +149 -5
  25. package/out/lsp/domain-lang-index-manager.js +388 -52
  26. package/out/lsp/domain-lang-index-manager.js.map +1 -1
  27. package/out/lsp/domain-lang-refresh.d.ts +35 -0
  28. package/out/lsp/domain-lang-refresh.js +129 -0
  29. package/out/lsp/domain-lang-refresh.js.map +1 -0
  30. package/out/lsp/domain-lang-workspace-manager.d.ts +10 -0
  31. package/out/lsp/domain-lang-workspace-manager.js +35 -0
  32. package/out/lsp/domain-lang-workspace-manager.js.map +1 -1
  33. package/out/lsp/tool-handlers.js +63 -57
  34. package/out/lsp/tool-handlers.js.map +1 -1
  35. package/out/main.js +30 -190
  36. package/out/main.js.map +1 -1
  37. package/out/sdk/loader-node.js +1 -1
  38. package/out/sdk/loader-node.js.map +1 -1
  39. package/out/sdk/validator.js +17 -14
  40. package/out/sdk/validator.js.map +1 -1
  41. package/out/services/import-resolver.d.ts +67 -17
  42. package/out/services/import-resolver.js +146 -65
  43. package/out/services/import-resolver.js.map +1 -1
  44. package/out/services/lsp-logger.d.ts +42 -0
  45. package/out/services/lsp-logger.js +50 -0
  46. package/out/services/lsp-logger.js.map +1 -0
  47. package/out/services/lsp-runtime-settings.d.ts +20 -0
  48. package/out/services/lsp-runtime-settings.js +20 -0
  49. package/out/services/lsp-runtime-settings.js.map +1 -0
  50. package/out/services/performance-optimizer.d.ts +9 -9
  51. package/out/services/performance-optimizer.js +17 -41
  52. package/out/services/performance-optimizer.js.map +1 -1
  53. package/out/services/workspace-manager.d.ts +22 -1
  54. package/out/services/workspace-manager.js +57 -9
  55. package/out/services/workspace-manager.js.map +1 -1
  56. package/out/utils/import-utils.js +6 -6
  57. package/out/utils/import-utils.js.map +1 -1
  58. package/out/validation/constants.d.ts +6 -0
  59. package/out/validation/constants.js +7 -0
  60. package/out/validation/constants.js.map +1 -1
  61. package/out/validation/import.d.ts +13 -3
  62. package/out/validation/import.js +54 -10
  63. package/out/validation/import.js.map +1 -1
  64. package/package.json +5 -2
  65. package/src/diagram/context-map-diagram-generator.ts +451 -0
  66. package/src/diagram/context-map-layout-configurator.ts +43 -0
  67. package/src/diagram/elk-layout-factory.ts +83 -0
  68. package/src/domain-lang-module.ts +22 -5
  69. package/src/index.ts +8 -0
  70. package/src/lsp/domain-lang-code-lens-provider.ts +54 -0
  71. package/src/lsp/domain-lang-completion.ts +3 -3
  72. package/src/lsp/domain-lang-document-symbol-provider.ts +5 -5
  73. package/src/lsp/domain-lang-index-manager.ts +438 -56
  74. package/src/lsp/domain-lang-refresh.ts +205 -0
  75. package/src/lsp/domain-lang-workspace-manager.ts +45 -0
  76. package/src/lsp/tool-handlers.ts +61 -47
  77. package/src/main.ts +36 -244
  78. package/src/sdk/loader-node.ts +1 -1
  79. package/src/sdk/validator.ts +17 -13
  80. package/src/services/import-resolver.ts +196 -89
  81. package/src/services/lsp-logger.ts +89 -0
  82. package/src/services/lsp-runtime-settings.ts +34 -0
  83. package/src/services/performance-optimizer.ts +18 -57
  84. package/src/services/workspace-manager.ts +62 -10
  85. package/src/utils/import-utils.ts +6 -6
  86. package/src/validation/constants.ts +9 -0
  87. package/src/validation/import.ts +67 -12
@@ -1,15 +1,67 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { DocumentState, SimpleCache, WorkspaceCache, URI, type LangiumDocument, type LangiumSharedCoreServices } from 'langium';
4
- import { WorkspaceManager } from './workspace-manager.js';
3
+ import { DocumentCache, SimpleCache, URI, type LangiumDocument, type LangiumSharedCoreServices } from 'langium';
4
+ import { ManifestManager } from './workspace-manager.js';
5
5
  import type { DomainLangServices } from '../domain-lang-module.js';
6
6
  import type { LockFile } from './types.js';
7
+ import { getLspRuntimeSettings } from './lsp-runtime-settings.js';
8
+
9
+ // --- PRS-017 R8: Structured import resolution errors ---
10
+
11
+ /**
12
+ * Resolution failure reason codes for programmatic handling.
13
+ */
14
+ export type ImportResolutionReason =
15
+ | 'file-not-found'
16
+ | 'unknown-alias'
17
+ | 'missing-manifest'
18
+ | 'not-installed'
19
+ | 'dependency-not-found'
20
+ | 'missing-entry'
21
+ | 'unresolvable';
22
+
23
+ /**
24
+ * Structured error for import resolution failures.
25
+ *
26
+ * Carries the specifier, attempted paths, a reason code, and
27
+ * a human-readable hint so callers can build precise diagnostics
28
+ * without parsing error message strings.
29
+ */
30
+ export class ImportResolutionError extends Error {
31
+ /** The import specifier that failed to resolve. */
32
+ readonly specifier: string;
33
+ /** Paths that were tried during resolution (in order). */
34
+ readonly attemptedPaths: readonly string[];
35
+ /** Machine-readable failure reason. */
36
+ readonly reason: ImportResolutionReason;
37
+ /** Human-readable suggestion for fixing the problem. */
38
+ readonly hint: string;
39
+
40
+ constructor(opts: {
41
+ specifier: string;
42
+ attemptedPaths?: string[];
43
+ reason: ImportResolutionReason;
44
+ hint: string;
45
+ message?: string;
46
+ }) {
47
+ const msg = opts.message ?? `Cannot resolve import '${opts.specifier}': ${opts.hint}`;
48
+ super(msg);
49
+ this.name = 'ImportResolutionError';
50
+ this.specifier = opts.specifier;
51
+ this.attemptedPaths = Object.freeze(opts.attemptedPaths ?? []);
52
+ this.reason = opts.reason;
53
+ this.hint = opts.hint;
54
+ }
55
+ }
7
56
 
8
57
  /**
9
58
  * Cache interface for import resolution.
10
- * Uses WorkspaceCache in LSP mode (clears on ANY document change) or SimpleCache in standalone mode.
59
+ * In LSP mode: DocumentCache segments cache per-document URI, auto-invalidating only
60
+ * the changed document's sub-map. Cross-document invalidation (when an imported file
61
+ * moves/deletes) is handled by DomainLangIndexManager calling invalidateForDocuments().
62
+ * In standalone mode: SimpleCache with manual invalidation via clearCache().
11
63
  */
12
- type ResolverCache = WorkspaceCache<string, URI> | SimpleCache<string, URI>;
64
+ type ResolverCache = DocumentCache<string, URI> | SimpleCache<string, URI>;
13
65
 
14
66
  /**
15
67
  * ImportResolver resolves import statements using manifest-centric rules (PRS-010).
@@ -23,87 +75,129 @@ type ResolverCache = WorkspaceCache<string, URI> | SimpleCache<string, URI>;
23
75
  * - ./types → ./types/index.dlang → ./types.dlang
24
76
  * - Module entry defaults to index.dlang (no model.yaml required)
25
77
  *
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
78
+ * Caching Strategy (PRS-017 R1 — uses Langium standard infrastructure):
79
+ * - LSP mode: Uses `DocumentCache` keyed by importing document URI
80
+ * Each document's import resolutions are cached independently.
81
+ * When a document changes, only ITS cache entries are auto-cleared.
82
+ * Cross-document invalidation (when an imported file moves/deletes) is
83
+ * handled by DomainLangIndexManager calling `invalidateForDocuments()`
84
+ * with the reverse dependency graph.
29
85
  * - Standalone mode: Uses `SimpleCache` - manual invalidation via clearCache()
30
86
  *
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
87
+ * Why DocumentCache with manual cross-invalidation (not WorkspaceCache)?
88
+ * - WorkspaceCache clears the ENTIRE cache on ANY document change
89
+ * - In a 50-file workspace, editing one file caused ~50 redundant re-resolutions
90
+ * - DocumentCache + targeted invalidation via reverse dep graph only clears
91
+ * the changed file and its direct/transitive importers
92
+ * - This matches gopls' per-package invalidation strategy
38
93
  *
39
94
  * @see https://langium.org/docs/recipes/caching/ for Langium caching patterns
40
95
  */
41
96
  export class ImportResolver {
42
- private readonly workspaceManager: WorkspaceManager;
97
+ private readonly workspaceManager: ManifestManager;
43
98
  /**
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()
99
+ * Per-document cache for resolved import URIs.
100
+ * In LSP mode: DocumentCache - clears only the changed document's entries.
101
+ * Cross-document invalidation handled by DomainLangIndexManager.
102
+ * In standalone mode: SimpleCache - manual invalidation via clearCache().
47
103
  */
48
104
  private readonly resolverCache: ResolverCache;
105
+
106
+ /**
107
+ * Whether the cache is a DocumentCache (LSP mode) for targeted invalidation.
108
+ */
109
+ private readonly isDocumentCache: boolean;
49
110
 
50
111
  /**
51
112
  * Creates an ImportResolver.
52
113
  *
53
- * @param services - DomainLang services. If `services.shared` is present, uses WorkspaceCache
54
- * for automatic invalidation. Otherwise uses SimpleCache for standalone mode.
114
+ * @param services - DomainLang services. If `services.shared` is present, uses DocumentCache
115
+ * for per-document invalidation. Otherwise uses SimpleCache for standalone mode.
55
116
  */
56
117
  constructor(services: DomainLangServices) {
57
- this.workspaceManager = services.imports.WorkspaceManager;
118
+ this.workspaceManager = services.imports.ManifestManager;
58
119
 
59
- // Use Langium's WorkspaceCache when shared services are available (LSP mode)
120
+ // Use Langium's DocumentCache when shared services are available (LSP mode)
60
121
  // Fall back to SimpleCache for standalone utilities (SDK, CLI)
61
122
  const shared = (services as DomainLangServices & { shared?: LangiumSharedCoreServices }).shared;
62
123
  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)
124
+ // LSP mode: DocumentCache per-document sub-maps (PRS-017 R1)
70
125
  //
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);
126
+ // Each document's import resolutions are cached in a separate sub-map.
127
+ // When a document changes, only ITS sub-map is auto-cleared.
128
+ // Cross-document invalidation (imported file moved/deleted) is handled
129
+ // by DomainLangIndexManager calling invalidateForDocuments() with the
130
+ // reverse dependency graph.
131
+ //
132
+ // This replaces the previous WorkspaceCache which cleared EVERYTHING
133
+ // on any change, causing redundant re-resolutions across the workspace.
134
+ this.resolverCache = new DocumentCache<string, URI>(shared);
135
+ this.isDocumentCache = true;
76
136
  } else {
77
137
  // Standalone mode: simple key-value cache, manual invalidation
78
138
  this.resolverCache = new SimpleCache<string, URI>();
139
+ this.isDocumentCache = false;
79
140
  }
80
141
  }
81
142
 
82
143
  /**
83
144
  * Clears the entire import resolution cache.
84
- * In LSP mode, this is also triggered automatically by WorkspaceCache on any document change.
85
145
  * Call explicitly when model.yaml or model.lock changes.
86
146
  */
87
147
  clearCache(): void {
88
148
  this.resolverCache.clear();
89
149
  }
90
150
 
151
+ /**
152
+ * Invalidates cached import resolutions for specific documents (PRS-017 R1).
153
+ *
154
+ * Called by DomainLangIndexManager when files change, using the reverse
155
+ * dependency graph to determine which documents' caches need clearing.
156
+ * This provides targeted invalidation instead of clearing the entire cache.
157
+ *
158
+ * @param uris - Document URIs whose import resolution caches should be cleared
159
+ */
160
+ invalidateForDocuments(uris: Iterable<string>): void {
161
+ if (this.isDocumentCache) {
162
+ const docCache = this.resolverCache as DocumentCache<string, URI>;
163
+ for (const uri of uris) {
164
+ docCache.clear(URI.parse(uri));
165
+ }
166
+ }
167
+ }
168
+
91
169
  /**
92
170
  * Resolve an import specifier relative to a Langium document.
93
- * Results are cached using WorkspaceCache (clears on any workspace change).
171
+ * Results are cached per-document using DocumentCache (PRS-017 R1).
94
172
  */
95
173
  async resolveForDocument(document: LangiumDocument, specifier: string): Promise<URI> {
96
- // Cache key combines document URI + specifier for uniqueness
174
+ if (this.isDocumentCache) {
175
+ // LSP mode: DocumentCache with (documentUri, specifier) as two-part key
176
+ const docCache = this.resolverCache as DocumentCache<string, URI>;
177
+ const cached = docCache.get(document.uri, specifier);
178
+ if (cached) {
179
+ this.trace(`[cache hit] ${specifier} from ${document.uri.fsPath}`);
180
+ return cached;
181
+ }
182
+ const baseDir = path.dirname(document.uri.fsPath);
183
+ const result = await this.resolveFrom(baseDir, specifier);
184
+ this.trace(`[resolved] ${specifier} from ${document.uri.fsPath} → ${result.fsPath}`);
185
+ docCache.set(document.uri, specifier, result);
186
+ return result;
187
+ }
188
+
189
+ // Standalone mode: SimpleCache with composite key
190
+ const simpleCache = this.resolverCache as SimpleCache<string, URI>;
97
191
  const cacheKey = `${document.uri.toString()}|${specifier}`;
98
- const cached = this.resolverCache.get(cacheKey);
192
+ const cached = simpleCache.get(cacheKey);
99
193
  if (cached) {
194
+ this.trace(`[cache hit] ${specifier}`);
100
195
  return cached;
101
196
  }
102
-
103
- // Resolve and cache
104
197
  const baseDir = path.dirname(document.uri.fsPath);
105
198
  const result = await this.resolveFrom(baseDir, specifier);
106
- this.resolverCache.set(cacheKey, result);
199
+ this.trace(`[resolved] ${specifier} → ${result.fsPath}`);
200
+ simpleCache.set(cacheKey, result);
107
201
  return result;
108
202
  }
109
203
 
@@ -156,12 +250,12 @@ export class ImportResolver {
156
250
  return this.resolveLocalPath(resolved, specifier);
157
251
  }
158
252
 
159
- throw new Error(
160
- `Unknown path alias '${specifier.split('/')[0]}' in import '${specifier}'.\n` +
161
- `Hint: Define it in model.yaml paths section:\n` +
162
- ` paths:\n` +
163
- ` "${specifier.split('/')[0]}": "./some/path"`
164
- );
253
+ throw new ImportResolutionError({
254
+ specifier,
255
+ reason: 'unknown-alias',
256
+ hint: `Define it in model.yaml paths section:\n paths:\n "${specifier.split('/')[0]}": "./some/path"`,
257
+ message: `Unknown path alias '${specifier.split('/')[0]}' in import '${specifier}'.\nHint: Define it in model.yaml paths section.`
258
+ });
165
259
  }
166
260
 
167
261
  /**
@@ -204,34 +298,33 @@ export class ImportResolver {
204
298
  private async resolveExternalDependency(specifier: string): Promise<URI> {
205
299
  const manifest = await this.workspaceManager.getManifest();
206
300
  if (!manifest) {
207
- throw new Error(
208
- `External dependency '${specifier}' requires model.yaml.\n` +
209
- `Hint: Create model.yaml and add the dependency:\n` +
210
- ` dependencies:\n` +
211
- ` ${specifier}:\n` +
212
- ` ref: v1.0.0`
213
- );
301
+ throw new ImportResolutionError({
302
+ specifier,
303
+ reason: 'missing-manifest',
304
+ hint: `Create model.yaml and add the dependency:\n dependencies:\n ${specifier}:\n ref: v1.0.0`,
305
+ message: `External dependency '${specifier}' requires model.yaml.`
306
+ });
214
307
  }
215
308
 
216
309
  const lock = await this.workspaceManager.getLockFile();
217
310
  if (!lock) {
218
- throw new Error(
219
- `Dependency '${specifier}' not installed.\n` +
220
- `Hint: Run 'dlang install' to fetch dependencies and generate model.lock.`
221
- );
311
+ throw new ImportResolutionError({
312
+ specifier,
313
+ reason: 'not-installed',
314
+ hint: "Run 'dlang install' to fetch dependencies and generate model.lock.",
315
+ message: `Dependency '${specifier}' not installed.`
316
+ });
222
317
  }
223
318
 
224
319
  // Use WorkspaceManager to resolve from cache (read-only, no network)
225
320
  const resolved = await this.workspaceManager.resolveDependencyPath(specifier);
226
321
  if (!resolved) {
227
- throw new Error(
228
- `Dependency '${specifier}' not found in model.yaml or not installed.\n` +
229
- `Hint: Add it to your dependencies:\n` +
230
- ` dependencies:\n` +
231
- ` ${specifier}:\n` +
232
- ` ref: v1.0.0\n` +
233
- `Then run 'dlang install' to fetch it.`
234
- );
322
+ throw new ImportResolutionError({
323
+ specifier,
324
+ reason: 'dependency-not-found',
325
+ hint: `Add it to your dependencies:\n dependencies:\n ${specifier}:\n ref: v1.0.0\nThen run 'dlang install' to fetch it.`,
326
+ message: `Dependency '${specifier}' not found in model.yaml or not installed.`
327
+ });
235
328
  }
236
329
 
237
330
  return URI.file(resolved);
@@ -256,10 +349,13 @@ export class ImportResolver {
256
349
  }
257
350
 
258
351
  if (ext && ext !== '.dlang') {
259
- throw new Error(
260
- `Invalid file extension '${ext}' in import '${original}'.\n` +
261
- `Hint: DomainLang files must use the .dlang extension.`
262
- );
352
+ throw new ImportResolutionError({
353
+ specifier: original,
354
+ attemptedPaths: [resolved],
355
+ reason: 'unresolvable',
356
+ hint: `DomainLang files must use the .dlang extension.`,
357
+ message: `Invalid file extension '${ext}' in import '${original}'.`
358
+ });
263
359
  }
264
360
 
265
361
  // No extension → directory-first resolution
@@ -286,13 +382,12 @@ export class ImportResolver {
286
382
  }
287
383
 
288
384
  // Directory exists but no entry file
289
- throw new Error(
290
- `Module '${original}' is missing its entry file.\n` +
291
- `Expected: ${resolved}/${entryPoint}\n` +
292
- `Hint: Create '${entryPoint}' in the module directory, or specify a custom entry in model.yaml:\n` +
293
- ` model:\n` +
294
- ` entry: main.dlang`
295
- );
385
+ throw new ImportResolutionError({
386
+ specifier: original,
387
+ attemptedPaths: [path.join(resolved, entryPoint)],
388
+ reason: 'missing-entry',
389
+ hint: `Create '${entryPoint}' in the module directory, or specify a custom entry in model.yaml:\n model:\n entry: main.dlang`
390
+ });
296
391
  }
297
392
 
298
393
  // Step 2: Try .dlang file fallback
@@ -302,13 +397,12 @@ export class ImportResolver {
302
397
  }
303
398
 
304
399
  // Neither directory nor file found
305
- throw new Error(
306
- `Cannot resolve import '${original}'.\n` +
307
- `Tried:\n` +
308
- ` • ${resolved}/index.dlang (directory module)\n` +
309
- ` • ${resolved}.dlang (file)\n` +
310
- `Hint: Check that the path is correct and the file exists.`
311
- );
400
+ throw new ImportResolutionError({
401
+ specifier: original,
402
+ attemptedPaths: [`${resolved}/index.dlang`, `${resolved}.dlang`],
403
+ reason: 'file-not-found',
404
+ hint: 'Check that the path is correct and the file exists.'
405
+ });
312
406
  }
313
407
 
314
408
  /**
@@ -356,16 +450,29 @@ export class ImportResolver {
356
450
  async getLockFile(): Promise<LockFile | undefined> {
357
451
  return this.workspaceManager.getLockFile();
358
452
  }
453
+
454
+ // --- PRS-017 R10: Import resolution tracing ---
455
+
456
+ /**
457
+ * Logs an import resolution trace message when `domainlang.lsp.traceImports` is enabled.
458
+ * Output goes to stderr so it's visible in the LSP output channel.
459
+ */
460
+ private trace(message: string): void {
461
+ if (getLspRuntimeSettings().traceImports) {
462
+ console.warn(`[ImportResolver] ${message}`);
463
+ }
464
+ }
359
465
  }
360
466
 
361
467
  async function assertFileExists(filePath: string, original: string): Promise<void> {
362
468
  try {
363
469
  await fs.access(filePath);
364
470
  } catch {
365
- throw new Error(
366
- `Import file not found: '${original}'.\n` +
367
- `Resolved path: ${filePath}\n` +
368
- `Hint: Check that the file exists and the path is correct.`
369
- );
471
+ throw new ImportResolutionError({
472
+ specifier: original,
473
+ attemptedPaths: [filePath],
474
+ reason: 'file-not-found',
475
+ hint: 'Check that the file exists and the path is correct.'
476
+ });
370
477
  }
371
478
  }
@@ -0,0 +1,89 @@
1
+ import { getLspRuntimeSettings } from './lsp-runtime-settings.js';
2
+
3
+ /**
4
+ * Structured LSP logger for DomainLang (PRS-017 R17).
5
+ *
6
+ * Wraps console.warn/error with structured context (component name,
7
+ * document URI, timing data) so that log messages are easy to
8
+ * correlate when debugging multi-file change propagation.
9
+ *
10
+ * Usage:
11
+ * ```ts
12
+ * const log = createLogger('IndexManager');
13
+ * log.info('exports changed', { uri, count: 3 });
14
+ * log.warn('stale cache entry');
15
+ * log.error('cycle detected', { cycle: ['a', 'b', 'a'] });
16
+ * log.timed('rebuildAll', async () => { ...work... });
17
+ * ```
18
+ *
19
+ * Output goes to stderr (visible in VS Code's "Output" → "DomainLang" channel)
20
+ * because the LSP protocol uses stdout for JSON-RPC messages.
21
+ */
22
+
23
+ /** Structured context attached to log messages. */
24
+ export interface LogContext {
25
+ /** Langium document URI, shortened for readability. */
26
+ uri?: string;
27
+ /** Additional key → value pairs (serialised as JSON). */
28
+ [key: string]: unknown;
29
+ }
30
+
31
+ export interface LspLogger {
32
+ info(message: string, context?: LogContext): void;
33
+ warn(message: string, context?: LogContext): void;
34
+ error(message: string, context?: LogContext): void;
35
+ /**
36
+ * Measures and logs the duration of an async operation.
37
+ * Returns the operation's result.
38
+ */
39
+ timed<T>(label: string, fn: () => T | Promise<T>): Promise<T>;
40
+ }
41
+
42
+ function formatContext(ctx: LogContext | undefined): string {
43
+ if (!ctx || Object.keys(ctx).length === 0) return '';
44
+ // Shorten file:// URIs to just the filename for readability
45
+ const display = { ...ctx };
46
+ if (typeof display.uri === 'string') {
47
+ const parts = display.uri.split('/');
48
+ display.uri = parts.at(-1);
49
+ }
50
+ return ` ${JSON.stringify(display)}`;
51
+ }
52
+
53
+ /**
54
+ * Creates a structured logger scoped to a named component.
55
+ *
56
+ * @param component - Short component name (e.g. 'IndexManager', 'ImportResolver')
57
+ */
58
+ export function createLogger(component: string): LspLogger {
59
+ const prefix = `[DomainLang:${component}]`;
60
+
61
+ return {
62
+ info(message: string, context?: LogContext): void {
63
+ if (getLspRuntimeSettings().infoLogs) {
64
+ console.warn(`${prefix} ${message}${formatContext(context)}`);
65
+ }
66
+ },
67
+ warn(message: string, context?: LogContext): void {
68
+ console.warn(`${prefix} WARN ${message}${formatContext(context)}`);
69
+ },
70
+ error(message: string, context?: LogContext): void {
71
+ console.error(`${prefix} ERROR ${message}${formatContext(context)}`);
72
+ },
73
+ async timed<T>(label: string, fn: () => T | Promise<T>): Promise<T> {
74
+ const start = performance.now();
75
+ try {
76
+ const result = await fn();
77
+ const elapsed = (performance.now() - start).toFixed(1);
78
+ if (getLspRuntimeSettings().infoLogs) {
79
+ console.warn(`${prefix} ${label} completed in ${elapsed}ms`);
80
+ }
81
+ return result;
82
+ } catch (err) {
83
+ const elapsed = (performance.now() - start).toFixed(1);
84
+ console.error(`${prefix} ${label} failed after ${elapsed}ms`);
85
+ throw err;
86
+ }
87
+ }
88
+ };
89
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Runtime LSP settings configured by the VS Code client.
3
+ *
4
+ * Defaults are conservative (both disabled) and can be updated via
5
+ * initialization options and workspace configuration changes.
6
+ */
7
+ export interface DomainLangLspRuntimeSettings {
8
+ /** Enables import resolution trace logging. */
9
+ traceImports: boolean;
10
+ /** Enables info-level/timing logs. Warnings/errors are always logged. */
11
+ infoLogs: boolean;
12
+ }
13
+
14
+ let runtimeSettings: DomainLangLspRuntimeSettings = {
15
+ traceImports: false,
16
+ infoLogs: false,
17
+ };
18
+
19
+ /**
20
+ * Updates runtime settings.
21
+ */
22
+ export function setLspRuntimeSettings(next: Partial<DomainLangLspRuntimeSettings>): void {
23
+ runtimeSettings = {
24
+ ...runtimeSettings,
25
+ ...next,
26
+ };
27
+ }
28
+
29
+ /**
30
+ * Returns current runtime settings.
31
+ */
32
+ export function getLspRuntimeSettings(): DomainLangLspRuntimeSettings {
33
+ return runtimeSettings;
34
+ }
@@ -5,33 +5,26 @@
5
5
  * - In-memory caching of frequently accessed lock files
6
6
  * - Parallel dependency downloads
7
7
  * - Cache warming strategies
8
- * - Stale cache detection
8
+ * - Event-based invalidation (PRS-017 R15)
9
9
  */
10
10
 
11
11
  import type { LockFile } from './types.js';
12
12
  import path from 'node:path';
13
13
  import fs from 'node:fs/promises';
14
14
 
15
- /**
16
- * Cache entry with timestamp for TTL management.
17
- */
18
- interface CacheEntry<T> {
19
- value: T;
20
- timestamp: number;
21
- }
22
-
23
15
  /**
24
16
  * Performance optimizer with in-memory caching.
17
+ *
18
+ * PRS-017 R15: Cache entries are valid until explicitly invalidated
19
+ * by `invalidateCache()` or `clearAllCaches()`. The previous TTL-based
20
+ * approach could serve stale data (within window) or unnecessarily
21
+ * re-read unchanged files (after expiry). Event-based invalidation
22
+ * via `processManifestChanges()` and `processLockFileChanges()` is
23
+ * always correct and immediate.
25
24
  */
26
25
  export class PerformanceOptimizer {
27
- private lockFileCache = new Map<string, CacheEntry<LockFile>>();
28
- private manifestCache = new Map<string, CacheEntry<unknown>>();
29
- private readonly cacheTTL: number;
30
-
31
- constructor(options: { cacheTTL?: number } = {}) {
32
- // Default TTL: 5 minutes
33
- this.cacheTTL = options.cacheTTL ?? 5 * 60 * 1000;
34
- }
26
+ private lockFileCache = new Map<string, LockFile>();
27
+ private manifestCache = new Map<string, unknown>();
35
28
 
36
29
  /**
37
30
  * Gets a lock file from cache or loads it from disk.
@@ -40,9 +33,8 @@ export class PerformanceOptimizer {
40
33
  const cacheKey = this.normalizePath(workspaceRoot);
41
34
  const cached = this.lockFileCache.get(cacheKey);
42
35
 
43
- // Check if cache is still valid
44
- if (cached && Date.now() - cached.timestamp < this.cacheTTL) {
45
- return cached.value;
36
+ if (cached) {
37
+ return cached;
46
38
  }
47
39
 
48
40
  // Load from disk
@@ -52,10 +44,7 @@ export class PerformanceOptimizer {
52
44
  const lockFile = JSON.parse(content) as LockFile;
53
45
 
54
46
  // Cache it
55
- this.lockFileCache.set(cacheKey, {
56
- value: lockFile,
57
- timestamp: Date.now(),
58
- });
47
+ this.lockFileCache.set(cacheKey, lockFile);
59
48
 
60
49
  return lockFile;
61
50
  } catch {
@@ -70,8 +59,8 @@ export class PerformanceOptimizer {
70
59
  const cacheKey = this.normalizePath(manifestPath);
71
60
  const cached = this.manifestCache.get(cacheKey);
72
61
 
73
- if (cached && Date.now() - cached.timestamp < this.cacheTTL) {
74
- return cached.value;
62
+ if (cached) {
63
+ return cached;
75
64
  }
76
65
 
77
66
  try {
@@ -79,10 +68,7 @@ export class PerformanceOptimizer {
79
68
  const { parse } = await import('yaml');
80
69
  const manifest: unknown = parse(content);
81
70
 
82
- this.manifestCache.set(cacheKey, {
83
- value: manifest,
84
- timestamp: Date.now(),
85
- });
71
+ this.manifestCache.set(cacheKey, manifest);
86
72
 
87
73
  return manifest;
88
74
  } catch {
@@ -92,10 +78,12 @@ export class PerformanceOptimizer {
92
78
 
93
79
  /**
94
80
  * Invalidates cache for a specific workspace.
81
+ * Called when model.lock or model.yaml changes (event-based, PRS-017 R15).
95
82
  */
96
83
  invalidateCache(workspaceRoot: string): void {
97
84
  const cacheKey = this.normalizePath(workspaceRoot);
98
85
  this.lockFileCache.delete(cacheKey);
86
+ this.manifestCache.delete(cacheKey);
99
87
  }
100
88
 
101
89
  /**
@@ -116,33 +104,6 @@ export class PerformanceOptimizer {
116
104
  };
117
105
  }
118
106
 
119
- /**
120
- * Detects if cached files are stale compared to disk.
121
- */
122
- async detectStaleCaches(): Promise<string[]> {
123
- const stale: string[] = [];
124
-
125
- for (const [workspaceRoot] of this.lockFileCache) {
126
- const lockPath = path.join(workspaceRoot, 'model.lock');
127
- try {
128
- const stat = await fs.stat(lockPath);
129
- const cached = this.lockFileCache.get(workspaceRoot);
130
-
131
- // Floor mtimeMs to integer precision to match Date.now() —
132
- // some filesystems (e.g. APFS) report sub-millisecond mtime,
133
- // which can exceed the integer timestamp from Date.now().
134
- if (cached && Math.floor(stat.mtimeMs) > cached.timestamp) {
135
- stale.push(workspaceRoot);
136
- }
137
- } catch {
138
- // File doesn't exist anymore
139
- stale.push(workspaceRoot);
140
- }
141
- }
142
-
143
- return stale;
144
- }
145
-
146
107
  /**
147
108
  * Normalizes a file path for cache keys.
148
109
  */