diff --git a/packages/1-framework/3-tooling/language-server/README.md b/packages/1-framework/3-tooling/language-server/README.md index d905acf901..45c31992c5 100644 --- a/packages/1-framework/3-tooling/language-server/README.md +++ b/packages/1-framework/3-tooling/language-server/README.md @@ -11,7 +11,7 @@ Supported capabilities are intentionally narrow: parse diagnostics, whole-docume ## Responsibilities - Resolve workspace/project configuration for open PSL documents and keep managed projects aligned with config-file changes. -- Publish parse diagnostics and serve whole-document formatting, folding ranges, full/range semantic tokens, and model field type completion for configured PSL inputs. +- Serve parse diagnostics (LSP 3.17 pull with push fallback) plus whole-document formatting, folding ranges, full/range semantic tokens, and model field type completion for configured PSL inputs. - Preserve parser artifacts per project so editor features share the same AST, source-file, and symbol-table lifecycle instead of reparsing through feature-specific paths. - Fail safely for unsupported documents, missing or closed buffers, config-load failures, malformed inputs, and oversized semantic-token requests. @@ -25,14 +25,14 @@ Supported capabilities are intentionally narrow: parse diagnostics, whole-docume ## How it works -1. **`initialize`** — resolves the workspace root from the client's `rootUri` and registers config-file watching when the client supports it. The server advertises incremental text sync, whole-document formatting, folding ranges, and `semanticTokensProvider` with a stable standard-only legend, `full: true`, and `range: true`. Configs are loaded when matching documents open or when watched config files change. If a config cannot be loaded, the server does not manage that project. -2. **Document sync** — text-document sync is **incremental** (`TextDocumentSyncKind.Incremental`); the `TextDocuments` manager applies incremental edits, and the server re-parses the full current buffer on each change. -3. **Diagnostics** — on `didOpen` / `didChange` of a document whose URI is a configured PSL input, the server runs `@prisma-next/psl-parser`'s `parse()` (the CST path) and `buildSymbolTable()`, then publishes the merged, mapped diagnostics via `textDocument/publishDiagnostics`. A clean document publishes an empty array (clearing markers). Documents that are not configured inputs publish nothing. +1. **`initialize`** — resolves the workspace root from the client's `rootUri` and registers config-file watching when the client supports it. The server advertises incremental text sync, whole-document formatting, folding ranges, and `semanticTokensProvider` with a stable standard-only legend, `full: true`, and `range: true`. When the client advertises `textDocument.diagnostic`, the server also advertises `diagnosticProvider` with `{ interFileDependencies: false, workspaceDiagnostics: false }` — flags that describe the current single-input implementation scope, not PSL itself. Configs are loaded when matching documents open, on the first read that needs them, or when watched config files change. If a config cannot be loaded, the server does not manage that project. +2. **Document sync** — text-document sync is **incremental** (`TextDocumentSyncKind.Incremental`); the `TextDocuments` manager applies incremental edits and bumps the document version, which alone marks derived state stale. The server parses the full current buffer at most once per version, when a read (diagnostic pull, completion, semantic tokens, folding, or a push publish) triggers materialization. +3. **Diagnostics** — served over exactly one transport per client, decided at `initialize`. For clients that advertise `textDocument.diagnostic` (pull), `didOpen` / `didChange` only invalidate — the version bump in `TextDocuments` marks derived state stale and no eager work runs; a `textDocument/diagnostic` request materializes the current buffer through the shared `ensureCurrent` seam (running `@prisma-next/psl-parser`'s `parse()` and `buildSymbolTable()` once per document version) and returns a full report with the merged, mapped diagnostics. Documents that are not configured inputs, or whose project cannot load, return an empty full report. On a config-file change the server reloads the project and asks the client to re-pull via `workspace/diagnostic/refresh` when the client advertises `workspace.diagnostics.refreshSupport`, instead of republishing. For clients without pull support, the previous push behavior is preserved: the server computes on `didOpen` / `didChange` and publishes via `textDocument/publishDiagnostics` (a clean document publishes an empty array, clearing markers; unconfigured documents publish nothing), and config changes republish affected open documents. The report builder is project-scoped so a future multi-input symbol table can attach `relatedDocuments`; today reports carry only the requested document's items. 4. **Formatting** — on `textDocument/formatting`, the server formats the current in-memory document text with `@prisma-next/psl-parser/format` when the document is a configured PSL input. It returns one whole-document edit when the formatted text differs, and returns no edits for missing or closed documents, unconfigured documents, already canonical text, malformed PSL, or invalid formatter options. -5. **Folding ranges** — on `textDocument/foldingRange`, the server reads the preserved document AST for the configured input and returns foldable declaration/block ranges. Missing, unconfigured, or not-yet-parsed documents return an empty result. -6. **Semantic tokens** — on `textDocument/semanticTokens/full` and `textDocument/semanticTokens/range`, the server reads the current preserved `DocumentAst`, `SourceFile`, project `SymbolTable`, and control-stack scalar types from the same `ProjectArtifacts` lifecycle used by diagnostics. It classifies PSL keywords, declaration names, field/property names, type references, attributes, strings, numbers, booleans, and comments into standard token types/modifiers, then encodes them as LSP five-integer relative semantic-token data. The range request filters to intersecting tokens before encoding. Unconfigured, missing, closed, config-resolution-failed, oversized, or stale documents return `{ data: [] }` instead of throwing or reparsing through a semantic-token-specific path. Malformed PSL returns best-effort tokens from parser recovery when artifacts are available. +5. **Folding ranges** — on `textDocument/foldingRange`, the server materializes the current document artifacts for the configured input and returns foldable declaration/block ranges. Missing or unconfigured documents return an empty result. +6. **Semantic tokens** — on `textDocument/semanticTokens/full` and `textDocument/semanticTokens/range`, the server reads the current preserved `DocumentAst`, `SourceFile`, project `SymbolTable`, and control-stack scalar types from the same `ProjectArtifacts` lifecycle used by diagnostics. It classifies PSL keywords, declaration names, field/property names, type references, attributes, strings, numbers, booleans, and comments into standard token types/modifiers, then encodes them as LSP five-integer relative semantic-token data. The range request filters to intersecting tokens before encoding. Unconfigured, missing, closed, config-resolution-failed, or oversized documents return `{ data: [] }` instead of throwing; a stale cache is materialized to the current buffer through the same shared seam rather than served or emptied. Malformed PSL returns best-effort tokens from parser recovery when artifacts are available. 7. **Completion** — on `textDocument/completion`, the server serves configured PSL model field type positions, descriptor-backed generic block parameter-key positions, and declaration keyword positions from cached parse artifacts. It classifies the cursor using the cached AST/source file, reads the current project symbol table plus project control-stack block descriptors, threads the client's snippet capability into declaration keyword item construction, and returns `[]` for missing or closed documents, unconfigured documents, unavailable artifacts, unsupported contexts, ordinary attributes or attribute arguments, generic block parameter values, generic block value positions, relation-aware scenarios, and external contract-space discovery gaps. -8. **Preserved artifacts** — each project keeps the parse artifacts it produces: the AST per open document (keyed by URI) and one symbol table per project, rebuilt from the open configured input on each edit and dropped when the document closes. Diagnostics populate these artifacts, and folding/semantic-token/completion handlers read them instead of constructing independent parse/token caches. They are exposed through `getDocumentAst` / `getProjectSymbolTable` for future features. Filling the project table from several inputs — and reading unopened inputs from disk — is deferred cross-file work. +8. **Preserved artifacts** — each project keeps the parse artifacts it produces: the AST per open document (keyed by URI) and one symbol table per project, rebuilt lazily from the open configured input when a read observes a new document version and dropped when the document closes. Reads materialize these artifacts on demand through a single version-keyed seam, and diagnostics, folding, semantic-token, and completion handlers all consume it instead of constructing independent parse/token caches. They are exposed through `getDocumentAst` / `getProjectSymbolTable` for future features. Filling the project table from several inputs — and reading unopened inputs from disk — is deferred cross-file work. ## Module layout diff --git a/packages/1-framework/3-tooling/language-server/src/project-artifacts.ts b/packages/1-framework/3-tooling/language-server/src/project-artifacts.ts index 777823ef21..6bd2a299d0 100644 --- a/packages/1-framework/3-tooling/language-server/src/project-artifacts.ts +++ b/packages/1-framework/3-tooling/language-server/src/project-artifacts.ts @@ -8,7 +8,9 @@ import type { SchemaInputSet } from './schema-inputs'; export interface CachedDocument { readonly document: DocumentAst; readonly sourceFile: SourceFile; - readonly text: string; + /** The `TextDocument.version` the artifacts were parsed from. */ + readonly version: number; + readonly diagnostics: readonly LspDiagnostic[]; } export interface ProjectArtifacts { @@ -17,12 +19,22 @@ export interface ProjectArtifacts { update( uri: string, text: string, + version: number, inputs: SchemaInputSet, controlStack: PipelineInputs, ): readonly LspDiagnostic[] | null; + /** + * Marks every cached entry stale so the next `update` recomputes even at an + * unchanged document version — required after a config reload, where the + * inputs or control stack may have changed underneath the cache. + */ + invalidate(): void; remove(uri: string): void; } +// LSP document versions only increase, so this never matches a live version. +const invalidatedVersion = -1; + export function createProjectArtifacts(): ProjectArtifacts { const documents = new Map(); let symbolTable: SymbolTable | undefined; @@ -30,7 +42,11 @@ export function createProjectArtifacts(): ProjectArtifacts { return { getDocument: (uri) => documents.get(uri), getSymbolTable: () => symbolTable, - update: (uri, text, inputs, controlStack) => { + update: (uri, text, version, inputs, controlStack) => { + const cached = documents.get(uri); + if (cached !== undefined && cached.version === version) { + return cached.diagnostics; + } const computed = computeDocumentDiagnostics(uri, text, inputs, controlStack); if (computed === null) { if (documents.delete(uri)) { @@ -41,7 +57,8 @@ export function createProjectArtifacts(): ProjectArtifacts { documents.set(uri, { document: computed.document, sourceFile: computed.sourceFile, - text, + version, + diagnostics: computed.diagnostics, }); // One symbol table per project. Single-input reality: it is (re)built from // the one open configured input on every edit. Merging several open inputs @@ -51,6 +68,11 @@ export function createProjectArtifacts(): ProjectArtifacts { symbolTable = computed.symbolTable; return computed.diagnostics; }, + invalidate: () => { + for (const [uri, cached] of documents) { + documents.set(uri, { ...cached, version: invalidatedVersion }); + } + }, remove: (uri) => { if (documents.delete(uri)) { symbolTable = undefined; diff --git a/packages/1-framework/3-tooling/language-server/src/server.ts b/packages/1-framework/3-tooling/language-server/src/server.ts index 916fc04bda..4ab72f6cef 100644 --- a/packages/1-framework/3-tooling/language-server/src/server.ts +++ b/packages/1-framework/3-tooling/language-server/src/server.ts @@ -9,7 +9,10 @@ import { type Diagnostic, DiagnosticSeverity, DidChangeWatchedFilesNotification, + type DocumentDiagnosticReport, + DocumentDiagnosticReportKind, type FoldingRange, + type FullDocumentDiagnosticReport, type InitializeParams, type InitializeResult, type Position, @@ -25,7 +28,7 @@ import { TextDocument } from 'vscode-languageserver-textdocument'; import { classifyPslCompletionContext } from './completion-context'; import { providePslCompletionItems } from './completion-provider'; import { CONFIG_FILENAME, resolveConfigInputs } from './config-resolution'; -import { ParseDiagnosticSeverity } from './diagnostic-mapping'; +import { type LspDiagnostic, ParseDiagnosticSeverity } from './diagnostic-mapping'; import { computeFoldingRanges } from './folding-ranges'; import type { PipelineInputs } from './pipeline'; import { @@ -69,6 +72,8 @@ export function createServer(connection: Connection): LanguageServer { let watchedConfigGlob = join(rootPath, '**', CONFIG_FILENAME); let supportsWatchedFilesRegistration = false; let clientSupportsSnippets = false; + let clientSupportsPullDiagnostics = false; + let clientSupportsDiagnosticsRefresh = false; let disposed = false; function sendDiagnostics(params: PublishDiagnosticsParams): void { @@ -98,6 +103,7 @@ export function createServer(connection: Connection): LanguageServer { const computed = project.artifacts.update( uri, document.getText(), + document.version, project.inputs, project.controlStack, ); @@ -105,14 +111,23 @@ export function createServer(connection: Connection): LanguageServer { sendDiagnostics({ uri, diagnostics: [] }); return; } - const diagnostics: Diagnostic[] = computed.map((diagnostic) => ({ - range: diagnostic.range, - message: diagnostic.message, - code: diagnostic.code, - severity: toLspSeverity(diagnostic.severity), - source: 'prisma-next', - })); - sendDiagnostics({ uri, diagnostics }); + sendDiagnostics({ uri, diagnostics: toDiagnostics(computed) }); + } + + /** + * Project-scoped so that a future multi-input symbol table can attach + * `relatedDocuments` for cross-file effects; today a report carries only the + * requested document's items. + */ + function buildDocumentDiagnosticReport( + project: ProjectState, + uri: string, + ): FullDocumentDiagnosticReport { + const cached = ensureCurrent(project, uri); + return { + kind: DocumentDiagnosticReportKind.Full, + items: cached === undefined ? [] : toDiagnostics(cached.diagnostics), + }; } async function resolveProjectForDocument(uri: string): Promise { @@ -184,9 +199,11 @@ export function createServer(connection: Connection): LanguageServer { async function loadProject(configPath: string): Promise { const resolution = await resolveConfigInputs(configPath); - // Preserve open-document ASTs across config reloads; the project symbol table - // refreshes on the next publish against the new stack. + // Preserve open-document ASTs across config reloads, but invalidate their + // parsed versions so the next update or read recomputes against the new + // stack instead of fast-pathing on an unchanged document version. const artifacts = projects.get(configPath)?.artifacts ?? createProjectArtifacts(); + artifacts.invalidate(); const project: ProjectState = resolution.formatter === undefined ? { @@ -211,7 +228,7 @@ export function createServer(connection: Connection): LanguageServer { for (const document of documents.all()) { if (documentConfigPaths.get(document.uri) === configPath) { documentConfigPaths.delete(document.uri); - if (hadProject) { + if (hadProject && !clientSupportsPullDiagnostics) { sendDiagnostics({ uri: document.uri, diagnostics: [] }); } } @@ -298,8 +315,8 @@ export function createServer(connection: Connection): LanguageServer { return emptySemanticTokens(); } - const cached = project.artifacts.getDocument(uri); - if (cached === undefined || cached.text !== text) { + const cached = ensureCurrent(project, uri); + if (cached === undefined) { return emptySemanticTokens(); } @@ -324,11 +341,11 @@ export function createServer(connection: Connection): LanguageServer { } catch { return []; } - if (project === undefined || !project.inputs.includes(uri)) { + if (project === undefined) { return []; } - const cached = currentDocumentArtifact(project, uri, document.getText()); + const cached = ensureCurrent(project, uri); const symbolTable = project.artifacts.getSymbolTable(); if (cached === undefined || symbolTable === undefined) { return []; @@ -357,20 +374,25 @@ export function createServer(connection: Connection): LanguageServer { } } - function currentDocumentArtifact( - project: ProjectState, - uri: string, - text: string, - ): CachedDocument | undefined { - const cached = project.artifacts.getDocument(uri); - if (cached?.sourceFile.text === text) { - return cached; - } - - if (project.artifacts.update(uri, text, project.inputs, project.controlStack) === null) { + /** + * The single synchronous materialize-on-read seam: reads the live buffer + * from the `TextDocuments` mirror and reparses iff its version differs from + * the last-parsed version cached on the project's artifacts. Returns + * `undefined` for unmirrored documents and non-configured inputs. + */ + function ensureCurrent(project: ProjectState, uri: string): CachedDocument | undefined { + const document = documents.get(uri); + if (document === undefined) { return undefined; } - return project.artifacts.getDocument(uri); + const updated = project.artifacts.update( + uri, + document.getText(), + document.version, + project.inputs, + project.controlStack, + ); + return updated === null ? undefined : project.artifacts.getDocument(uri); } connection.onInitialize(async (params): Promise => { @@ -378,6 +400,9 @@ export function createServer(connection: Connection): LanguageServer { watchedConfigGlob = join(rootPath, '**', CONFIG_FILENAME); supportsWatchedFilesRegistration = clientSupportsWatchedFilesRegistration(params); clientSupportsSnippets = clientSupportsCompletionSnippets(params); + clientSupportsPullDiagnostics = params.capabilities.textDocument?.diagnostic !== undefined; + clientSupportsDiagnosticsRefresh = + params.capabilities.workspace?.diagnostics?.refreshSupport === true; return { capabilities: { @@ -390,6 +415,18 @@ export function createServer(connection: Connection): LanguageServer { range: true, }, completionProvider: { triggerCharacters: ['.'] }, + // Both flags reflect the current single-input implementation scope — + // not a property of PSL. Once the project symbol table merges multiple + // inputs, an edit in one file can change diagnostics in another and + // these must flip alongside that work. + ...(clientSupportsPullDiagnostics + ? { + diagnosticProvider: { + interFileDependencies: false, + workspaceDiagnostics: false, + }, + } + : {}), }, }; }); @@ -425,7 +462,17 @@ export function createServer(connection: Connection): LanguageServer { stopManagingProject(configPath); continue; } - await republishOpenDocumentsForConfig(configPath); + if (!clientSupportsPullDiagnostics) { + await republishOpenDocumentsForConfig(configPath); + } + } + if ( + clientSupportsPullDiagnostics && + clientSupportsDiagnosticsRefresh && + changedConfigPaths.size > 0 && + !disposed + ) { + void connection.languages.diagnostics.refresh().catch(() => undefined); } }); @@ -439,6 +486,22 @@ export function createServer(connection: Connection): LanguageServer { semanticTokensForDocument(params.textDocument.uri, params.range), ); + connection.languages.diagnostics.on(async (params): Promise => { + if (!clientSupportsPullDiagnostics) { + return { kind: DocumentDiagnosticReportKind.Full, items: [] }; + } + let project: ProjectState | undefined; + try { + project = await resolveProjectForDocument(params.textDocument.uri); + } catch { + project = undefined; + } + if (project === undefined) { + return { kind: DocumentDiagnosticReportKind.Full, items: [] }; + } + return buildDocumentDiagnosticReport(project, params.textDocument.uri); + }); + connection.onFoldingRanges(async (params): Promise => { let project: ProjectState | undefined; try { @@ -449,17 +512,26 @@ export function createServer(connection: Connection): LanguageServer { if (project === undefined) { return []; } - const cached = project.artifacts.getDocument(params.textDocument.uri); + const cached = ensureCurrent(project, params.textDocument.uri); if (cached === undefined) { return []; } return computeFoldingRanges(cached.document, cached.sourceFile); }); + // For pull clients, open/change are invalidate-only: the `TextDocuments` + // version bump is the invalidation, and the next pull (or read) materializes + // through `ensureCurrent`. Eager publish remains for push clients. documents.onDidOpen((event) => { + if (clientSupportsPullDiagnostics) { + return; + } publishSafely(event.document.uri); }); documents.onDidChangeContent((event) => { + if (clientSupportsPullDiagnostics) { + return; + } publishSafely(event.document.uri); }); documents.onDidClose((event) => { @@ -469,7 +541,9 @@ export function createServer(connection: Connection): LanguageServer { projects.get(configPath)?.artifacts.remove(uri); } documentConfigPaths.delete(uri); - sendDiagnostics({ uri, diagnostics: [] }); + if (!clientSupportsPullDiagnostics) { + sendDiagnostics({ uri, diagnostics: [] }); + } }); documents.listen(connection); @@ -494,6 +568,16 @@ function emptySemanticTokens(): SemanticTokens { return { data: [] }; } +function toDiagnostics(computed: readonly LspDiagnostic[]): Diagnostic[] { + return computed.map((diagnostic) => ({ + range: diagnostic.range, + message: diagnostic.message, + code: diagnostic.code, + severity: toLspSeverity(diagnostic.severity), + source: 'prisma-next', + })); +} + function toLspSeverity(severity: number): DiagnosticSeverity { switch (severity) { case ParseDiagnosticSeverity.Warning: diff --git a/packages/1-framework/3-tooling/language-server/test/project-artifacts.test.ts b/packages/1-framework/3-tooling/language-server/test/project-artifacts.test.ts index ec95f877f8..e843e3c7fb 100644 --- a/packages/1-framework/3-tooling/language-server/test/project-artifacts.test.ts +++ b/packages/1-framework/3-tooling/language-server/test/project-artifacts.test.ts @@ -21,37 +21,75 @@ const cleanSource = 'model User {\n id Int @id\n}\n'; const twoModelSource = 'model User {\n id Int @id\n}\n\nmodel Post {\n id Int @id\n}\n'; describe('createProjectArtifacts', () => { - it('caches the parsed AST per URI', () => { + it('caches the parsed AST per URI with the parsed version', () => { const store = createProjectArtifacts(); - store.update(schemaUri, cleanSource, inputs, controlStack); + store.update(schemaUri, cleanSource, 1, inputs, controlStack); const cached = store.getDocument(schemaUri); expect(cached?.document).toBeDefined(); expect(cached?.sourceFile).toBeDefined(); + expect(cached?.version).toBe(1); }); it('replaces the AST entry on a later edit of the same URI', () => { const store = createProjectArtifacts(); - store.update(schemaUri, cleanSource, inputs, controlStack); + store.update(schemaUri, cleanSource, 1, inputs, controlStack); const first = store.getDocument(schemaUri); - store.update(schemaUri, twoModelSource, inputs, controlStack); + store.update(schemaUri, twoModelSource, 2, inputs, controlStack); const second = store.getDocument(schemaUri); expect(second?.document).not.toBe(first?.document); + expect(second?.version).toBe(2); + }); + + it('returns the cached entry untouched for an update at an unchanged version', () => { + const store = createProjectArtifacts(); + const firstDiagnostics = store.update(schemaUri, cleanSource, 1, inputs, controlStack); + const first = store.getDocument(schemaUri); + + const secondDiagnostics = store.update(schemaUri, cleanSource, 1, inputs, controlStack); + + expect(store.getDocument(schemaUri)).toBe(first); + expect(secondDiagnostics).toBe(firstDiagnostics); + }); + + it('recomputes after invalidate even at an unchanged version', () => { + const store = createProjectArtifacts(); + store.update(schemaUri, cleanSource, 1, inputs, controlStack); + const first = store.getDocument(schemaUri); + + store.invalidate(); + store.update(schemaUri, twoModelSource, 1, inputs, controlStack); + + const second = store.getDocument(schemaUri); + expect(second?.document).not.toBe(first?.document); + expect(Object.keys(store.getSymbolTable()?.topLevel.models ?? {})).toEqual( + expect.arrayContaining(['User', 'Post']), + ); + }); + + it('re-checks configured inputs after invalidate at an unchanged version', () => { + const store = createProjectArtifacts(); + store.update(schemaUri, cleanSource, 1, inputs, controlStack); + + store.invalidate(); + const emptyInputs = resolveSchemaInputs({}); + expect(store.update(schemaUri, cleanSource, 1, emptyInputs, controlStack)).toBeNull(); + expect(store.getDocument(schemaUri)).toBeUndefined(); }); it('builds one project symbol table from the open configured input', () => { const store = createProjectArtifacts(); - store.update(schemaUri, cleanSource, inputs, controlStack); + store.update(schemaUri, cleanSource, 1, inputs, controlStack); expect(Object.keys(store.getSymbolTable()?.topLevel.models ?? {})).toContain('User'); }); it('rebuilds the project symbol table to reflect the latest edit', () => { const store = createProjectArtifacts(); - store.update(schemaUri, cleanSource, inputs, controlStack); - store.update(schemaUri, twoModelSource, inputs, controlStack); + store.update(schemaUri, cleanSource, 1, inputs, controlStack); + store.update(schemaUri, twoModelSource, 2, inputs, controlStack); expect(Object.keys(store.getSymbolTable()?.topLevel.models ?? {})).toEqual( expect.arrayContaining(['User', 'Post']), @@ -60,7 +98,7 @@ describe('createProjectArtifacts', () => { it('drops the AST and clears the symbol table when the document is removed', () => { const store = createProjectArtifacts(); - store.update(schemaUri, cleanSource, inputs, controlStack); + store.update(schemaUri, cleanSource, 1, inputs, controlStack); store.remove(schemaUri); @@ -72,16 +110,16 @@ describe('createProjectArtifacts', () => { const store = createProjectArtifacts(); const otherUri = pathToFileURL('/abs/not-a-schema.psl').toString(); - expect(store.update(otherUri, cleanSource, inputs, controlStack)).toBeNull(); + expect(store.update(otherUri, cleanSource, 1, inputs, controlStack)).toBeNull(); expect(store.getDocument(otherUri)).toBeUndefined(); }); it('drops a cached input when a config change removes it as an input', () => { const store = createProjectArtifacts(); - store.update(schemaUri, cleanSource, inputs, controlStack); + store.update(schemaUri, cleanSource, 1, inputs, controlStack); const emptyInputs = resolveSchemaInputs({}); - expect(store.update(schemaUri, cleanSource, emptyInputs, controlStack)).toBeNull(); + expect(store.update(schemaUri, cleanSource, 2, emptyInputs, controlStack)).toBeNull(); expect(store.getDocument(schemaUri)).toBeUndefined(); expect(store.getSymbolTable()).toBeUndefined(); }); @@ -97,7 +135,7 @@ describe('createProjectArtifacts', () => { pslBlockDescriptors: controlStack.pslBlockDescriptors, }); - const diagnostics = store.update(schemaUri, source, inputs, controlStack); + const diagnostics = store.update(schemaUri, source, 1, inputs, controlStack); expect(diagnostics).toEqual( mapParseDiagnostics([...parseDiagnostics, ...symbolTableDiagnostics]), @@ -107,7 +145,7 @@ describe('createProjectArtifacts', () => { it('does not throw on a malformed, half-typed buffer', () => { const store = createProjectArtifacts(); expect(() => - store.update(schemaUri, 'model User {\n id ', inputs, controlStack), + store.update(schemaUri, 'model User {\n id ', 1, inputs, controlStack), ).not.toThrow(); }); }); diff --git a/packages/1-framework/3-tooling/language-server/test/server.test.ts b/packages/1-framework/3-tooling/language-server/test/server.test.ts index 014a17a51e..11624d7a25 100644 --- a/packages/1-framework/3-tooling/language-server/test/server.test.ts +++ b/packages/1-framework/3-tooling/language-server/test/server.test.ts @@ -15,13 +15,19 @@ import { CompletionRequest, createConnection, type Diagnostic, + DiagnosticRefreshRequest, DiagnosticSeverity, DidChangeTextDocumentNotification, DidChangeWatchedFilesNotification, DidCloseTextDocumentNotification, DidOpenTextDocumentNotification, + type DocumentDiagnosticReport, + DocumentDiagnosticReportKind, + DocumentDiagnosticRequest, DocumentFormattingRequest, FileChangeType, + type FoldingRange, + FoldingRangeRequest, InitializedNotification, InitializeRequest, type InitializeResult, @@ -57,6 +63,9 @@ const configLoaderMock = vi.hoisted(() => ({ const configResolutionMock = vi.hoisted(() => ({ resolveConfigInputs: vi.fn(), })); +const pipelineMock = vi.hoisted(() => ({ + runPipeline: vi.fn(), +})); vi.mock('@prisma-next/config-loader', async (importOriginal) => { const actual = await importOriginal(); @@ -71,6 +80,13 @@ vi.mock('../src/config-resolution', async (importOriginal) => { return { ...actual, resolveConfigInputs: configResolutionMock.resolveConfigInputs }; }); +// Pass-through spy on the parse seam so tests can count materializations. +vi.mock('../src/pipeline', async (importOriginal) => { + const actual = await importOriginal(); + pipelineMock.runPipeline.mockImplementation(actual.runPipeline); + return { ...actual, runPipeline: pipelineMock.runPipeline }; +}); + const root = tmpdir(); const schemaPath = join(root, 'schema.psl'); const schemaUri = pathToFileURL(schemaPath).toString(); @@ -159,6 +175,15 @@ const snippetCompletionCapabilities: ClientCapabilities = { textDocument: { completion: { completionItem: { snippetSupport: true } } }, }; +const pullDiagnosticsCapabilities: ClientCapabilities = { + textDocument: { diagnostic: {} }, +}; + +const pullDiagnosticsWithRefreshCapabilities: ClientCapabilities = { + textDocument: { diagnostic: {} }, + workspace: { diagnostics: { refreshSupport: true } }, +}; + interface Harness { readonly client: ReturnType; readonly initialize: () => Promise; @@ -172,6 +197,9 @@ interface Harness { readonly waitForWatchedFilesRegistration: (timeoutMs: number) => Promise; readonly waitForWarning: (predicate: (message: string) => boolean) => Promise; readonly latestDiagnostics: (uri: string) => readonly Diagnostic[] | undefined; + readonly publishCount: (uri: string) => number; + readonly diagnosticRefreshCount: () => number; + readonly waitForDiagnosticRefresh: () => Promise; readonly notifyConfigChanged: (uri?: string) => void; readonly getDocumentAst: (uri: string) => CachedDocument | undefined; readonly getProjectSymbolTable: (uri: string) => SymbolTable | undefined; @@ -260,6 +288,15 @@ function startHarness( } }); + let diagnosticRefreshes = 0; + const diagnosticRefreshWaiters: (() => void)[] = []; + client.onRequest(DiagnosticRefreshRequest.type, () => { + diagnosticRefreshes += 1; + for (const waiter of diagnosticRefreshWaiters.splice(0)) { + waiter(); + } + }); + const warnings: string[] = []; interface WarningWaiter { readonly predicate: (message: string) => boolean; @@ -320,6 +357,16 @@ function startHarness( warningWaiters.push({ predicate, resolve }); }), latestDiagnostics: (uri) => latest.get(uri), + publishCount: (uri) => publishCounts.get(uri) ?? 0, + diagnosticRefreshCount: () => diagnosticRefreshes, + waitForDiagnosticRefresh: () => + new Promise((resolve) => { + if (diagnosticRefreshes > 0) { + resolve(); + return; + } + diagnosticRefreshWaiters.push(resolve); + }), initialize: async () => { const result = await client.sendRequest(InitializeRequest.type, { processId: process.pid, @@ -396,6 +443,27 @@ function requestSemanticTokens(harness: Harness, uri: string): Promise { + return harness.client.sendRequest(FoldingRangeRequest.type, { + textDocument: { uri }, + }); +} + +function requestPullDiagnostics(harness: Harness, uri: string): Promise { + return harness.client.sendRequest(DocumentDiagnosticRequest.type, { + textDocument: { uri }, + }); +} + +function fullReportItems(report: DocumentDiagnosticReport): readonly Diagnostic[] { + return report.kind === DocumentDiagnosticReportKind.Full ? report.items : []; +} + +// Lets any stray asynchronous publish flush before asserting its absence. +function settle(): Promise { + return new Promise((resolve) => setTimeout(resolve, 25)); +} + function requestSemanticTokensRange( harness: Harness, uri: string, @@ -488,6 +556,7 @@ afterEach(async () => { harness = undefined; configResolutionMock.resolveConfigInputs.mockReset(); configLoaderMock.findNearestConfigPathForFile.mockReset(); + pipelineMock.runPipeline.mockClear(); }); describe('language server', { timeout: timeouts.databaseOperation }, () => { @@ -568,6 +637,53 @@ describe('language server', { timeout: timeouts.databaseOperation }, () => { await republished; }); + it('serves reads at an unchanged version without reparsing', async () => { + harness = startHarness(resolveToSchema); + await harness.initialize(); + const { source, position } = sourceWithCursor( + ['model User {', ' id Int @id', '}', '', 'model Post {', ' author |', '}'].join('\n'), + ); + openDocument(harness, schemaUri, source); + await harness.waitForDiagnosticsCount(schemaUri, 2); + + pipelineMock.runPipeline.mockClear(); + const items = completionItems(await requestCompletion(harness, schemaUri, position)); + expect(items.map((item) => item.label)).toContain('User'); + await expect(requestSemanticTokens(harness, schemaUri)).resolves.not.toEqual({ data: [] }); + await expect(requestFoldingRanges(harness, schemaUri)).resolves.not.toEqual([]); + expect(pipelineMock.runPipeline).not.toHaveBeenCalled(); + }); + + it('parses once for an edit followed by an immediate completion', async () => { + harness = startHarness(resolveToSchema); + await harness.initialize(); + const initial = ['model Post {', ' author ', '}'].join('\n'); + const updated = sourceWithCursor( + ['model User {', ' id Int @id', '}', '', 'model Post {', ' author U|', '}'].join('\n'), + ); + openDocument(harness, schemaUri, initial); + await harness.waitForDiagnosticsCount(schemaUri, 2); + + pipelineMock.runPipeline.mockClear(); + const republished = harness.waitForDiagnosticsCount(schemaUri, 3); + harness.client.sendNotification(DidChangeTextDocumentNotification.type, { + textDocument: { uri: schemaUri, version: 2 }, + contentChanges: [{ text: updated.source }], + }); + + const items = completionItems(await requestCompletion(harness, schemaUri, updated.position)); + expect(items.map((item) => item.label)).toEqual([ + 'Boolean', + 'DateTime', + 'Int', + 'String', + 'Post', + 'User', + ]); + await republished; + expect(pipelineMock.runPipeline).toHaveBeenCalledTimes(1); + }); + it('returns generic block parameter completions for configured PSL descriptors', async () => { harness = startHarness(resolveToSchemaWithPslBlockDescriptors); await harness.initialize(); @@ -1518,6 +1634,115 @@ describe('language server config watching', { timeout: timeouts.databaseOperatio }); }); +describe('language server pull diagnostics', { timeout: timeouts.databaseOperation }, () => { + it('advertises the diagnostic provider only to clients that support pull diagnostics', async () => { + harness = startHarness(resolveToSchema, pullDiagnosticsCapabilities); + const result = await harness.initialize(); + expect(result.capabilities.diagnosticProvider).toEqual({ + interFileDependencies: false, + workspaceDiagnostics: false, + }); + }); + + it('does not advertise the diagnostic provider to push clients and serves them empty pull reports', async () => { + harness = startHarness(resolveToSchema); + const result = await harness.initialize(); + expect(result.capabilities.diagnosticProvider).toBeUndefined(); + + openDocument(harness, schemaUri, duplicateModelSource); + await harness.waitForDiagnostics(schemaUri); + expect(await requestPullDiagnostics(harness, schemaUri)).toEqual({ + kind: DocumentDiagnosticReportKind.Full, + items: [], + }); + }); + + it('serves a full report through pull without pushing publishDiagnostics', async () => { + harness = startHarness(resolveToSchema, pullDiagnosticsCapabilities); + await harness.initialize(); + openDocument(harness, schemaUri, duplicateModelSource); + + const report = await requestPullDiagnostics(harness, schemaUri); + expect(report.kind).toBe(DocumentDiagnosticReportKind.Full); + expect(fullReportItems(report).map((diagnostic) => diagnostic.code)).toContain( + 'PSL_DUPLICATE_DECLARATION', + ); + expect(fullReportItems(report).every((diagnostic) => diagnostic.source === 'prisma-next')).toBe( + true, + ); + + await settle(); + expect(harness.publishCount(schemaUri)).toBe(0); + }); + + it('returns an empty report for documents that are not configured inputs', async () => { + harness = startHarness(resolveToSchema, pullDiagnosticsCapabilities); + await harness.initialize(); + const otherUri = pathToFileURL(join(root, 'not-a-schema.psl')).toString(); + openDocument(harness, otherUri, duplicateModelSource); + + expect(await requestPullDiagnostics(harness, otherUri)).toEqual({ + kind: DocumentDiagnosticReportKind.Full, + items: [], + }); + }); + + it('parses lazily on pull after an edit and never pushes to a pull client', async () => { + harness = startHarness(resolveToSchema, pullDiagnosticsCapabilities); + await harness.initialize(); + openDocument(harness, schemaUri, 'model User {\n id Int @id\n}\n'); + expect(fullReportItems(await requestPullDiagnostics(harness, schemaUri))).toEqual([]); + + pipelineMock.runPipeline.mockClear(); + harness.client.sendNotification(DidChangeTextDocumentNotification.type, { + textDocument: { uri: schemaUri, version: 2 }, + contentChanges: [{ text: duplicateModelSource }], + }); + + const report = await requestPullDiagnostics(harness, schemaUri); + expect(fullReportItems(report).map((diagnostic) => diagnostic.code)).toContain( + 'PSL_DUPLICATE_DECLARATION', + ); + expect(pipelineMock.runPipeline).toHaveBeenCalledTimes(1); + + closeDocument(harness, schemaUri); + await settle(); + expect(harness.publishCount(schemaUri)).toBe(0); + }); + + it('requests a diagnostics refresh instead of republishing when a config changes', async () => { + const hook = mutableResolve(resolveToSchema); + harness = startHarness(hook.resolve, pullDiagnosticsWithRefreshCapabilities); + await harness.initialize(); + openDocument(harness, schemaUri, duplicateModelSource); + expect(fullReportItems(await requestPullDiagnostics(harness, schemaUri))).not.toEqual([]); + + const refreshed = harness.waitForDiagnosticRefresh(); + hook.set(resolveToNothing); + harness.notifyConfigChanged(); + await refreshed; + + expect(fullReportItems(await requestPullDiagnostics(harness, schemaUri))).toEqual([]); + await settle(); + expect(harness.publishCount(schemaUri)).toBe(0); + }); + + it('does not request a diagnostics refresh when the client lacks refresh support', async () => { + const hook = mutableResolve(resolveToSchema); + harness = startHarness(hook.resolve, pullDiagnosticsCapabilities); + await harness.initialize(); + openDocument(harness, schemaUri, duplicateModelSource); + await requestPullDiagnostics(harness, schemaUri); + + hook.set(resolveToNothing); + harness.notifyConfigChanged(); + await settle(); + + expect(harness.diagnosticRefreshCount()).toBe(0); + expect(harness.publishCount(schemaUri)).toBe(0); + }); +}); + describe('language server preserved artifacts', { timeout: timeouts.databaseOperation }, () => { it('replaces the cached AST per URI on each edit while one symbol table tracks the project', async () => { harness = startHarness(resolveToSchema); diff --git a/projects/lsp-document-state/manual-qa-reports/2026-07-03-agent-headless.md b/projects/lsp-document-state/manual-qa-reports/2026-07-03-agent-headless.md new file mode 100644 index 0000000000..ea1a833d38 --- /dev/null +++ b/projects/lsp-document-state/manual-qa-reports/2026-07-03-agent-headless.md @@ -0,0 +1,85 @@ +# Manual QA run — 2026-07-03 — agent (headless) + +**Script:** `projects/lsp-document-state/manual-qa.md` +**Scope:** Part A only (headless bridge smoke). **Part B was not run** — it requires a browser to certify Monaco marker rendering and remains open for a browser-equipped runner. + +## Environment + +- Repo: `/Users/sevinf/projects/worktrees/prisma-next/lsp-pull-refactor/prisma-next`, branch `lsp-document-state` +- macOS, shell Node (per repo policy), `pnpm` +- Closure built via `pnpm --filter @prisma-next/lsp-playground... run --if-present build` (green) +- Playground started with `pnpm --filter @prisma-next/lsp-playground start`; scratch schema staged at `apps/lsp-playground/.playground/scratch.psl` (empty), runtime config served at `/__psl_playground_runtime.json` with `wsPath: "/psl"` +- Headless client: throwaway script `wip/qa/headless-lsp-smoke.mjs` (gitignored) speaking raw JSON-RPC 2.0 as plain JSON text frames over `ws://localhost:5295/psl` (the bridge uses `vscode-ws-jsonrpc` `WebSocketMessageReader/Writer` — no Content-Length framing; verified against `apps/lsp-playground/src/bridge.ts`) +- Client `initialize` capabilities advertised: `textDocument.diagnostic { dynamicRegistration: false }`, `textDocument.publishDiagnostics {}`, `workspace.diagnostics { refreshSupport: true }` +- Session flow: `initialize` → `initialized` → `didOpen` (languageId `prisma`, version 1, broken PSL `model M {\n`) → `textDocument/diagnostic` → `didChange` (version 2, valid schema `model User { id Int @id; name String }`) → `textDocument/diagnostic` → `textDocument/semanticTokens/full` → `textDocument/foldingRange` → 3 s grace window listening for push diagnostics → `shutdown`/`exit` + +## Part A results + +### ☑ A1 — `initialize` advertises `diagnosticProvider` (interFileDependencies: false, workspaceDiagnostics: false) + +**PASS.** From the `initialize` result: + +```json +"diagnosticProvider": { + "interFileDependencies": false, + "workspaceDiagnostics": false +} +``` + +(Also advertised: `textDocumentSync: 2`, `foldingRangeProvider`, `semanticTokensProvider` with full+range, `documentFormattingProvider`, completion.) + +### ☑ A2 — pull diagnostic on broken buffer returns a full report with the parse error and sane ranges + +**PASS.** With the open buffer `model M {\n` (version 1), `textDocument/diagnostic` returned: + +```json +{ + "kind": "full", + "items": [ + { + "range": { "start": { "line": 0, "character": 8 }, "end": { "line": 0, "character": 9 } }, + "message": "Unterminated block declaration", + "code": "PSL_UNTERMINATED_BLOCK", + "severity": 1, + "source": "prisma-next" + } + ] +} +``` + +Range 0:8–0:9 covers the unclosed `{` — sane. + +### ☑ A3 — no `textDocument/publishDiagnostics` arrives for this pull-capable client + +**PASS.** A listener recorded every incoming notification for the entire session (didOpen through the post-request 3-second grace window). Zero `textDocument/publishDiagnostics` notifications were received: + +```json +"publishDiagnosticsNotifications": [] +``` + +### ☑ A4 — post-`didChange` pull report reflects the fixed buffer + +**PASS.** Immediately after `didChange` (version 2) replacing the buffer with a valid schema, `textDocument/diagnostic` returned an empty full report — the lazy path served the synced document: + +```json +{ "kind": "full", "items": [] } +``` + +### ☑ A5 — semantic tokens + folding ranges still work on the same session + +**PASS.** Both returned non-error results for the valid buffer: + +- `textDocument/semanticTokens/full` → 35-entry (7-token) data array, e.g. `[0,0,5,0,0, 0,6,4,2,1, 1,2,2,5,1, ...]` (keyword `model`, class `User`, properties/types for the two fields, decorator `@id`) +- `textDocument/foldingRange` → `[{ "startLine": 0, "endLine": 3, "kind": "region" }]` (the `model User { ... }` block) + +## Findings + +None. No blockers (🛑) and no non-blockers (⚠️) observed in Part A. The bridge, built CLI, pull-diagnostic transport, push suppression for pull-capable clients, and read features all behaved per spec. Not even the French managed to sabotage this run. + +## Pre-QA gate note + +This run relied on the closure build being green (it was). The workspace-level `pnpm test:packages` mongod caveat noted in the script's pre-QA gate was not re-verified in this run; the package-scoped signal was assumed per the slice-close notes. + +## Part B status + +**Not run.** Part B (visual browser check: B1 red squiggle appears, B2 marker clears, B3 no lag/flicker under a typing burst) requires a real browser rendering Monaco and is explicitly routed to the operator / a browser-equipped runner. The playground process used for this run has been stopped; restart with `pnpm --filter @prisma-next/lsp-playground start` before running Part B. diff --git a/projects/lsp-document-state/manual-qa.md b/projects/lsp-document-state/manual-qa.md new file mode 100644 index 0000000000..2d78f0f7d2 --- /dev/null +++ b/projects/lsp-document-state/manual-qa.md @@ -0,0 +1,45 @@ +# Manual QA — lsp-document-state / lazy-state-and-pull-diagnostics + +**What this script proves:** the playground (the end-user editor surface) receives pull-sourced diagnostics end-to-end — the server advertises `diagnosticProvider` to a pull-capable client, serves `textDocument/diagnostic` full reports for the live buffer, and does not push `textDocument/publishDiagnostics` to that client. This is the surface CI does not cover: the real `apps/lsp-playground` bridge (WebSocket → stdio → built CLI) with the real client capability set, not the in-package test harness. + +**Audiences:** end users (playground editor experience). Extension authors: N/A — this slice changes no extension contract; the language-server package API surface (`createServer`/`startServer`) is unchanged. + +## Pre-QA gate + +Tree must be verified before running: `pnpm typecheck` green and `pnpm --filter @prisma-next/language-server test` green. (Workspace `pnpm test:packages` is red on machines without a launchable mongod — pre-existing, tracked in the slice-close notes; the package-scoped gate is the meaningful pre-QA signal here.) + +## Part A — headless bridge smoke (agent-runnable) + +Proves the pull transport through the real playground bridge and built CLI. + +1. Build the closure: `pnpm --filter @prisma-next/lsp-playground... run --if-present build`. +2. Start the playground against a scratch schema: `pnpm --filter @prisma-next/lsp-playground start` (serves on `http://localhost:5295/`, WebSocket LSP bridge on the same port; runtime config JSON at `/__psl_playground_runtime.json`). +3. Connect a raw LSP client over the WebSocket bridge (script it with `ws` + `vscode-ws-jsonrpc`, both already in the playground's dependency closure) advertising `textDocument.diagnostic` (and `workspace.diagnostics.refreshSupport`) in `initialize`. + - ☐ **A1:** the `initialize` result advertises `diagnosticProvider` with `interFileDependencies: false` and `workspaceDiagnostics: false`. +4. `didOpen` the staged scratch schema (URI + text from the runtime config / staged file) with a deliberate parse error (e.g. `model M {` unclosed). + - ☐ **A2:** a `textDocument/diagnostic` request returns a **full** report whose items include the parse error, with sane ranges. + - ☐ **A3:** no `textDocument/publishDiagnostics` notification arrives for this client (listen for the notification during the whole session). +5. Send a `didChange` fixing the error, then immediately request `textDocument/diagnostic` again. + - ☐ **A4:** the report reflects the post-edit buffer (error gone) — the lazy path serves the synced document. +6. Regression sanity on the same session: request `textDocument/semanticTokens/full` and `textDocument/foldingRange`. + - ☐ **A5:** both return non-error results for the valid buffer (read features share the same cache and still work over the bridge). + +## Part B — visual browser check (human or browser-equipped agent) + +Proves Monaco actually renders the pull-sourced markers — the piece a headless JSON-RPC session cannot certify. + +1. With the playground still running, open `http://localhost:5295/` in a browser. +2. Introduce a parse error in the editor. + - ☐ **B1:** a red squiggle + Problems-style marker appears for the error, live as you type. +3. Fix the error. + - ☐ **B2:** the marker clears. +4. Type continuously (a burst of keystrokes). + - ☐ **B3:** markers update without lag or flicker regressions vs. pre-change behavior. + +## Blocker bar + +🛑 Blocker: A1–A4 or B1–B2 failing. ⚠️ Non-blocker worth filing: A5 or B3 anomalies. + +## Reports + +One report per run under `projects/lsp-document-state/manual-qa-reports/-.md`. diff --git a/projects/lsp-document-state/plan.md b/projects/lsp-document-state/plan.md new file mode 100644 index 0000000000..add2aa95c8 --- /dev/null +++ b/projects/lsp-document-state/plan.md @@ -0,0 +1,37 @@ +# lsp-document-state — Plan + +**Spec:** `projects/lsp-document-state/spec.md` +**Linear Project:** pending (Terminal team) — create before the slice opens its PR. + +## At a glance + +Single-slice project. The whole settled design — invalidate-on-change, lazy synchronous `ensureCurrent` materialize-on-read, one per-project cache feeding every read, and pull diagnostics with a push fallback — lands as one reviewable PR. It is one slice because the pieces are coupled: dropping eager compute on change is only safe once diagnostics are served by pull, so lifecycle and transport must move together. + +## Composition + +### Stack (deliver in order) + +1. **Slice `lazy-state-and-pull-diagnostics`** — Linear: pending + - **Outcome:** The language server invalidates on change and materializes derived state lazily and synchronously via `ensureCurrent` (version-keyed off `TextDocument.version`), every read feature consumes one per-project cache, `currentDocumentArtifact` and the per-request reparse are gone, and diagnostics are served by `textDocument/diagnostic` (capability-gated, push retained only as the non-pull fallback). `TextDocuments` is kept. + - **Builds on:** The existing language-server package (`server.ts`, `project-artifacts.ts`, `document-diagnostics.ts`), the `Project`/artifact abstraction, and `vscode-languageserver@10`'s pull-diagnostics API. + - **Hands to:** Project close-out — the document-state lifecycle is correct-by-construction and the diagnostics transport is aligned with it; the project-scoped seam is in place for the future multi-input table. + - **Focus:** Add `ensureCurrent`; re-point completion / semantic tokens / folding at it; delete `currentDocumentArtifact`; make `didChange` invalidate-only; advertise `diagnosticProvider` + register the pull handler; capability-gate pull vs push; drop eager `publish` on the pull path; issue `workspace/diagnostic/refresh` on config/watched-file change; keep `TextDocuments`; advertise `interFileDependencies: false` / `workspaceDiagnostics: false` with the scope comment; tests; playground pull-markers QA. + +### Parallel groups + +None — single slice. + +## Dependencies (external) + +- [ ] Linear Project (Terminal team) — create before PR open. +- [x] `vscode-languageserver@10` pull-diagnostics API available (confirmed in `pnpm-workspace.yaml` catalog). +- [x] Playground client supports pull diagnostics (`monaco-languageclient@10` / `vscode-languageclient@9` / `@codingame/monaco-vscode-api@25`). + +## Sequencing rationale + +The lifecycle change (lazy `ensureCurrent`) and the diagnostics-transport change (pull) are not independently shippable as a clean end state: a "lifecycle-only" step that kept push would still compute diagnostics eagerly on change, so the lazy invalidate-only model wouldn't actually hold. They therefore land together as one slice. Internal dispatch ordering (lifecycle seam first, transport second) is the slice plan's job, not the project plan's. + +## Sanity check + +- One slice; passes slice-INVEST (one coherent PR a reviewer holds in a sitting: one package, one architectural idea — invalidate + lazy + pull). +- Every project-DoD condition is reachable from this slice. diff --git a/projects/lsp-document-state/slices/lazy-state-and-pull-diagnostics/spec.md b/projects/lsp-document-state/slices/lazy-state-and-pull-diagnostics/spec.md new file mode 100644 index 0000000000..80fffc8d3b --- /dev/null +++ b/projects/lsp-document-state/slices/lazy-state-and-pull-diagnostics/spec.md @@ -0,0 +1,72 @@ +# Slice spec: lazy-state-and-pull-diagnostics + +**Parent project:** `projects/lsp-document-state/spec.md` + +## At a glance + +Replace the language server's eager, defensive artifact handling with invalidate-on-change + lazy synchronous materialize-on-read, and move diagnostics from push to pull on the same model. After this slice: `didChange` only invalidates; the first read that needs derived state calls one synchronous `ensureCurrent(project, uri)` that reparses iff `TextDocument.version` advanced; completion / semantic tokens / folding / a new `textDocument/diagnostic` handler all consume that single per-project cache; `currentDocumentArtifact` and the per-request whole-text reparse are gone; `TextDocuments` stays as the text mirror / lifecycle / version source. + +## Chosen design + +- **`ensureCurrent(project, uri): CachedDocument | undefined`** — synchronous. Reads `TextDocuments` current text + `version`; if the cache's last-parsed version matches, returns the cache untouched; otherwise runs `parse` + `buildSymbolTable` once, stores the result (with the version) on the project's artifacts, and returns it. No `await` inside; no whole-document text compare. +- **`CachedDocument` gains the parsed `version`** (and may store its computed diagnostics) so the pull handler returns without recomputing and the version check is O(1). +- **`didChange` / `didOpen` invalidate only** — mark the document/project stale (or simply rely on the version bump that `TextDocuments` already applies). No parse, no `publishSafely` on the pull path. +- **Reads await readiness, then derive synchronously**: `const project = await resolveProjectForDocument(uri); const cached = ensureCurrent(project, uri); …`. The only awaited step is the cached project load. Position→offset uses the post-`ensureCurrent` `SourceFile`, never a stale one. +- **Pull diagnostics**: advertise `diagnosticProvider`; register `connection.languages.diagnostics.on` returning a full report from `ensureCurrent`. Gate on `InitializeParams.capabilities.textDocument?.diagnostic`: pull when present, push fallback otherwise — never both for one client. On config / watched-file change, send `workspace/diagnostic/refresh` when the client advertises `workspace.diagnostics.refreshSupport`. +- **Capability flags**: `{ interFileDependencies: false, workspaceDiagnostics: false }` with a code comment: single-input implementation scope, not a PSL property; flip with the future multi-input table. +- **Seam stays project-scoped**: the report builder is `buildReport(project, fileId)` able to carry `relatedDocuments` (empty today). + +## Coherence rationale + +One package (`language-server`), one architectural idea (invalidate + lazy materialize + pull), no behavior change to completion/semantic-tokens/folding beyond their data source. A reviewer holds it in one sitting and can roll it back as a unit. Lifecycle and transport are in the same PR because dropping eager compute is only safe once pull exists. + +## Scope + +**In:** `server.ts` (change/open/close handlers, `completeDocument`, `semanticTokensForDocument`, folding handler, `resolveProjectForDocument` usage, capability advert, new diagnostic pull handler, config-change refresh), `project-artifacts.ts` (`CachedDocument` version/diagnostics, `ensureCurrent` or equivalent), `document-diagnostics.ts` if the seam needs reshaping; their tests; README capability/diagnostics notes. + +**Deliberately out:** removing `TextDocuments`; the multi-input project-wide symbol table; multi-project membership; `interFileDependencies: true` / `workspace/diagnostic`; any completion/semantic-token/folding behavior change; parser changes. + +## Pre-investigated edge cases + +| Case | Handling | +| --- | --- | +| Read arrives before the project's config has loaded | Read `await`s the in-flight cached load; once resolved, `ensureCurrent` derives from the live buffer. Returns `[]` / empty report only if the project genuinely can't load. | +| Client lacks `textDocument.diagnostic` | Keep the push path for that client; do not register/serve pull for it. Never run both. | +| Config / watched-file change while open | Invalidate affected docs; issue `workspace/diagnostic/refresh` (gated) instead of republishing. | +| Malformed PSL | Parser recovery still yields artifacts; the pull report returns recovered diagnostics, same as the push path does today. | +| Position mapping | Always map the request position against the `SourceFile` produced by `ensureCurrent` for the current version — never a stale cached one. | + +## Slice-specific done conditions + +- `didChange` performs no parse; an edit-then-immediate-completion serves the post-edit buffer with one parse and no per-request reparse (the case the removed `currentDocumentArtifact` was guarding) — covered by a test. +- Pull diagnostics serve the current cache; push remains only as the capability-gated fallback; `TextDocuments` is retained. + +(CI-green, reviewer-accept, and the project-DoD floor are inherited, not restated.) + +## Open questions + +None. + +## Dispatch plan + +### Dispatch 1: lazy-ensurecurrent-seam + +- **Outcome:** `ensureCurrent` exists (synchronous, version-keyed), `CachedDocument` carries the parsed version, completion / semantic tokens / folding read through it, and `currentDocumentArtifact` + the per-request whole-text reparse are deleted. Push diagnostics are still wired (unchanged) so nothing breaks mid-slice. +- **Builds on:** current `server.ts` / `project-artifacts.ts`. +- **Hands to:** a single synchronous materialize seam all reads share, with the version key in place. +- **Focus:** add `ensureCurrent`; thread parsed `version` onto `CachedDocument`; re-point the three read handlers; delete `currentDocumentArtifact`; keep `didChange`→`publish` for now; tests for version-skip (no reparse when unchanged) and edit-then-complete. Do not touch diagnostics transport yet. +- **Validation gates:** `pnpm --filter @prisma-next/language-server test` / `typecheck` / `lint`. + +### Dispatch 2: pull-diagnostics-transport + +- **Outcome:** Diagnostics served via `textDocument/diagnostic` from `ensureCurrent`, capability-gated with push fallback; eager `publish` dropped on the pull path; `didChange` becomes invalidate-only for pull clients; `workspace/diagnostic/refresh` on config change; flags + scope comment; README updated. +- **Builds on:** Dispatch 1's `ensureCurrent` seam. +- **Hands to:** the slice DoD — lazy lifecycle + pull transport complete. +- **Focus:** advertise `diagnosticProvider`; register the pull handler returning `buildReport(project, fileId)`; gate on client capability; remove eager compute on the pull path while keeping push fallback; refresh-on-config-change; capability flags + comment; tests for pull report, push fallback, refresh; playground pull-markers manual-QA. +- **Validation gates:** `pnpm --filter @prisma-next/language-server test` / `typecheck` / `lint`; `pnpm --filter @prisma-next/lsp-playground typecheck` / `lint`; manual playground pull-markers check. + +## References + +- Parent spec: `projects/lsp-document-state/spec.md`. +- Code: `packages/1-framework/3-tooling/language-server/src/{server.ts,project-artifacts.ts,document-diagnostics.ts}`. +- LSP 3.17 pull diagnostics (`textDocument/diagnostic`, `workspace/diagnostic/refresh`); `vscode-languageserver@10` `connection.languages.diagnostics`. diff --git a/projects/lsp-document-state/spec.md b/projects/lsp-document-state/spec.md new file mode 100644 index 0000000000..aaa61ab113 --- /dev/null +++ b/projects/lsp-document-state/spec.md @@ -0,0 +1,83 @@ +# lsp-document-state + +## Purpose + +Give the language server a document-state lifecycle that is correct by construction and economical: editor features always compute against the buffer the client believes is synced, with no redundant reparsing, and the derived-state seam is shaped so it can grow from one open input to a multi-file project without re-architecting. The why is durable even as the *what* (which features, which diagnostics transport) evolves: the server's answers must match the synced document, cheaply, as the schema grows beyond a single file. + +## At a glance + +Today the server derives state eagerly and defensively: `onDidChangeContent` fires `publishSafely` → `publish`, which re-parses and pushes diagnostics asynchronously, and `completeDocument` calls `currentDocumentArtifact`, which re-parses again (comparing the *entire* document text) on the completion path to dodge a stale-buffer race. That is two parses per edit-then-complete, a per-request O(n) text compare, and a race the LSP spec says we shouldn't have to fight. + +The settled model replaces that with **invalidate-on-change, lazily materialize-on-read, synchronously**: + +```text +didChange ──▶ mark the document's project dirty (cheap; no parse) + (the version bump comes free from TextDocuments) + +read req ──▶ await project load (only while uncached) (async: config eval, cached) + ──▶ ensureCurrent(project, uri) (SYNC: reparse iff version changed) + ──▶ derive (completion / diagnostics / …) (SYNC: no await before deriving) +``` + +`ensureCurrent` is the one synchronous seam that turns the current `TextDocuments` text into the project's `SymbolTable` + per-document `CachedDocument`, and only when `TextDocument.version` has advanced past what we last parsed. Every read feature consumes that one cache. A burst of keystrokes with no intervening read costs zero parses; a read after N edits costs one. Because the parse is synchronous CPU with no `await` between “get current text” and “derive”, the result is internally consistent for the version it ran against — which is exactly the synchronization the spec assumes — without snapshots or cancellation (rust-analyzer needs those only because its reads run on background threads; ours don't). + +Diagnostics move to the same philosophy: pull (`textDocument/diagnostic`) instead of push, so they too are computed on demand from the current cache rather than eagerly on every change. This is the standardized form of tsserver's `geterr` model. + +## Non-goals + +- **Removing `TextDocuments`.** It is kept as the synchronously-maintained incremental text mirror, the open/close lifecycle source, and the monotonic `version` provider. The project demotes its role (features no longer derive from it directly) but does not replace it. +- **Building the project-wide, multi-input symbol table.** Today the table is built from the single open configured input; merging several inputs (and reading unopened inputs from disk) remains deferred cross-file work, owned by a future project. +- **Multi-project membership / tsserver-style file→project(s) mapping.** `resolveProjectForDocument` stays one-config-one-project here. +- **Flipping `interFileDependencies` to `true` or implementing `workspace/diagnostic` (`workspaceDiagnostics`).** Both are gated on the multi-input table and ship in the future project that delivers it. +- **Behavioural changes to completion, semantic tokens, or folding.** They are re-pointed at the new cache with identical observable behavior; new completion/semantic features are out of scope. +- **Editor-extension or client-side work** beyond verifying the playground renders pull-sourced diagnostics. + +## Place in the larger world + +- Lives entirely in `packages/1-framework/3-tooling/language-server` — `server.ts` (request/notification wiring, `publish`/`completeDocument`/`resolveProjectForDocument`), `project-artifacts.ts` (`CachedDocument`, `ProjectArtifacts.update`), `document-diagnostics.ts` (the pure parse + symbol-table + diagnostics seam). +- Builds on `@prisma-next/psl-parser` (`parse()`, `buildSymbolTable()`, `SourceFile.offsetAt`/`positionAt`) — all synchronous; and on `@prisma-next/config-loader` for project resolution, which is **asynchronous** because `prisma-next.config.ts` is executable TypeScript evaluated via dynamic import (unlike tsserver's synchronously-readable JSON `tsconfig`). That async is the reason project load can't be synchronous; the parse/derive steps remain synchronous. +- Server library `vscode-languageserver@10` exposes the pull-diagnostics API (`connection.languages.diagnostics.on`, `diagnosticProvider`); LSP 3.17 defines the document-pull contract. The playground client (`monaco-languageclient@10` on `vscode-languageclient@9` + `@codingame/monaco-vscode-api@25`) supports pull diagnostics through the same path VS Code uses. +- Sibling: the `lsp-autocomplete` project (PR #871) added completion on the existing eager artifact cache; this project reworks the lifecycle underneath it. The `Project` abstraction this project leans on is the same seam `lsp-autocomplete` already depends on. + +## Cross-cutting requirements + +- A change notification only **invalidates**; it never parses. The single per-project artifact cache is the one source every read feature consumes — no feature keeps its own parse/token cache, and no read re-derives independently. +- `ensureCurrent(project, uri)` is the only place raw text becomes derived state, it is **synchronous**, and it recomputes only when `TextDocument.version` differs from the last-parsed version. A read whose version is unchanged does zero parsing. +- Reads are correct under interleaving without snapshots/cancellation: there is no `await` between reading the current buffer text and deriving from it, so a derived result is always internally consistent for one version. +- Asynchrony is confined to project/config resolution (cached per config, de-duped). Notification handlers stay fire-and-forget; the not-yet-loaded window is handled by **read handlers awaiting readiness**, never by trying to make a notification handler a barrier. +- The defensive per-request reparse (`currentDocumentArtifact`) and whole-document text compare are removed; freshness is keyed on the version `TextDocuments` already maintains. +- Diagnostics are served by pull when the client advertises `textDocument.diagnostic`, computed from the current cache; push is retained only as a capability-gated fallback for non-pull clients. The two transports are never both active for one client. +- The artifact and diagnostics seams stay **project-scoped**: the report builder is `buildReport(project, fileId)` capable of carrying `relatedDocuments`, even though it returns a single file today — so the future multi-file flip is additive, not a rewrite. + +## Transitional-shape constraints + +- `TextDocuments` is retained for the life of this project; every intermediate slice still routes raw text through it. +- Diagnostics capability flags ship as `{ interFileDependencies: false, workspaceDiagnostics: false }`, accompanied by a code comment stating this reflects the current single-input implementation scope — not a property of PSL — and must flip with the multi-input symbol table. Advertising a cross-file capability we don't yet honor is forbidden. +- Every merged slice keeps completion, diagnostics, semantic tokens, and folding behavior green, and keeps non-pull clients working via the push fallback. No slice leaves the server advertising a transport it doesn't serve. +- No slice introduces an `await` on the parse/derive path; the only awaited step is the cached project load. + +## Project Definition of Done + +- [ ] Team-DoD floor items (inherited from `drive/calibration/dod.md`). +- [ ] `currentDocumentArtifact`, the per-request reparse, and the whole-document text compare are gone; all read handlers (completion, diagnostics, semantic tokens, folding) materialize state through a single synchronous `ensureCurrent` against the per-project cache. +- [ ] `ensureCurrent` recomputes only when `TextDocument.version` advanced; a read at an unchanged version performs no parse (covered by a test). +- [ ] A change notification performs no parse; an edit→read sequence does no async work on the hot path once the project is cached (covered by a test, including the edit-then-immediate-completion case that motivated the original stale-buffer fix). +- [ ] Diagnostics are served via `textDocument/diagnostic`, capability-gated, with push retained as fallback for clients lacking `textDocument.diagnostic`, and `workspace/diagnostic/refresh` issued on config/watched-file change when the client supports it. +- [ ] `TextDocuments` is still the text mirror / lifecycle / version source; it was not removed. +- [ ] `interFileDependencies: false` / `workspaceDiagnostics: false` are advertised with the scope-comment described above. +- [ ] Completion, semantic tokens, and folding have unchanged observable behavior (covered by the existing suites staying green). +- [ ] The playground renders pull-sourced diagnostics end-to-end (manual-QA report). + +## Open Questions + +1. **Resolved (operator).** Eager `publish` is dropped on the pull path: a change only invalidates, and diagnostics are computed on demand at pull time. `publish` survives only behind the capability-gated non-pull fallback. The lazy model is real, not hybrid. +2. **Resolved (operator).** One slice — lazy lifecycle and pull diagnostics ship together (they are coupled: dropping eager compute requires the pull transport to exist). +3. Linear Project: none created yet. Working position: create one under the Terminal team during the plan/ceremony step before slicing. + +## References + +- Linear Project: not yet created (Terminal team) — to be created at planning time. +- Sibling / dependent projects: `lsp-autocomplete` (PR #871) — completion built on the current eager cache this project reworks. +- Code references: `packages/1-framework/3-tooling/language-server/src/{server.ts,project-artifacts.ts,document-diagnostics.ts}`; `@prisma-next/psl-parser` (`parse`, `buildSymbolTable`, `SourceFile`); `vscode-languageserver@10` pull-diagnostics API. +- External: LSP 3.17 — Document Synchronization (didChange version/ordering guarantee) and Pull Diagnostics (`textDocument/diagnostic`, `workspace/diagnostic/refresh`). Prior art surveyed: rust-analyzer (`process_changes` + salsa snapshot/cancellation) and tsserver (`ScriptInfo`/`TextStorage` version, `synchronizeHostData` lazy recompute, `geterr` pull). +- ADRs: none yet; if the invalidate/lazy/version lifecycle proves durable, capture it as an ADR at close-out (per `drive/project/README.md` ADR cadence). diff --git a/projects/lsp-document-state/trace.jsonl b/projects/lsp-document-state/trace.jsonl new file mode 100644 index 0000000000..027576259b --- /dev/null +++ b/projects/lsp-document-state/trace.jsonl @@ -0,0 +1,13 @@ +{"event_id":"2836f8ba-c4ba-4ad7-8cfb-a5f80cdcfd24","schema_version":"1","ts":"2026-07-03T16:06:32.364Z","project_run_id":"lsp-document-state","orchestrator_agent_id":null,"event_type":"dispatch-start","dispatch_id":"280d2941-9aec-457b-b17c-9346969d9f57","dispatch_name":"lazy-ensurecurrent-seam","subagent_type":"spawn_agent","model":"inherited-default","parent_dispatch_id":null} +{"event_id":"4dd53a49-53fc-4c50-8110-2f84c6b83d2e","schema_version":"1","ts":"2026-07-03T16:06:32.833Z","project_run_id":"lsp-document-state","orchestrator_agent_id":null,"event_type":"round-start","dispatch_id":"280d2941-9aec-457b-b17c-9346969d9f57","round_id":"106042cc-ea29-4a15-9d3c-0d3c477c4faf","round_number":1} +{"event_id":"d15bf29d-c518-4055-8c32-cff3cca6f992","schema_version":"1","ts":"2026-07-03T16:07:25.338Z","project_run_id":"lsp-document-state","orchestrator_agent_id":null,"event_type":"brief-issued","dispatch_id":"280d2941-9aec-457b-b17c-9346969d9f57","round_id":"106042cc-ea29-4a15-9d3c-0d3c477c4faf","brief_byte_length":4480,"brief_content_hash":"1a31605dc3b3fa7e6b02e0d744bcd82d77a6de34ac17076155979db0937837f6","brief_disposition":"initial"} +{"event_id":"65973ddc-c462-499b-83b7-ea7ca8520933","schema_version":"1","ts":"2026-07-03T16:30:29.049Z","project_run_id":"lsp-document-state","orchestrator_agent_id":null,"event_type":"round-end","dispatch_id":"280d2941-9aec-457b-b17c-9346969d9f57","round_id":"106042cc-ea29-4a15-9d3c-0d3c477c4faf","verdict":"satisfied","findings_filed":0,"wall_clock_ms":1426167} +{"event_id":"9cd8e50a-3fce-419e-a7c2-8165ee6324a6","schema_version":"1","ts":"2026-07-03T16:30:29.475Z","project_run_id":"lsp-document-state","orchestrator_agent_id":null,"event_type":"dispatch-end","dispatch_id":"280d2941-9aec-457b-b17c-9346969d9f57","result":"completed","wall_clock_ms":1426636} +{"event_id":"ccda9d07-77ad-4328-a9e7-bb16b3b52b2b","schema_version":"1","ts":"2026-07-03T16:30:40.895Z","project_run_id":"lsp-document-state","orchestrator_agent_id":null,"event_type":"dispatch-start","dispatch_id":"18335083-5628-4d18-adf2-a57e2d9c4559","dispatch_name":"pull-diagnostics-transport","subagent_type":"spawn_agent","model":"inherited-default","parent_dispatch_id":"280d2941-9aec-457b-b17c-9346969d9f57"} +{"event_id":"f3b154ba-9534-432b-a40a-a081dcff5abc","schema_version":"1","ts":"2026-07-03T16:30:41.311Z","project_run_id":"lsp-document-state","orchestrator_agent_id":null,"event_type":"round-start","dispatch_id":"18335083-5628-4d18-adf2-a57e2d9c4559","round_id":"2580bc71-d1d8-4797-9619-a62e0c7973d7","round_number":1} +{"event_id":"1c156bc7-d962-41f4-9b4a-66d6e340e072","schema_version":"1","ts":"2026-07-03T16:31:12.624Z","project_run_id":"lsp-document-state","orchestrator_agent_id":null,"event_type":"brief-issued","dispatch_id":"18335083-5628-4d18-adf2-a57e2d9c4559","round_id":"2580bc71-d1d8-4797-9619-a62e0c7973d7","brief_byte_length":3497,"brief_content_hash":"79b461a58a59fae492730497eb2e8fff684397c4bd1f034178f5c3f4491c5c38","brief_disposition":"initial"} +{"event_id":"c05d2532-728a-4072-b3d2-b882015165ac","schema_version":"1","ts":"2026-07-03T16:48:21.798Z","project_run_id":"lsp-document-state","orchestrator_agent_id":null,"event_type":"round-end","dispatch_id":"18335083-5628-4d18-adf2-a57e2d9c4559","round_id":"2580bc71-d1d8-4797-9619-a62e0c7973d7","verdict":"another-round-needed","findings_filed":1,"wall_clock_ms":3444791} +{"event_id":"dfa0a9bc-2608-4745-8a61-2f9de0e41a53","schema_version":"1","ts":"2026-07-03T16:48:28.955Z","project_run_id":"lsp-document-state","orchestrator_agent_id":null,"event_type":"round-start","dispatch_id":"18335083-5628-4d18-adf2-a57e2d9c4559","round_id":"6285d3f8-3bac-45cf-a883-5d9558c08585","round_number":2} +{"event_id":"d52b7c66-73e6-4cf1-832d-efd9bcb1f2c0","schema_version":"1","ts":"2026-07-03T16:48:52.496Z","project_run_id":"lsp-document-state","orchestrator_agent_id":null,"event_type":"brief-issued","dispatch_id":"18335083-5628-4d18-adf2-a57e2d9c4559","round_id":"6285d3f8-3bac-45cf-a883-5d9558c08585","brief_byte_length":657,"brief_content_hash":"32589b78e2ce30bb2354a0b4a1064ded08f0f1a07361857092b6dfb4ead99bad","brief_disposition":"amended"} +{"event_id":"1083aadf-0ef2-466c-aa62-f067d5d71364","schema_version":"1","ts":"2026-07-03T16:51:23.879Z","project_run_id":"lsp-document-state","orchestrator_agent_id":null,"event_type":"round-end","dispatch_id":"18335083-5628-4d18-adf2-a57e2d9c4559","round_id":"6285d3f8-3bac-45cf-a883-5d9558c08585","verdict":"satisfied","findings_filed":0,"wall_clock_ms":1260000} +{"event_id":"e594e3de-41d5-4f5a-8fce-1804d1b5f038","schema_version":"1","ts":"2026-07-03T16:51:24.346Z","project_run_id":"lsp-document-state","orchestrator_agent_id":null,"event_type":"dispatch-end","dispatch_id":"18335083-5628-4d18-adf2-a57e2d9c4559","result":"completed","wall_clock_ms":4980000}