diff --git a/examples/order-flow/server/test/project-visibility.test.ts b/examples/order-flow/server/test/project-visibility.test.ts index cec6d499..fab1c70e 100644 --- a/examples/order-flow/server/test/project-visibility.test.ts +++ b/examples/order-flow/server/test/project-visibility.test.ts @@ -23,11 +23,19 @@ * holds a single file. */ -import { AstUtils, DocumentState, type ReferenceInfo } from '@hydranium/langium'; -import { describe, expect, it } from 'vitest'; +import { AstUtils, DocumentState, type ReferenceInfo, URI } from '@hydranium/langium'; +import { readFileSync } from 'node:fs'; +import { describe, expect, it, onTestFinished } from 'vitest'; import { type DomainModel, type Entity, type Field, isEntity } from '../src/language-server/ast.js'; import { ORDER_FLOW_STDLIB_URI } from '../src/language-server/order-flow-stdlib.js'; -import { documentFor, makeWorkspaceHarness, type OrderFlowHarness, workspaceUri } from './order-flow-harness.js'; +import { + documentFor, + makeScratchWorkspaceHarness, + makeWorkspaceHarness, + type OrderFlowHarness, + WORKSPACE_FILES, + workspaceUri +} from './order-flow-harness.js'; function entityNamed(model: DomainModel, name: string): Entity { const found = model.declarations.find(declaration => isEntity(declaration) && declaration.name === name); @@ -211,3 +219,44 @@ describe('order-flow visibility — public declarations cross the project bounda expect(candidates).not.toContain('Order'); }); }); + +describe('order-flow projects — a descriptor change', () => { + it('takes effect in the build the edit starts', async () => { + const { harness, workspace } = await makeScratchWorkspaceHarness(); + onTestFinished(() => workspace.dispose()); + const projects = harness.shared.workspace.ProjectManager; + const builder = harness.shared.workspace.DocumentBuilder; + const ordersPath = workspace.resolve(WORKSPACE_FILES.ordersDomain); + const ordersUri = URI.file(ordersPath); + const original = readFileSync(ordersPath, 'utf8'); + const totalTypeName = (): string | undefined => { + const model = harness.shared.workspace.LangiumDocuments.getDocument(ordersUri)!.parseResult.value as DomainModel; + return fieldNamed(entityNamed(model, 'Order'), 'total').type.declared?.ref?.name; + }; + + workspace.write(WORKSPACE_FILES.ordersDomain, original.replace('project orders requires commerce-core', 'project orders')); + await builder.update([ordersUri], []); + expect(projects.getProjectById('orders')?.dependencies).toBeUndefined(); + expect(totalTypeName()).toBeUndefined(); + + workspace.write(WORKSPACE_FILES.ordersDomain, original); + await builder.update([ordersUri], []); + expect(projects.getProjectById('orders')?.dependencies).toEqual(['commerce-core']); + expect(totalTypeName()).toBe('Money'); + }); + + it('registers a descriptor created by an update in that update', async () => { + const { harness, workspace } = await makeScratchWorkspaceHarness(); + onTestFinished(() => workspace.dispose()); + const projects = harness.shared.workspace.ProjectManager; + const refundsUri = URI.file( + workspace.write('refunds/refunds.domain', 'project refunds requires commerce-core\n\nentity Refund {\n amount: Money\n}\n') + ); + + await harness.shared.workspace.DocumentBuilder.update([refundsUri], []); + + expect(projects.getProjectById('refunds')?.dependencies).toEqual(['commerce-core']); + const model = harness.shared.workspace.LangiumDocuments.getDocument(refundsUri)!.parseResult.value as DomainModel; + expect(fieldNamed(entityNamed(model, 'Refund'), 'amount').type.declared?.ref?.name).toBe('Money'); + }); +}); diff --git a/packages/core/src/langium/project/abstract-project-manager.ts b/packages/core/src/langium/project/abstract-project-manager.ts index 2a5206f9..a976be72 100644 --- a/packages/core/src/langium/project/abstract-project-manager.ts +++ b/packages/core/src/langium/project/abstract-project-manager.ts @@ -12,13 +12,14 @@ import { type AstNode, AstUtils, type DocumentBuilder, + DocumentState, type FileSystemProvider, type LangiumDocument, type LangiumDocuments, type URI, UriUtils } from '@hydranium/langium'; -import { Disposable } from 'vscode-languageserver'; +import { CancellationToken, Disposable } from 'vscode-languageserver'; import type { WorkspaceFolder } from 'vscode-languageserver-types'; import type { LogNameOptions } from '../diagnostics/logger.js'; import { type ServerSharedServicesMinimal } from '../shared-services.js'; @@ -377,6 +378,13 @@ export abstract class AbstractProjectManager protected async loadDescriptor(uri: URI): Promise { try { const document = await this.langiumDocuments.getOrCreateDocument(uri); + if (document.state < DocumentState.Parsed) { + // `onUpdate` fires before the build parses, so the AST is still the previous edit's. + // The state is restored so the build still runs, and notifies, its own parse phase. + const state = document.state; + await this.services.workspace.LangiumDocumentFactory.update(document, CancellationToken.None); + document.state = state; + } const project = await this.parseProjectDescriptor(uri, document); if (project) { this.projects.set(project.id, project); diff --git a/packages/core/src/langium/project/project-change-event.ts b/packages/core/src/langium/project/project-change-event.ts index 3b8196a6..b11d933f 100644 --- a/packages/core/src/langium/project/project-change-event.ts +++ b/packages/core/src/langium/project/project-change-event.ts @@ -28,8 +28,8 @@ import type { URI } from '@hydranium/langium'; * - {@link affectedDocuments} lists URIs whose reachable scope changed. The * default cascade plumbing in `HydraniumWorkspaceManager` subscribes to * this event and re-runs `DocumentBuilder.resetToState` for each entry, - * which the next build cycle picks up. Consumers can subscribe - * independently for other reactions. + * which the build of the update that changed the descriptor picks up. + * Consumers can subscribe independently for other reactions. */ export interface ProjectChangeEvent { /** Ids of projects newly registered since the last event. */ diff --git a/packages/core/src/langium/workspace/hydranium-workspace-manager.ts b/packages/core/src/langium/workspace/hydranium-workspace-manager.ts index 281867a1..a44668a8 100644 --- a/packages/core/src/langium/workspace/hydranium-workspace-manager.ts +++ b/packages/core/src/langium/workspace/hydranium-workspace-manager.ts @@ -137,8 +137,8 @@ export interface HydraniumWorkspaceManagerOptions extends LogNameOptions { * URIs (its own members plus any transitive dependents per the * `getAffectedProjects` hook). This manager re-runs * `DocumentBuilder.resetToState(doc, DocumentState.Changed)` on each - * affected document so the next build cycle re-links them with the new - * visibility scope. + * affected document so the build of the update that changed the descriptor + * re-links them with the new visibility scope. * * Descriptor URIs that triggered the cycle are intentionally excluded * from the affected set by `AbstractProjectManager.onBuildUpdate` — @@ -501,9 +501,9 @@ export class HydraniumWorkspaceManager extends DefaultWorkspaceManager { /** * Default cascade-rebuild handler. Iterates {@link ProjectChangeEvent.affectedDocuments} - * and resets each to {@link DocumentState.Changed} so the next build cycle - * re-runs the pipeline (parse → link → validate) under the new project - * visibility scope. + * and resets each to {@link DocumentState.Changed} so the build of the update + * that changed the descriptor re-runs the pipeline (parse → link → validate) + * under the new project visibility scope. * * Subclasses can override for additional behaviour (e.g. logging, * telemetry, custom rebuild policies); call `super` to preserve the