/** * DomainLang Scope Provider * * Implements import-based scoping with alias support and package-boundary transitive imports. * * **Key Concepts (per ADR-003):** * - Elements are only visible if defined in current document OR explicitly imported * - Import aliases control visibility: `import "pkg" as ddd` makes types visible as `ddd.*` only * - Package-boundary transitive imports: External packages (.dlang/packages/) can re-export * - Local file imports remain non-transitive (explicit dependencies only) * * **Why this matters:** * Without this, Langium's DefaultScopeProvider would make ALL indexed documents visible * in the global scope, which would: * 1. Allow referencing elements that haven't been imported * 2. Make the import system meaningless * 3. Create confusion about dependencies between files * * @see https://langium.org/docs/recipes/scoping/ for Langium scoping patterns * @see ADR-003 for alias and package-boundary design decisions */ import type { AstNodeDescription, LangiumDocument, ReferenceInfo, Scope, Stream } from 'langium'; import { AstUtils, DefaultScopeProvider, EMPTY_SCOPE, MapScope, stream, WorkspaceCache } from 'langium'; import type { DomainLangServices } from '../domain-lang-module.js'; import { DomainLangIndexManager } from './domain-lang-index-manager.js'; import type { PackageBoundaryDetector } from '../services/package-boundary-detector.js'; import type { ImportInfo } from '../services/types.js'; import { createLogger } from '../services/lsp-logger.js'; const log = createLogger('ScopeProvider'); /** * Custom scope provider that restricts cross-file references to imported documents only. * * Extends Langium's DefaultScopeProvider to override the global scope computation. */ export class DomainLangScopeProvider extends DefaultScopeProvider { /** * Reference to IndexManager for getting resolved imports with aliases. */ private readonly domainLangIndexManager: DomainLangIndexManager; /** * Detects package boundaries for transitive import resolution. */ private readonly packageBoundaryDetector: PackageBoundaryDetector; /** * Caches the element description index by reference type. * Invalidated automatically when any document in the workspace changes. */ private readonly descByUriCache: WorkspaceCache>; constructor(services: DomainLangServices) { super(services); const indexManager = services.shared.workspace.IndexManager; if (!(indexManager instanceof DomainLangIndexManager)) { throw new Error('IndexManager is not a DomainLangIndexManager — check DI configuration'); } this.domainLangIndexManager = indexManager; this.packageBoundaryDetector = services.imports.PackageBoundaryDetector; this.descByUriCache = new WorkspaceCache(services.shared); } /** * Override getGlobalScope to implement alias-scoped and package-boundary transitive imports. * * The default Langium behavior includes ALL documents in the workspace. * We restrict and transform scope to: * 1. The current document's own exported symbols * 2. Symbols from directly imported documents (with alias prefixing) * 3. Symbols from package-boundary transitive imports (external packages only) * * @param referenceType - The AST type being referenced * @param context - Information about the reference * @returns A scope containing only visible elements */ protected override getGlobalScope(referenceType: string, context: ReferenceInfo): Scope { try { const document = AstUtils.getDocument(context.container); const descriptions = this.computeVisibleDescriptions(referenceType, document); return new MapScope(descriptions); } catch (error) { log.error('Error in getGlobalScope', { error: error instanceof Error ? error.message : String(error) }); return EMPTY_SCOPE; } } /** * Computes all visible descriptions for a document, including: * - Current document's own symbols * - Direct imports (with alias prefixing) * - Package-boundary transitive imports * * @param referenceType - The AST type being referenced * @param document - The document making the reference * @returns Stream of visible descriptions */ private computeVisibleDescriptions( referenceType: string, document: LangiumDocument ): Stream { const docUri = document.uri.toString(); // Cache the expensive allElements() iteration per reference type. // WorkspaceCache auto-invalidates when any document changes. const descByUri = this.descByUriCache.get(referenceType, () => { const map = new Map(); for (const desc of this.indexManager.allElements(referenceType)) { const uriStr = desc.documentUri.toString(); let bucket = map.get(uriStr); if (!bucket) { bucket = []; map.set(uriStr, bucket); } bucket.push(desc); } return map; }); const allVisibleDescriptions: AstNodeDescription[] = []; // 1. Always include current document's own symbols const ownDescriptions = descByUri.get(docUri) ?? []; allVisibleDescriptions.push(...ownDescriptions); // 2. Get import info (with aliases) const importInfo = this.domainLangIndexManager.getImportInfo(docUri); // Track which documents we've already included to avoid duplicates const processedUris = new Set([docUri]); // 3. Process each direct import for (const imp of importInfo) { if (!imp.resolvedUri || processedUris.has(imp.resolvedUri)) { continue; } // Add descriptions from the directly imported document this.addDescriptionsFromImportFast( imp, descByUri, processedUris, allVisibleDescriptions ); // 4. Check for package-boundary transitive imports this.addPackageBoundaryTransitiveImports( imp, descByUri, document, processedUris, allVisibleDescriptions ); } return stream(allVisibleDescriptions); } /** * Adds descriptions from a single import using the pre-computed descByUri index. */ private addDescriptionsFromImportFast( imp: ImportInfo, descByUri: Map, processedUris: Set, output: AstNodeDescription[] ): void { const descriptions = descByUri.get(imp.resolvedUri) ?? []; if (imp.alias) { for (const desc of descriptions) { output.push(this.createAliasedDescription(desc, imp.alias)); } } else { output.push(...descriptions); } processedUris.add(imp.resolvedUri); } /** * Adds package-boundary transitive imports for external packages. * * When document A imports package document B (e.g., index.dlang), * and B imports internal package files C, D, etc. (same package root), * then A can see types from C, D, etc. (package re-exports). * * Local file imports remain non-transitive. * * @param imp - Import information for the direct import * @param descByUri - Pre-computed index of descriptions by document URI * @param currentDocument - The document making the reference * @param processedUris - Set of already-processed URIs to avoid duplicates * @param output - Array to append visible descriptions to */ private addPackageBoundaryTransitiveImports( imp: ImportInfo, descByUri: Map, currentDocument: LangiumDocument, processedUris: Set, output: AstNodeDescription[] ): void { // Get the imports of the imported document (B's imports) const transitiveImports = this.domainLangIndexManager.getImportInfo(imp.resolvedUri); for (const transitiveImp of transitiveImports) { if (!transitiveImp.resolvedUri || processedUris.has(transitiveImp.resolvedUri)) { continue; } // Check if both documents are in the same external package // (package boundary = same commit directory within .dlang/packages/) const samePackage = this.packageBoundaryDetector.areInSamePackageSync( imp.resolvedUri, transitiveImp.resolvedUri ); if (samePackage) { // Within package boundary: include transitive imports // Apply the top-level import's alias (if any) this.addDescriptionsFromImportFast( { specifier: transitiveImp.specifier, alias: imp.alias, // Use the top-level import's alias resolvedUri: transitiveImp.resolvedUri }, descByUri, processedUris, output ); } } } /** * Creates an alias-prefixed version of a description. * * Example: CoreDomain with alias "ddd" → ddd.CoreDomain * * @param original - Original description * @param alias - Import alias to prefix with * @returns New description with prefixed name */ private createAliasedDescription( original: AstNodeDescription, alias: string ): AstNodeDescription { return { ...original, name: `${alias}.${original.name}` }; } }