diff --git a/apps/lsp-playground/fixtures/broken.psl b/apps/lsp-playground/fixtures/broken.psl index 8ab2f4061f50..e83416cfcef2 100644 --- a/apps/lsp-playground/fixtures/broken.psl +++ b/apps/lsp-playground/fixtures/broken.psl @@ -1,3 +1,4 @@ +// use prisma-next model User { id Int @id email String diff --git a/packages/1-framework/3-tooling/language-server/README.md b/packages/1-framework/3-tooling/language-server/README.md index 5244d5718105..abd317de07e3 100644 --- a/packages/1-framework/3-tooling/language-server/README.md +++ b/packages/1-framework/3-tooling/language-server/README.md @@ -4,56 +4,8 @@ The Prisma Next language server speaks the Language Server Protocol over stdio for PSL schema inputs declared in a project's `prisma.config.ts`. It is launched by the `prisma lsp` subcommand, so editor features come from the project's own Prisma Next version and stay version-matched by construction. -## Scope - -Supported capabilities are intentionally narrow: parse diagnostics, whole-document formatting, folding ranges, full/range semantic tokens, model field type completion, descriptor-backed generic block parameter completion, and declaration keyword completion for configured PSL inputs. Formatting is only available for documents listed in `contract.source.inputs`, uses `@internal/psl-parser/format`, and applies formatter options from the project's Prisma config `formatter` block. Semantic tokens use the standard LSP token taxonomy advertised by the server; they do not introduce Prisma-specific token names or a second parser. Completion is only available for open configured PSL inputs. At model field type positions, it suggests configured scalar types plus visible model, composite type, scalar, type-alias, and namespace qualifier candidates from the current project symbol table; bare positions offer namespace segments such as `auth.`, and after a namespace qualifier the provider suggests visible model and composite type members inside that namespace. The classifier also accepts contract-space-qualified type-position syntax such as `supabase:auth.User` when the namespace data is visible in the current cached artifacts. Inside descriptor-backed generic block bodies, it suggests declared parameter keys and excludes keys already present in sibling entries. At document top-level declaration positions, it suggests native PSL block keywords `model`, `type`, `types`, and `namespace`, plus descriptor-backed generic block keywords from `pslBlockDescriptors`. Inside namespace bodies, declaration keyword completion suggests only namespace-valid native keywords `model` and `type`, plus descriptor-backed generic block keywords; it does not suggest nested `namespace` or `types`. Declaration keyword items are snippets only when the client advertises `textDocument.completion.completionItem.snippetSupport === true`; otherwise the server returns plain-text edits for the same labels. Ordinary PSL `@` / `@@` attribute completions, attribute argument completions, generic block parameter value completions, generic block value completions, relation-aware completions, and new external contract-space candidate discovery are not part of this slice. Hover, navigation, range formatting, on-type formatting, semantic-token delta requests, and editor-extension work are out of scope. A server process can manage multiple projects under the workspace root, keyed by the config file each open document belongs to. +The server only handles documents whose first non-whitespace content is a `// use prisma-next` line comment; all other documents belong to the legacy (Prisma ≤7) language server and are ignored per request from current document content. The check must stay byte-for-byte in sync with the legacy server's copy in `prisma/language-tools`. ## Responsibilities -- Resolve workspace/project configuration for open PSL documents and keep managed projects aligned with config-file changes. -- 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. - -## Dependencies - -- `@internal/config-loader` — discovers nearest config files and loads project configuration. -- `@internal/psl-parser` — parses PSL, builds symbol tables, exposes syntax artifacts, and formats PSL text. -- `@internal/framework-components` — supplies control-stack input types used when resolving project configuration. -- `@internal/errors` and `@internal/utils` — shared framework utilities used by parsing/config plumbing. -- `vscode-languageserver` and `vscode-languageserver-textdocument` — LSP connection, request/notification types, semantic-token/folding/formatting types, and incremental document management. - -## 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`. 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 each open or change is forwarded to the project's artifact store as a document-changed event. The server parses the full current buffer at most once per change, when a read (diagnostic pull, completion, semantic tokens, folding, or a push publish) next needs the document's artifacts. -3. **Diagnostics** — served over exactly one transport per client, decided at `initialize`. For clients that advertise `textDocument.diagnostic` (pull), `didOpen` / `didChange` only invalidate — the artifact store is only told the document changed and no eager work runs; a `textDocument/diagnostic` request reads the document artifacts from the store, which parses the current buffer internally when needed (running `@internal/psl-parser`'s `parse()` and `buildSymbolTable()` once per change) 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 `@internal/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 current document artifacts from the project store 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; the store parses the current buffer internally before returning artifacts, so tokens never derive from an out-of-date parse. 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. **Project artifact store** — each project load constructs one artifact store with the config's inputs, control stack, and a text provider over the open-document mirror. The store returns the AST, `SourceFile`, and diagnostics per open configured input plus one project symbol table, parsing internally when a read needs them; document-changed/closed events (edits, closes) and store replacement on config reload are the only things that change what reads return. The artifacts 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 - -- `diagnostic-mapping.ts` — pure `ParseDiagnostic[] → LspDiagnostic[]` mapping. Free of any `vscode-languageserver` import; it returns plain shape objects (ranges pass through unchanged) so it stays reusable. The connection layer adapts the numeric severity to the LSP enum. -- `schema-inputs.ts` — resolves the schema-input set (`SchemaInputSet`) from a config and answers URI membership. -- `config-resolution.ts` — wraps `loadConfig` and resolves schema inputs, formatter options, and control-stack inputs for a config. A standalone async function so it can be re-run on a config change without rewiring the server. -- `document-diagnostics.ts` — `computeDocumentDiagnostics(uri, text, inputs, controlStack)`, the pure seam that parses, builds the symbol table, and returns the diagnostics plus the parse artifacts. -- `project-artifacts.ts` — `createProjectArtifacts({ inputs, controlStack, getText })`, the per-project-load store that owns document artifacts and the project symbol table, parsing on demand and responding to document-changed/closed events. -- `folding-ranges.ts` — pure AST-to-LSP folding-range computation for declaration/block bodies. -- `semantic-tokens.ts` — pure PSL semantic-token collection, range filtering, multiline normalization, duplicate resolution, modifier bitset encoding, and LSP semantic-token data encoding. -- `completion-context.ts` — pure cursor classifier for PSL completion contexts, currently routing model field type positions, descriptor-backed generic block parameter-key positions, and declaration keyword positions while marking everything outside slice scope unsupported. -- `completion-provider.ts` — pure completion item provider for supported model field type, generic block parameter, and declaration keyword contexts. -- `server.ts` — `createServer(connection)` wires diagnostics, whole-document formatting, folding ranges, semantic-token handlers, completion, config watching, and project-artifact access onto an injected connection. -- `start-server.ts` — `startServer()` and `startServer(streams)`, the two ways in, kept apart by overloads so neither can be awaited wrongly. With no argument it builds its own stdio connection from the process arguments and returns nothing: `vscode-languageserver/node` ends the process itself on disconnect, so there is no exit code to hand back. With the host's streams it resolves with one. -- `stream-server.ts` — runs the server over an injected stream pair and resolves with the exit code the client's departure implies: 0 after a `shutdown`, 1 otherwise (and 1 for a frame the reader cannot make sense of, which leaves it desynchronised for good). It waits for the work in flight to finish first — end of input is not the end of the conversation — and redirects the global console to the host's stderr for the length of the run, so nothing but frames reaches the client. Nothing here ends the process; the host owns it. -- `stdio-transport.ts` — the byte adapter between a host's streams (`AsyncIterable` in, `write(text: string)` out) and the Node streams `vscode-languageserver`'s reader and writer want. Output is decoded through a `StringDecoder`, so a body split mid-character across two writes still declares the `Content-Length` it has. -- `guarded-connection.ts` — wraps the connection so a send the departed client cannot receive does nothing. Without it a send after the input ends throws, and a throw escaping a notification handler ends the process. - -## Package Location - -- **Domain**: framework (target-agnostic) -- **Layer**: tooling -- **Plane**: migration -- **Path**: `packages/1-framework/3-tooling/language-server` +- Serve diagnostics, whole-document formatting, folding ranges, semantic tokens, and completion for open configured PSL inputs carrying the directive. diff --git a/packages/1-framework/3-tooling/language-server/src/document-diagnostics.ts b/packages/1-framework/3-tooling/language-server/src/document-diagnostics.ts index 1fc10ecf9e24..611f8060b646 100644 --- a/packages/1-framework/3-tooling/language-server/src/document-diagnostics.ts +++ b/packages/1-framework/3-tooling/language-server/src/document-diagnostics.ts @@ -2,6 +2,7 @@ import type { SymbolTable } from '@internal/psl-parser'; import type { DocumentAst, SourceFile } from '@internal/psl-parser/syntax'; import type { LspDiagnostic } from './diagnostic-mapping'; import { type PipelineInputs, runPipeline } from './pipeline'; +import { isPrismaNextSchema } from './schema-directive'; import type { SchemaInputSet } from './schema-inputs'; export interface DocumentDiagnostics { @@ -23,7 +24,7 @@ export function computeDocumentDiagnostics( inputs: SchemaInputSet, controlStack: PipelineInputs, ): DocumentDiagnostics | null { - if (!inputs.includes(uri)) { + if (!inputs.includes(uri) || !isPrismaNextSchema(text)) { return null; } const { document, sourceFile, symbolTable, diagnostics } = runPipeline(text, controlStack); 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 45cb53da045d..e69c42278957 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 @@ -135,7 +135,7 @@ export function createProjectArtifacts(options: ProjectArtifactsOptions): Projec // once its last open input closes. Throwing loudly beats serving a // fabricated empty symbolTable that would mask the broken invariant. throw new InternalError( - 'invariant violated: project has no open configured input — the server must drop such projects', + 'invariant violated: project has no readable configured input — callers must check document artifacts first', ); }, documentChanged: drop, diff --git a/packages/1-framework/3-tooling/language-server/src/schema-directive.ts b/packages/1-framework/3-tooling/language-server/src/schema-directive.ts new file mode 100644 index 000000000000..b483880c7327 --- /dev/null +++ b/packages/1-framework/3-tooling/language-server/src/schema-directive.ts @@ -0,0 +1,5 @@ +const PRISMA_NEXT_DIRECTIVE = /^\s*\/\/ *use +prisma-next *(?!\S)/; + +export function isPrismaNextSchema(text: string): boolean { + return PRISMA_NEXT_DIRECTIVE.test(text); +} 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 edda326d410f..473c8e7946d7 100644 --- a/packages/1-framework/3-tooling/language-server/src/server.ts +++ b/packages/1-framework/3-tooling/language-server/src/server.ts @@ -42,6 +42,7 @@ import { type DocumentArtifacts, type ProjectArtifacts, } from './project-artifacts'; +import { isPrismaNextSchema } from './schema-directive'; import type { SchemaInputSet } from './schema-inputs'; import { buildSemanticTokens, semanticTokensLegend } from './semantic-tokens'; @@ -379,12 +380,16 @@ function createServerOn(connection: Connection): LanguageServer { return []; } + const source = document.getText(); + if (!isPrismaNextSchema(source)) { + return []; + } + const project = await resolveProjectForDocument(uri); if (project === undefined) { return []; } - const source = document.getText(); let formatted: string; try { formatted = format(source, project.formatter); @@ -652,7 +657,13 @@ function createServerOn(connection: Connection): LanguageServer { getDocumentAst: (uri) => artifactsForDocument(uri)?.document(uri), // `| undefined` only because the uri may be unmanaged (closed, non-input, // or projectless); a managed document's project always yields a symbolTable. - getProjectSymbolTable: (uri) => artifactsForDocument(uri)?.symbolTable(), + getProjectSymbolTable: (uri) => { + const artifacts = artifactsForDocument(uri); + if (artifacts?.document(uri) === undefined) { + return undefined; + } + return artifacts.symbolTable(); + }, }; } diff --git a/packages/1-framework/3-tooling/language-server/test/document-diagnostics.test.ts b/packages/1-framework/3-tooling/language-server/test/document-diagnostics.test.ts index 7995615620ef..8f1ca8c2214b 100644 --- a/packages/1-framework/3-tooling/language-server/test/document-diagnostics.test.ts +++ b/packages/1-framework/3-tooling/language-server/test/document-diagnostics.test.ts @@ -17,7 +17,10 @@ const controlStack: PipelineInputs = { pslBlockDescriptors: {}, }; +const directive = '// use prisma-next'; + const duplicateModelSource = [ + directive, 'model User {', ' id Int @id', '}', @@ -29,7 +32,7 @@ const duplicateModelSource = [ describe('computeDocumentDiagnostics', () => { it('publishes parser diagnostics for a configured PSL input with a parse error', () => { - const source = 'model {'; + const source = '// use prisma-next\nmodel {'; const result = computeDocumentDiagnostics(schemaUri, source, inputs, controlStack); expect(result).not.toBeNull(); expect(result?.diagnostics).toEqual(mapParseDiagnostics(parse(source).diagnostics)); @@ -39,7 +42,7 @@ describe('computeDocumentDiagnostics', () => { it('publishes an empty array for a clean configured PSL input', () => { const result = computeDocumentDiagnostics( schemaUri, - 'model User {\n id Int @id\n}\n', + '// use prisma-next\nmodel User {\n id Int @id\n}\n', inputs, controlStack, ); @@ -51,6 +54,12 @@ describe('computeDocumentDiagnostics', () => { const result = computeDocumentDiagnostics(otherUri, 'model {', inputs, controlStack); expect(result).toBeNull(); }); + + it('returns null for a configured input without the prisma-next directive', () => { + const result = computeDocumentDiagnostics(schemaUri, 'model {', inputs, controlStack); + expect(result).toBeNull(); + }); + it('runs the symbol-table tier and reports a duplicate top-level declaration', () => { const result = computeDocumentDiagnostics( schemaUri, @@ -64,7 +73,7 @@ describe('computeDocumentDiagnostics', () => { }); it('matches the merged parse + symbol-table diagnostics for the same inputs', () => { - const source = ['model Profile {', ' user a.b.c', '}'].join('\n'); + const source = [directive, 'model Profile {', ' user a.b.c', '}'].join('\n'); const { document, sourceFile, diagnostics: parseDiagnostics } = parse(source); const { diagnostics: symbolTableDiagnostics } = buildSymbolTable({ document, @@ -82,7 +91,7 @@ describe('computeDocumentDiagnostics', () => { it('exposes the parsed AST and the symbol table as artifacts', () => { const result = computeDocumentDiagnostics( schemaUri, - 'model User {\n id Int @id\n}\n', + '// use prisma-next\nmodel User {\n id Int @id\n}\n', inputs, controlStack, ); @@ -100,7 +109,12 @@ describe('computeDocumentDiagnostics', () => { it('does not throw on a malformed, half-typed buffer', () => { expect(() => - computeDocumentDiagnostics(schemaUri, 'model User {\n id ', inputs, controlStack), + computeDocumentDiagnostics( + schemaUri, + '// use prisma-next\nmodel User {\n id ', + inputs, + controlStack, + ), ).not.toThrow(); }); }); 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 ee8a5d8ccc14..a1e71d19daa2 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 @@ -36,8 +36,10 @@ const controlStack: PipelineInputs = { pslBlockDescriptors: {}, }; -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'; +const directive = '// use prisma-next\n'; +const cleanSource = `${directive}model User {\n id Int @id\n}\n`; +const twoModelSource = `${directive}model User {\n id Int @id\n}\n\nmodel Post {\n id Int @id\n}\n`; +const unmarkedSource = 'model Stray {\n id Int @id\n}\n'; function projectWithMirror(interpretation?: ProjectInterpretation): { readonly texts: Map; @@ -119,6 +121,47 @@ describe('createProjectArtifacts', () => { expect(pipelineMock.runPipeline).not.toHaveBeenCalled(); }); + it('returns undefined for a configured input without the prisma-next directive', () => { + const { texts, store } = projectWithMirror(); + texts.set(schemaUri, unmarkedSource); + + expect(store.document(schemaUri)).toBeUndefined(); + expect(pipelineMock.runPipeline).not.toHaveBeenCalled(); + }); + + it('serves a configured input again once an edit adds the directive', () => { + const { texts, store } = projectWithMirror(); + texts.set(schemaUri, unmarkedSource); + expect(store.document(schemaUri)).toBeUndefined(); + + texts.set(schemaUri, `${directive}${unmarkedSource}`); + store.documentChanged(schemaUri); + + expect(store.document(schemaUri)?.document).toBeDefined(); + }); + + it('excludes an unmarked sibling input from the symbol table', () => { + const schema2Uri = pathToFileURL('/abs/schema2.psl').toString(); + const twoInputs = resolveSchemaInputs({ + contract: { + source: { format: 'psl', inputs: ['/abs/schema.psl', '/abs/schema2.psl'] }, + }, + }); + const texts = new Map(); + const store = createProjectArtifacts({ + inputs: twoInputs, + controlStack, + getText: (uri) => texts.get(uri), + }); + texts.set(schemaUri, unmarkedSource); + texts.set(schema2Uri, cleanSource); + + expect(store.document(schemaUri)).toBeUndefined(); + const models = Object.keys(store.symbolTable().topLevel.models); + expect(models).toContain('User'); + expect(models).not.toContain('Stray'); + }); + it('reading the symbol table on a fresh store parses the open configured input once', () => { const { texts, store } = projectWithMirror(); texts.set(schemaUri, cleanSource); @@ -163,7 +206,9 @@ describe('createProjectArtifacts', () => { it('throws when no configured input is open instead of fabricating a table', () => { const { store } = projectWithMirror(); - expect(() => store.symbolTable()).toThrowError(/invariant violated.*no open configured input/i); + expect(() => store.symbolTable()).toThrowError( + /invariant violated.*no readable configured input/i, + ); expect(pipelineMock.runPipeline).not.toHaveBeenCalled(); }); @@ -180,7 +225,7 @@ describe('createProjectArtifacts', () => { it('returns diagnostics with parity to parse + buildSymbolTable for the same inputs', () => { const { texts, store } = projectWithMirror(); - const source = ['model Profile {', ' user a.b.c', '}'].join('\n'); + const source = [`${directive}model Profile {`, ' user a.b.c', '}'].join('\n'); texts.set(schemaUri, source); const { document, sourceFile, diagnostics: parseDiagnostics } = parse(source); const { diagnostics: symbolTableDiagnostics } = buildSymbolTable({ @@ -196,7 +241,7 @@ describe('createProjectArtifacts', () => { it('does not throw on a malformed, half-typed buffer', () => { const { texts, store } = projectWithMirror(); - texts.set(schemaUri, 'model User {\n id '); + texts.set(schemaUri, `${directive}model User {\n id `); expect(() => store.document(schemaUri)).not.toThrow(); }); }); @@ -205,7 +250,7 @@ describe('interpret slot', () => { const spanned = { code: 'PSL_UNRESOLVED_RELATION', message: 'relation target not found', - span: { start: { offset: 15, line: 2, column: 3 }, end: { offset: 21, line: 2, column: 9 } }, + span: { start: { offset: 34, line: 3, column: 3 }, end: { offset: 40, line: 3, column: 9 } }, }; it('does not interpret on document reads, only when the slot is pulled', () => { @@ -245,7 +290,7 @@ describe('interpret slot', () => { expect(store.document(schemaUri)?.interpretDiagnostics()).toEqual([ { - range: { start: { line: 1, character: 2 }, end: { line: 1, character: 8 } }, + range: { start: { line: 2, character: 2 }, end: { line: 2, character: 8 } }, message: 'relation target not found', code: 'PSL_UNRESOLVED_RELATION', severity: 1, diff --git a/packages/1-framework/3-tooling/language-server/test/schema-directive.test.ts b/packages/1-framework/3-tooling/language-server/test/schema-directive.test.ts new file mode 100644 index 000000000000..cfde97f3da48 --- /dev/null +++ b/packages/1-framework/3-tooling/language-server/test/schema-directive.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { isPrismaNextSchema } from '../src/schema-directive'; + +describe('isPrismaNextSchema', () => { + it('accepts a schema whose first line is the directive', () => { + expect(isPrismaNextSchema('// use prisma-next\nmodel User {\n id Int @id\n}\n')).toBe(true); + }); + + it('accepts the directive with no schema body', () => { + expect(isPrismaNextSchema('// use prisma-next')).toBe(true); + }); + + it('accepts leading blank lines and indentation before the directive', () => { + expect(isPrismaNextSchema('\n\n // use prisma-next\nmodel User {}\n')).toBe(true); + }); + + it('accepts trailing spaces after the directive', () => { + expect(isPrismaNextSchema('// use prisma-next \n')).toBe(true); + }); + + it('accepts flexible spacing inside the comment', () => { + expect(isPrismaNextSchema('//use prisma-next\n')).toBe(true); + expect(isPrismaNextSchema('// use prisma-next\n')).toBe(true); + }); + + it('rejects a token attached to the directive name', () => { + for (const suffix of ['2', 'gen']) { + expect(isPrismaNextSchema(`// use prisma-next${suffix}\n`)).toBe(false); + } + }); + + it('rejects a directive that is not the first content of the file', () => { + expect(isPrismaNextSchema('model User {}\n// use prisma-next\n')).toBe(false); + }); + + it('rejects a block-comment form', () => { + expect(isPrismaNextSchema('/* use prisma-next */\n')).toBe(false); + }); + + it('rejects unmarked and empty documents', () => { + expect(isPrismaNextSchema('model User {\n id Int @id\n}\n')).toBe(false); + expect(isPrismaNextSchema('')).toBe(false); + expect(isPrismaNextSchema('// use prisma\n')).toBe(false); + }); +}); 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 7a9323e5572b..2c020c0d76ae 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 @@ -97,8 +97,8 @@ const schemaPath = join(root, 'schema.psl'); const schemaUri = pathToFileURL(schemaPath).toString(); const configPath = join(root, 'prisma.config.ts'); const configUri = pathToFileURL(configPath).toString(); -const unformattedPsl = 'model User {\nid Int\n}'; -const formattedPsl = 'model User {\n id Int\n}\n'; +const unformattedPsl = '// use prisma-next\nmodel User {\nid Int\n}'; +const formattedPsl = '// use prisma-next\nmodel User {\n id Int\n}\n'; const scalarTypes = ['String', 'Int', 'Boolean', 'DateTime'] as const; const nameSnippetPlaceholder = '$' + '{1:Name}'; @@ -595,6 +595,7 @@ describe('language server', { timeout: timeouts.databaseOperation }, () => { await harness.initialize(); const { source, position } = sourceWithCursor( [ + '// use prisma-next', 'model User {', ' id Int @id', '}', @@ -627,9 +628,18 @@ describe('language server', { timeout: timeouts.databaseOperation }, () => { it('refreshes completion artifacts from the current buffer before classifying', async () => { harness = startHarness(resolveToSchema); await harness.initialize(); - const initial = ['model Post {', ' author |', '}'].join('\n'); + const initial = ['// use prisma-next', 'model Post {', ' author |', '}'].join('\n'); const updated = sourceWithCursor( - ['model User {', ' id Int @id', '}', '', 'model Post {', ' author U|', '}'].join('\n'), + [ + '// use prisma-next', + 'model User {', + ' id Int @id', + '}', + '', + 'model Post {', + ' author U|', + '}', + ].join('\n'), ); openDocument(harness, schemaUri, initial); await harness.waitForDiagnostics(schemaUri); @@ -657,7 +667,16 @@ describe('language server', { timeout: timeouts.databaseOperation }, () => { harness = startHarness(resolveToSchema); await harness.initialize(); const { source, position } = sourceWithCursor( - ['model User {', ' id Int @id', '}', '', 'model Post {', ' author |', '}'].join('\n'), + [ + '// use prisma-next', + 'model User {', + ' id Int @id', + '}', + '', + 'model Post {', + ' author |', + '}', + ].join('\n'), ); openDocument(harness, schemaUri, source); await harness.waitForDiagnosticsCount(schemaUri, 2); @@ -673,9 +692,18 @@ describe('language server', { timeout: timeouts.databaseOperation }, () => { 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 initial = ['// use prisma-next', 'model Post {', ' author ', '}'].join('\n'); const updated = sourceWithCursor( - ['model User {', ' id Int @id', '}', '', 'model Post {', ' author U|', '}'].join('\n'), + [ + '// use prisma-next', + 'model User {', + ' id Int @id', + '}', + '', + 'model Post {', + ' author U|', + '}', + ].join('\n'), ); openDocument(harness, schemaUri, initial); await harness.waitForDiagnosticsCount(schemaUri, 2); @@ -703,7 +731,9 @@ describe('language server', { timeout: timeouts.databaseOperation }, () => { it('returns generic block parameter completions for configured PSL descriptors', async () => { harness = startHarness(resolveToSchemaWithPslBlockDescriptors); await harness.initialize(); - const { source, position } = sourceWithCursor(['policy UserAccess {', ' wh|', '}'].join('\n')); + const { source, position } = sourceWithCursor( + ['// use prisma-next', 'policy UserAccess {', ' wh|', '}'].join('\n'), + ); openDocument(harness, schemaUri, source); await harness.waitForDiagnostics(schemaUri); @@ -714,7 +744,7 @@ describe('language server', { timeout: timeouts.databaseOperation }, () => { it('returns declaration keyword completions with plain-text edits by default', async () => { harness = startHarness(resolveToSchemaWithPslBlockDescriptors); await harness.initialize(); - const { source, position } = sourceWithCursor('|'); + const { source, position } = sourceWithCursor('// use prisma-next\n|'); openDocument(harness, schemaUri, source); await harness.waitForDiagnostics(schemaUri); @@ -757,7 +787,7 @@ describe('language server', { timeout: timeouts.databaseOperation }, () => { it('returns declaration keyword snippets when the client supports snippets', async () => { harness = startHarness(resolveToSchemaWithPslBlockDescriptors, snippetCompletionCapabilities); await harness.initialize(); - const { source, position } = sourceWithCursor('|'); + const { source, position } = sourceWithCursor('// use prisma-next\n|'); openDocument(harness, schemaUri, source); await harness.waitForDiagnostics(schemaUri); @@ -775,7 +805,9 @@ describe('language server', { timeout: timeouts.databaseOperation }, () => { it('returns namespace-body declaration keywords without document-only keywords', async () => { harness = startHarness(resolveToSchemaWithPslBlockDescriptors); await harness.initialize(); - const { source, position } = sourceWithCursor(['namespace feature {', ' |', '}'].join('\n')); + const { source, position } = sourceWithCursor( + ['// use prisma-next', 'namespace feature {', ' |', '}'].join('\n'), + ); openDocument(harness, schemaUri, source); await harness.waitForDiagnostics(schemaUri); @@ -790,7 +822,16 @@ describe('language server', { timeout: timeouts.databaseOperation }, () => { await harness.initialize(); const otherUri = pathToFileURL(join(root, 'not-a-schema.psl')).toString(); const { source, position } = sourceWithCursor( - ['model User {', ' id Int @id', '}', '', 'model Post {', ' author |', '}'].join('\n'), + [ + '// use prisma-next', + 'model User {', + ' id Int @id', + '}', + '', + 'model Post {', + ' author |', + '}', + ].join('\n'), ); openDocument(harness, otherUri, source); @@ -801,7 +842,9 @@ describe('language server', { timeout: timeouts.databaseOperation }, () => { it('returns no completion items for ordinary field attribute contexts', async () => { harness = startHarness(resolveToSchema); await harness.initialize(); - const { source, position } = sourceWithCursor(['model User {', ' id Int @|', '}'].join('\n')); + const { source, position } = sourceWithCursor( + ['// use prisma-next', 'model User {', ' id Int @|', '}'].join('\n'), + ); openDocument(harness, schemaUri, source); await harness.waitForDiagnostics(schemaUri); @@ -813,7 +856,7 @@ describe('language server', { timeout: timeouts.databaseOperation }, () => { harness = startHarness(resolveToSchema); await harness.initialize(); const { source, position } = sourceWithCursor( - ['model User {', ' id Int @id', ' @@|', '}'].join('\n'), + ['// use prisma-next', 'model User {', ' id Int @id', ' @@|', '}'].join('\n'), ); openDocument(harness, schemaUri, source); await harness.waitForDiagnostics(schemaUri); @@ -827,7 +870,12 @@ describe('language server', { timeout: timeouts.databaseOperation }, () => { await harness.initialize(); harness.client.sendNotification(DidOpenTextDocumentNotification.type, { - textDocument: { uri: schemaUri, languageId: 'prisma', version: 1, text: 'model {' }, + textDocument: { + uri: schemaUri, + languageId: 'prisma', + version: 1, + text: '// use prisma-next\nmodel {', + }, }); const diagnostics = await harness.waitForDiagnostics(schemaUri); @@ -843,7 +891,7 @@ describe('language server', { timeout: timeouts.databaseOperation }, () => { uri: schemaUri, languageId: 'prisma', version: 1, - text: 'model User {\n id Int @id\n}\n', + text: '// use prisma-next\nmodel User {\n id Int @id\n}\n', }, }); @@ -857,7 +905,12 @@ describe('language server', { timeout: timeouts.databaseOperation }, () => { const otherUri = pathToFileURL(join(root, 'not-a-schema.psl')).toString(); harness.client.sendNotification(DidOpenTextDocumentNotification.type, { - textDocument: { uri: otherUri, languageId: 'prisma', version: 1, text: 'model {' }, + textDocument: { + uri: otherUri, + languageId: 'prisma', + version: 1, + text: '// use prisma-next\nmodel {', + }, }); await settle(); @@ -870,7 +923,12 @@ describe('language server', { timeout: timeouts.databaseOperation }, () => { await harness.initialize(); harness.client.sendNotification(DidOpenTextDocumentNotification.type, { - textDocument: { uri: schemaUri, languageId: 'prisma', version: 1, text: 'model {' }, + textDocument: { + uri: schemaUri, + languageId: 'prisma', + version: 1, + text: '// use prisma-next\nmodel {', + }, }); const broken = await harness.waitForDiagnostics(schemaUri); expect(broken.length).toBeGreaterThan(0); @@ -884,7 +942,7 @@ describe('language server', { timeout: timeouts.databaseOperation }, () => { }); harness.client.sendNotification(DidChangeTextDocumentNotification.type, { textDocument: { uri: schemaUri, version: 2 }, - contentChanges: [{ text: 'model User {\n id Int @id\n}\n' }], + contentChanges: [{ text: '// use prisma-next\nmodel User {\n id Int @id\n}\n' }], }); expect(await cleared).toEqual([]); }); @@ -895,7 +953,12 @@ describe('language server', { timeout: timeouts.databaseOperation }, () => { expect(result).toBeDefined(); harness.client.sendNotification(DidOpenTextDocumentNotification.type, { - textDocument: { uri: schemaUri, languageId: 'prisma', version: 1, text: 'model {' }, + textDocument: { + uri: schemaUri, + languageId: 'prisma', + version: 1, + text: '// use prisma-next\nmodel {', + }, }); await waitUntil(() => configResolutionMock.resolveConfigInputs.mock.calls.length === 1); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -910,7 +973,7 @@ describe('language server', { timeout: timeouts.databaseOperation }, () => { await expect(requestFormatting(harness, schemaUri)).resolves.toEqual([ { - range: { start: { line: 0, character: 0 }, end: { line: 2, character: 1 } }, + range: { start: { line: 0, character: 0 }, end: { line: 3, character: 1 } }, newText: formattedPsl, }, ]); @@ -937,7 +1000,7 @@ describe('language server', { timeout: timeouts.databaseOperation }, () => { it('returns no edits for malformed PSL', async () => { harness = startHarness(resolveToSchema); await harness.initialize(); - openDocument(harness, schemaUri, 'model {'); + openDocument(harness, schemaUri, '// use prisma-next\nmodel {'); expect((await harness.waitForDiagnostics(schemaUri)).length).toBeGreaterThan(0); await expect(requestFormatting(harness, schemaUri)).resolves.toEqual([]); @@ -967,8 +1030,8 @@ describe('language server', { timeout: timeouts.databaseOperation }, () => { await expect(requestFormatting(harness, schemaUri)).resolves.toEqual([ { - range: { start: { line: 0, character: 0 }, end: { line: 2, character: 1 } }, - newText: 'model User {\r\n\tid Int\r\n}\r\n', + range: { start: { line: 0, character: 0 }, end: { line: 3, character: 1 } }, + newText: '// use prisma-next\r\nmodel User {\r\n\tid Int\r\n}\r\n', }, ]); }); @@ -976,11 +1039,13 @@ describe('language server', { timeout: timeouts.databaseOperation }, () => { it('returns full semantic tokens for a configured open PSL input', async () => { harness = startHarness(resolveToSchema); await harness.initialize(); - openDocument(harness, schemaUri, 'model User {\n id Int @id\n}\n'); + openDocument(harness, schemaUri, '// use prisma-next\nmodel User {\n id Int @id\n}\n'); expect(await harness.waitForDiagnostics(schemaUri)).toEqual([]); await expect(requestSemanticTokens(harness, schemaUri)).resolves.toEqual({ - data: [0, 0, 5, 0, 0, 0, 6, 4, 2, 1, 1, 2, 2, 5, 1, 0, 3, 3, 4, 2, 0, 4, 3, 6, 0], + data: [ + 0, 0, 18, 9, 0, 1, 0, 5, 0, 0, 0, 6, 4, 2, 1, 1, 2, 2, 5, 1, 0, 3, 3, 4, 2, 0, 4, 3, 6, 0, + ], }); }); @@ -990,7 +1055,7 @@ describe('language server', { timeout: timeouts.databaseOperation }, () => { openDocument( harness, schemaUri, - 'model User {\n id Int @id\n}\n\nmodel Post {\n id Int @id\n}\n', + '// use prisma-next\nmodel User {\n id Int @id\n}\n\nmodel Post {\n id Int @id\n}\n', ); expect(await harness.waitForDiagnostics(schemaUri)).toEqual([]); @@ -1000,7 +1065,9 @@ describe('language server', { timeout: timeouts.databaseOperation }, () => { end: { line: 3, character: 0 }, }), ).resolves.toEqual({ - data: [0, 0, 5, 0, 0, 0, 6, 4, 2, 1, 1, 2, 2, 5, 1, 0, 3, 3, 4, 2, 0, 4, 3, 6, 0], + data: [ + 0, 0, 18, 9, 0, 1, 0, 5, 0, 0, 0, 6, 4, 2, 1, 1, 2, 2, 5, 1, 0, 3, 3, 4, 2, 0, 4, 3, 6, 0, + ], }); }); @@ -1008,7 +1075,7 @@ describe('language server', { timeout: timeouts.databaseOperation }, () => { harness = startHarness(resolveToSchema); await harness.initialize(); const otherUri = pathToFileURL(join(root, 'not-a-schema.psl')).toString(); - openDocument(harness, otherUri, 'model User {\n id Int @id\n}\n'); + openDocument(harness, otherUri, '// use prisma-next\nmodel User {\n id Int @id\n}\n'); await expect(requestSemanticTokens(harness, otherUri)).resolves.toEqual({ data: [] }); }); @@ -1018,7 +1085,7 @@ describe('language server', { timeout: timeouts.databaseOperation }, () => { await harness.initialize(); await expect(requestSemanticTokens(harness, schemaUri)).resolves.toEqual({ data: [] }); - openDocument(harness, schemaUri, 'model User {\n id Int @id\n}\n'); + openDocument(harness, schemaUri, '// use prisma-next\nmodel User {\n id Int @id\n}\n'); expect(await harness.waitForDiagnostics(schemaUri)).toEqual([]); const closed = harness.waitForDiagnosticsMatching( schemaUri, @@ -1033,7 +1100,7 @@ describe('language server', { timeout: timeouts.databaseOperation }, () => { it('returns best-effort semantic tokens for malformed configured inputs', async () => { harness = startHarness(resolveToSchema); await harness.initialize(); - openDocument(harness, schemaUri, 'model User {\n id Int @id\n'); + openDocument(harness, schemaUri, '// use prisma-next\nmodel User {\n id Int @id\n'); expect((await harness.waitForDiagnostics(schemaUri)).length).toBeGreaterThan(0); const tokens = await requestSemanticTokens(harness, schemaUri); @@ -1054,7 +1121,7 @@ describe('language server', { timeout: timeouts.databaseOperation }, () => { it('returns empty semantic tokens when config resolution fails', async () => { harness = startHarness(resolveFails); await harness.initialize(); - openDocument(harness, schemaUri, 'model User {\n id Int @id\n}\n'); + openDocument(harness, schemaUri, '// use prisma-next\nmodel User {\n id Int @id\n}\n'); await waitUntil(() => configResolutionMock.resolveConfigInputs.mock.calls.length === 1); await expect(requestSemanticTokens(harness, schemaUri)).resolves.toEqual({ data: [] }); @@ -1063,7 +1130,7 @@ describe('language server', { timeout: timeouts.databaseOperation }, () => { it('returns empty semantic tokens for oversized configured inputs', async () => { harness = startHarness(resolveToSchema); await harness.initialize(); - openDocument(harness, schemaUri, `// ${'x'.repeat(100_000)}`); + openDocument(harness, schemaUri, `// use prisma-next\n// ${'x'.repeat(100_000)}`); expect(await harness.waitForDiagnostics(schemaUri)).toEqual([]); await expect(requestSemanticTokens(harness, schemaUri)).resolves.toEqual({ data: [] }); @@ -1072,7 +1139,7 @@ describe('language server', { timeout: timeouts.databaseOperation }, () => { it('returns semantic tokens for the current edit', async () => { harness = startHarness(resolveToSchema); await harness.initialize(); - openDocument(harness, schemaUri, 'model User {\n id Int @id\n}\n'); + openDocument(harness, schemaUri, '// use prisma-next\nmodel User {\n id Int @id\n}\n'); expect(await harness.waitForDiagnostics(schemaUri)).toEqual([]); const cleared = harness.waitForDiagnosticsMatching( @@ -1081,12 +1148,14 @@ describe('language server', { timeout: timeouts.databaseOperation }, () => { ); harness.client.sendNotification(DidChangeTextDocumentNotification.type, { textDocument: { uri: schemaUri, version: 2 }, - contentChanges: [{ text: 'model Invoice {\n id Int @id\n}\n' }], + contentChanges: [{ text: '// use prisma-next\nmodel Invoice {\n id Int @id\n}\n' }], }); await cleared; await expect(requestSemanticTokens(harness, schemaUri)).resolves.toEqual({ - data: [0, 0, 5, 0, 0, 0, 6, 7, 2, 1, 1, 2, 2, 5, 1, 0, 3, 3, 4, 2, 0, 4, 3, 6, 0], + data: [ + 0, 0, 18, 9, 0, 1, 0, 5, 0, 0, 0, 6, 7, 2, 1, 1, 2, 2, 5, 1, 0, 3, 3, 4, 2, 0, 4, 3, 6, 0, + ], }); }); @@ -1095,25 +1164,28 @@ describe('language server', { timeout: timeouts.databaseOperation }, () => { harness = startHarness(async () => load.promise); await harness.initialize(); - openDocument(harness, schemaUri, 'model User {\n id Int @id\n}\n'); + openDocument(harness, schemaUri, '// use prisma-next\nmodel User {\n id Int @id\n}\n'); const currentDiagnostics = harness.waitForDiagnosticsMatching( schemaUri, (diagnostics) => diagnostics.length === 0, ); harness.client.sendNotification(DidChangeTextDocumentNotification.type, { textDocument: { uri: schemaUri, version: 2 }, - contentChanges: [{ text: 'model Invoice {\n id Int @id\n}\n' }], + contentChanges: [{ text: '// use prisma-next\nmodel Invoice {\n id Int @id\n}\n' }], }); load.resolve(resolutionForInputs([schemaPath])); await currentDiagnostics; await expect(requestSemanticTokens(harness, schemaUri)).resolves.toEqual({ - data: [0, 0, 5, 0, 0, 0, 6, 7, 2, 1, 1, 2, 2, 5, 1, 0, 3, 3, 4, 2, 0, 4, 3, 6, 0], + data: [ + 0, 0, 18, 9, 0, 1, 0, 5, 0, 0, 0, 6, 7, 2, 1, 1, 2, 2, 5, 1, 0, 3, 3, 4, 2, 0, 4, 3, 6, 0, + ], }); }); }); const duplicateModelSource = [ + '// use prisma-next', 'model User {', ' id Int @id', '}', @@ -1147,7 +1219,7 @@ describe('language server symbol-table diagnostics', { harness = startHarness(resolveToSchema); await harness.initialize(); - const source = ['model Profile {', ' user a.b.c', '}'].join('\n'); + const source = ['// use prisma-next', 'model Profile {', ' user a.b.c', '}'].join('\n'); harness.client.sendNotification(DidOpenTextDocumentNotification.type, { textDocument: { uri: schemaUri, languageId: 'prisma', version: 1, text: source }, }); @@ -1180,7 +1252,9 @@ describe('language server symbol-table diagnostics', { harness.client.sendNotification(DidChangeTextDocumentNotification.type, { textDocument: { uri: schemaUri, version: 2 }, contentChanges: [ - { text: 'model User {\n id Int @id\n}\n\nmodel Post {\n id Int @id\n}\n' }, + { + text: '// use prisma-next\nmodel User {\n id Int @id\n}\n\nmodel Post {\n id Int @id\n}\n', + }, ], }); expect(await cleared).toEqual([]); @@ -1195,7 +1269,7 @@ describe('language server symbol-table diagnostics', { uri: schemaUri, languageId: 'prisma', version: 1, - text: 'model User {\n id Int @id\n}\n', + text: '// use prisma-next\nmodel User {\n id Int @id\n}\n', }, }); @@ -1208,6 +1282,7 @@ describe('language server symbol-table diagnostics', { await harness.initialize(); const source = [ + '// use prisma-next', 'model Profile {', ' user a.b.c', '}', @@ -1236,6 +1311,7 @@ describe('language server symbol-table diagnostics', { // the duplicate `model User` (a symbol-table-tier diagnostic) in the source, // so a stable parse-then-symbol-table merge must reorder them on publish. const source = [ + '// use prisma-next', 'model User {', ' id Int @id', '}', @@ -1275,7 +1351,7 @@ describe('language server symbol-table diagnostics', { uri: schemaUri, languageId: 'prisma', version: 1, - text: 'model User {\n id ', + text: '// use prisma-next\nmodel User {\n id ', }, }); @@ -1382,7 +1458,7 @@ describe('language server project registry', { timeout: timeouts.databaseOperati uri: projectASchemaUri, languageId: 'prisma', version: 1, - text: 'model {', + text: '// use prisma-next\nmodel {', }, }); harness.client.sendNotification(DidOpenTextDocumentNotification.type, { @@ -1390,7 +1466,7 @@ describe('language server project registry', { timeout: timeouts.databaseOperati uri: projectBSchemaUri, languageId: 'prisma', version: 1, - text: 'model {', + text: '// use prisma-next\nmodel {', }, }); @@ -1422,7 +1498,7 @@ describe('language server project registry', { timeout: timeouts.databaseOperati uri: unseenSchemaUri, languageId: 'prisma', version: 1, - text: 'model {', + text: '// use prisma-next\nmodel {', }, }); @@ -1445,7 +1521,12 @@ describe('language server project registry', { timeout: timeouts.databaseOperati await harness.initialize(); harness.client.sendNotification(DidOpenTextDocumentNotification.type, { - textDocument: { uri: otherUri, languageId: 'prisma', version: 1, text: 'model {' }, + textDocument: { + uri: otherUri, + languageId: 'prisma', + version: 1, + text: '// use prisma-next\nmodel {', + }, }); await settle(); @@ -1475,7 +1556,12 @@ describe('language server project registry', { timeout: timeouts.databaseOperati await harness.initialize(); harness.client.sendNotification(DidOpenTextDocumentNotification.type, { - textDocument: { uri: childSchemaUri, languageId: 'prisma', version: 1, text: 'model {' }, + textDocument: { + uri: childSchemaUri, + languageId: 'prisma', + version: 1, + text: '// use prisma-next\nmodel {', + }, }); await waitUntil(() => resolvedConfigs.length === 1); @@ -1486,7 +1572,16 @@ describe('language server project registry', { timeout: timeouts.databaseOperati it('serves reads during a config reload from the fresh resolution', async () => { const { source, position } = sourceWithCursor( - ['model User {', ' id Int @id', '}', '', 'model Post {', ' author |', '}'].join('\n'), + [ + '// use prisma-next', + 'model User {', + ' id Int @id', + '}', + '', + 'model Post {', + ' author |', + '}', + ].join('\n'), ); const refreshLoad = controlledPromise(); let loadCount = 0; @@ -1536,7 +1631,12 @@ describe('language server project registry', { timeout: timeouts.databaseOperati await harness.initialize(); harness.client.sendNotification(DidOpenTextDocumentNotification.type, { - textDocument: { uri: schemaUri, languageId: 'prisma', version: 1, text: 'model {' }, + textDocument: { + uri: schemaUri, + languageId: 'prisma', + version: 1, + text: '// use prisma-next\nmodel {', + }, }); await waitUntil(() => loadCount === 1); harness.notifyConfigChanged(pathToFileURL(projectConfigPath).toString()); @@ -1587,10 +1687,20 @@ describe('language server project registry', { timeout: timeouts.databaseOperati await harness.initialize(); harness.client.sendNotification(DidOpenTextDocumentNotification.type, { - textDocument: { uri: projectASchemaUri, languageId: 'prisma', version: 1, text: 'model {' }, + textDocument: { + uri: projectASchemaUri, + languageId: 'prisma', + version: 1, + text: '// use prisma-next\nmodel {', + }, }); harness.client.sendNotification(DidOpenTextDocumentNotification.type, { - textDocument: { uri: projectBSchemaUri, languageId: 'prisma', version: 1, text: 'model {' }, + textDocument: { + uri: projectBSchemaUri, + languageId: 'prisma', + version: 1, + text: '// use prisma-next\nmodel {', + }, }); expect((await harness.waitForDiagnostics(projectASchemaUri)).length).toBeGreaterThan(0); expect((await harness.waitForDiagnostics(projectBSchemaUri)).length).toBeGreaterThan(0); @@ -1652,7 +1762,12 @@ describe('language server config watching', { timeout: timeouts.databaseOperatio await harness.initialize(); harness.client.sendNotification(DidOpenTextDocumentNotification.type, { - textDocument: { uri: schemaUri, languageId: 'prisma', version: 1, text: 'model {' }, + textDocument: { + uri: schemaUri, + languageId: 'prisma', + version: 1, + text: '// use prisma-next\nmodel {', + }, }); await settle(); expect(harness.publishCount(schemaUri)).toBe(0); @@ -1672,7 +1787,12 @@ describe('language server config watching', { timeout: timeouts.databaseOperatio await harness.initialize(); harness.client.sendNotification(DidOpenTextDocumentNotification.type, { - textDocument: { uri: schemaUri, languageId: 'prisma', version: 1, text: 'model {' }, + textDocument: { + uri: schemaUri, + languageId: 'prisma', + version: 1, + text: '// use prisma-next\nmodel {', + }, }); expect((await harness.waitForDiagnostics(schemaUri)).length).toBeGreaterThan(0); @@ -1691,7 +1811,12 @@ describe('language server config watching', { timeout: timeouts.databaseOperatio await harness.initialize(); harness.client.sendNotification(DidOpenTextDocumentNotification.type, { - textDocument: { uri: schemaUri, languageId: 'prisma', version: 1, text: 'model {' }, + textDocument: { + uri: schemaUri, + languageId: 'prisma', + version: 1, + text: '// use prisma-next\nmodel {', + }, }); await waitUntil(() => configResolutionMock.resolveConfigInputs.mock.calls.length === 1); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -1712,7 +1837,12 @@ describe('language server config watching', { timeout: timeouts.databaseOperatio await harness.initialize(); harness.client.sendNotification(DidOpenTextDocumentNotification.type, { - textDocument: { uri: schemaUri, languageId: 'prisma', version: 1, text: 'model {' }, + textDocument: { + uri: schemaUri, + languageId: 'prisma', + version: 1, + text: '// use prisma-next\nmodel {', + }, }); const before = await harness.waitForDiagnostics(schemaUri); expect(before.length).toBeGreaterThan(0); @@ -1781,7 +1911,7 @@ describe('language server pull diagnostics', { timeout: timeouts.databaseOperati 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'); + openDocument(harness, schemaUri, '// use prisma-next\nmodel User {\n id Int @id\n}\n'); expect(fullReportItems(await requestPullDiagnostics(harness, schemaUri))).toEqual([]); pipelineMock.runPipeline.mockClear(); @@ -1972,7 +2102,9 @@ describe('language server preserved artifacts', { timeout: timeouts.databaseOper harness.client.sendNotification(DidChangeTextDocumentNotification.type, { textDocument: { uri: schemaUri, version: 2 }, contentChanges: [ - { text: 'model User {\n id Int @id\n}\n\nmodel Post {\n id Int @id\n}\n' }, + { + text: '// use prisma-next\nmodel User {\n id Int @id\n}\n\nmodel Post {\n id Int @id\n}\n', + }, ], }); await cleared; @@ -2113,17 +2245,17 @@ describe('language server disposal', { timeout: timeouts.databaseOperation }, () }); describe('language server interpreter diagnostics', { timeout: timeouts.databaseOperation }, () => { - const cleanSchema = 'model User {\n id Int @id\n}\n'; - const fixedSchema = 'model User {\n id Int @id\n}\n// fixed\n'; - // Span covers "User" on the first line: 1-based columns 7..11 map to the - // 0-based LSP range {0,6}..{0,10}. + const cleanSchema = '// use prisma-next\nmodel User {\n id Int @id\n}\n'; + const fixedSchema = '// use prisma-next\nmodel User {\n id Int @id\n}\n// fixed\n'; + // Span covers "User" on the line after the directive: 1-based columns 7..11 + // map to the 0-based LSP range {1,6}..{1,10}. const unresolvedDiagnostic = { code: 'PSL_UNRESOLVED_RELATION', message: 'relation target not found', - span: { start: { offset: 6, line: 1, column: 7 }, end: { offset: 10, line: 1, column: 11 } }, + span: { start: { offset: 25, line: 2, column: 7 }, end: { offset: 29, line: 2, column: 11 } }, }; const expectedUnresolved: Diagnostic = { - range: { start: { line: 0, character: 6 }, end: { line: 0, character: 10 } }, + range: { start: { line: 1, character: 6 }, end: { line: 1, character: 10 } }, message: 'relation target not found', code: 'PSL_UNRESOLVED_RELATION', severity: DiagnosticSeverity.Error, @@ -2259,7 +2391,7 @@ describe('language server interpreter diagnostics', { timeout: timeouts.database describe('language server config failure surfacing', { timeout: timeouts.databaseOperation, }, () => { - const cleanSchema = 'model User {\n id Int @id\n}\n'; + const cleanSchema = '// use prisma-next\nmodel User {\n id Int @id\n}\n'; const expectedConfigFailure = (message: string): Diagnostic => ({ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }, message, @@ -2487,3 +2619,122 @@ describe('language server config failure surfacing', { expect(harness.nonEmptyPublishCount(configUri)).toBe(0); }); }); + +describe('language server prisma-next directive gating', { + timeout: timeouts.databaseOperation, +}, () => { + const unmarkedDuplicate = [ + 'model User {', + ' id Int @id', + '}', + '', + 'model User {', + ' id Int @id', + '}', + '', + ].join('\n'); + const markedDuplicate = `// use prisma-next\n${unmarkedDuplicate}`; + + it('publishes empty diagnostics and answers no feature requests for an unmarked configured input', async () => { + harness = startHarness(resolveToSchemaWithPslBlockDescriptors); + await harness.initialize(); + + openDocument(harness, schemaUri, unmarkedDuplicate); + expect(await harness.waitForDiagnostics(schemaUri)).toEqual([]); + expect(harness.publishCount(schemaUri)).toBeGreaterThan(0); + + const items = completionItems( + await requestCompletion(harness, schemaUri, { line: 0, character: 0 }), + ); + expect(items).toEqual([]); + await expect(requestSemanticTokens(harness, schemaUri)).resolves.toEqual({ data: [] }); + await expect(requestFoldingRanges(harness, schemaUri)).resolves.toEqual([]); + expect(harness.getDocumentAst(schemaUri)).toBeUndefined(); + expect(harness.getProjectSymbolTable(schemaUri)).toBeUndefined(); + }); + + it('returns no formatting edits for an unmarked configured input', async () => { + harness = startHarness(resolveToSchema); + await harness.initialize(); + + openDocument(harness, schemaUri, 'model User {\nid Int\n}'); + expect(await harness.waitForDiagnostics(schemaUri)).toEqual([]); + + await expect(requestFormatting(harness, schemaUri)).resolves.toEqual([]); + }); + + it('serves an empty pull report for an unmarked configured input', async () => { + harness = startHarness(resolveToSchema, pullDiagnosticsCapabilities); + await harness.initialize(); + + openDocument(harness, schemaUri, unmarkedDuplicate); + expect(await requestPullDiagnostics(harness, schemaUri)).toEqual({ + kind: DocumentDiagnosticReportKind.Full, + items: [], + }); + }); + + it('publishes empty diagnostics and stops answering once an edit removes the directive', async () => { + harness = startHarness(resolveToSchema); + await harness.initialize(); + + openDocument(harness, schemaUri, markedDuplicate); + const before = await harness.waitForDiagnostics(schemaUri); + expect(before.map((diagnostic) => diagnostic.code)).toContain('PSL_DUPLICATE_DECLARATION'); + + const cleared = harness.waitForDiagnosticsMatching( + schemaUri, + (diagnostics) => diagnostics.length === 0, + ); + harness.client.sendNotification(DidChangeTextDocumentNotification.type, { + textDocument: { uri: schemaUri, version: 2 }, + contentChanges: [{ text: unmarkedDuplicate }], + }); + expect(await cleared).toEqual([]); + + await expect(requestSemanticTokens(harness, schemaUri)).resolves.toEqual({ data: [] }); + await expect(requestFoldingRanges(harness, schemaUri)).resolves.toEqual([]); + expect(harness.getDocumentAst(schemaUri)).toBeUndefined(); + }); + + it('begins diagnosing from the edited content once an edit adds the directive', async () => { + harness = startHarness(resolveToSchema); + await harness.initialize(); + + openDocument(harness, schemaUri, unmarkedDuplicate); + expect(await harness.waitForDiagnostics(schemaUri)).toEqual([]); + + const diagnosed = harness.waitForDiagnosticsMatching( + schemaUri, + (diagnostics) => diagnostics.length > 0, + ); + harness.client.sendNotification(DidChangeTextDocumentNotification.type, { + textDocument: { uri: schemaUri, version: 2 }, + contentChanges: [{ text: markedDuplicate }], + }); + expect((await diagnosed).map((diagnostic) => diagnostic.code)).toContain( + 'PSL_DUPLICATE_DECLARATION', + ); + + const tokens = await requestSemanticTokens(harness, schemaUri); + expect(tokens?.data.length).toBeGreaterThan(0); + }); + + it('excludes an unmarked sibling input from schema composition', async () => { + const schema2Path = join(root, 'schema2.psl'); + const schema2Uri = pathToFileURL(schema2Path).toString(); + harness = startHarness(async () => resolutionForInputs([schemaPath, schema2Path])); + await harness.initialize(); + + openDocument(harness, schemaUri, '// use prisma-next\nmodel User {\n id Int @id\n}\n'); + expect(await harness.waitForDiagnostics(schemaUri)).toEqual([]); + openDocument(harness, schema2Uri, 'model Stray {\n id Int @id\n}\n'); + expect(await harness.waitForDiagnostics(schema2Uri)).toEqual([]); + + const models = Object.keys(harness.getProjectSymbolTable(schemaUri)?.topLevel.models ?? {}); + expect(models).toContain('User'); + expect(models).not.toContain('Stray'); + expect(harness.getDocumentAst(schema2Uri)).toBeUndefined(); + expect(harness.getProjectSymbolTable(schema2Uri)).toBeUndefined(); + }); +});