Skip to content

Commit a049b56

Browse files
authored
TML-2930: add prisma-next lsp subcommand + PSL diagnostics language server (#852)
## Linked issue Refs [TML-2930](https://linear.app/prisma-company/issue/TML-2930/lsp-scaffold-prisma-next-lsp-subcommand-psl-diagnostics) — foundation work for the [Language Tools Support Prisma Next PSL](https://linear.app/prisma-company/project/language-tools-support-prisma-next-psl-3422a7e44b9c) project. The spec + plan live under `projects/lsp-scaffold/` for review and are removed at project close-out. ## At a glance The editor launches the project-local CLI as a language server over stdio: ``` node_modules/.bin/prisma-next lsp --stdio ``` It completes the `initialize` handshake, advertising incremental document sync and diagnostics: ```jsonc // → initialize response (real handshake, verified) { "capabilities": { "textDocumentSync": 2 } } ``` and then publishes PSL parse diagnostics for the schema files declared in `prisma-next.config.ts`, at the exact source ranges the parser reports — the same diagnostics `format` / `contract emit` would surface, now live at the cursor instead of only at build time. ## The decision This PR ships the first piece of Prisma Next LSP support. It carries three things: 1. **A new `prisma-next lsp` subcommand and a new `@prisma-next/language-server` package.** The subcommand is a thin entry point (`createLspCommand`) that delegates to the package, which owns the LSP connection, lifecycle, document sync, and diagnostics. `lsp` is a CLI subcommand — version-matched to the project's own `@prisma-next` by construction, like `deno lsp` — not a separate binary. 2. **A new `@prisma-next/config-loader` package** that owns loading `prisma-next.config.ts`. This is where config loading now lives for both the CLI and the language server, replacing the loader that previously lived inside `@prisma-next/cli`. 3. **Diagnostics sourced from the CST `parse`**, not the legacy `parsePslDocument` (which is slated for removal independently). One server runs per project. Schema documents are identified from config (a document is a schema input when its path is one of the resolved `contract.source.inputs`). The server watches `prisma-next.config.ts` and re-resolves on change, so the input set stays live without a restart. ## How config loading is organised now Before this PR, loading `prisma-next.config.ts` lived inside `@prisma-next/cli` (`config-loader.ts` + `config-path-validation.ts`), so a language-server package could not reuse it without importing from the CLI — which the dependency rules forbid. This PR moves that loading into its own package and removes the CLI's private copy: 1. **`@prisma-next/config-loader` exposes a single `loadConfig(configPath?)`.** It runs `c12` to execute the config file, validates it, resolves `contract.source.inputs` / `contract.output` to absolute paths, and applies the emitter output/input collision check (it imports `getEmittedArtifactPaths` from `@prisma-next/emitter` itself). On failure it throws the CLI's structured `@prisma-next/errors/control` errors — the same errors, with the same codes, the CLI produced before. See [`config-loader/src/load.ts`](packages/1-framework/3-tooling/config-loader/src/load.ts). 2. **The CLI's `config-loader.ts` wrapper is deleted.** Its ~17 source consumers (`contract-emit`, `db-sign`, `db-verify`, `inspect-live-schema`, `migrate`, the `migration-*` commands, …) now import `loadConfig` from `@prisma-next/config-loader` directly — same function name, same behaviour, same error codes. `cli/src/config-path-validation.ts` is deleted; its path-resolution logic moved alongside the loader. 3. **`@prisma-next/config` goes back to types + validation + path-finalising helpers** (`finalize-config.ts`, `errors.ts`, `config-types.ts`, `config-validation.ts`); it no longer loads config and no longer depends on `c12`. ## How the language server works 1. **`initialize`** — resolves the project root from the client's `rootUri`/`rootPath`, loads `prisma-next.config.ts` via `@prisma-next/config-loader`'s `loadConfig`, and records which absolute paths are schema inputs. A missing or invalid config degrades gracefully: the server still initializes with an empty input set and logs a warning. Config is resolved once here. 2. **Reading config errors** — `loadConfig` throws structured `CliStructuredError`s, so the server's [`config-resolution.ts`](packages/1-framework/3-tooling/language-server/src/config-resolution.ts) branches on the stable error `code` (`4001` = config file not found, `4009` = config validation) to decide whether to degrade; any other code is re-thrown as a genuine failure. The CLI error codes are unchanged by this PR. 3. **Document sync** — incremental (`TextDocumentSyncKind.Incremental`); the `TextDocuments` manager applies the client's deltas and the server re-parses the full current buffer on each change. 4. **Diagnostics** — on `didOpen` / `didChange` of a document whose URI is a configured input, the server runs `@prisma-next/psl-parser`'s `parse()` and publishes the mapped diagnostics. A clean parse publishes an empty array (clearing markers); a non-input document publishes nothing. 5. **Live config** — on a `workspace/didChangeWatchedFiles` event for the config path, the server re-resolves the input set and re-publishes across open documents, so adding/removing an input takes effect without a restart. Works whether or not the config file is open in the editor. The `ParseDiagnostic → LSP diagnostic` mapping in [`diagnostic-mapping.ts`](packages/1-framework/3-tooling/language-server/src/diagnostic-mapping.ts) is a pure transform with no `vscode-languageserver` import: the parser already emits zero-based, LSP-shaped ranges, so ranges pass through unchanged and the connection layer adapts severity at the boundary. ## Behavior changes & evidence - **New `prisma-next lsp` command** launches a PSL language server over stdio. Impl: [`cli/src/commands/lsp.ts`](packages/1-framework/3-tooling/cli/src/commands/lsp.ts), [`language-server/src/start-server.ts`](packages/1-framework/3-tooling/language-server/src/start-server.ts). Evidence: [`cli/test/commands/lsp.test.ts`](packages/1-framework/3-tooling/cli/test/commands/lsp.test.ts) and the live handshake (see Testing performed). - **PSL diagnostics for open schema documents**, config-gated and sourced from the parser. Impl: [`language-server/src/server.ts`](packages/1-framework/3-tooling/language-server/src/server.ts), [`language-server/src/document-diagnostics.ts`](packages/1-framework/3-tooling/language-server/src/document-diagnostics.ts), [`language-server/src/schema-inputs.ts`](packages/1-framework/3-tooling/language-server/src/schema-inputs.ts). Evidence: [`test/server.test.ts`](packages/1-framework/3-tooling/language-server/test/server.test.ts), [`test/document-diagnostics.test.ts`](packages/1-framework/3-tooling/language-server/test/document-diagnostics.test.ts). - **Live re-resolution on config change**, without restart. Impl: [`language-server/src/server.ts`](packages/1-framework/3-tooling/language-server/src/server.ts), [`language-server/src/config-resolution.ts`](packages/1-framework/3-tooling/language-server/src/config-resolution.ts). Evidence: the config-watching cases in [`test/server.test.ts`](packages/1-framework/3-tooling/language-server/test/server.test.ts), [`test/config-resolution.test.ts`](packages/1-framework/3-tooling/language-server/test/config-resolution.test.ts). - **Config loading moved to `@prisma-next/config-loader`**; CLI consumers re-pointed. Impl: [`config-loader/src/load.ts`](packages/1-framework/3-tooling/config-loader/src/load.ts), CLI wrapper + `config-path-validation.ts` deleted. Evidence: [`config-loader/test/load.test.ts`](packages/1-framework/3-tooling/config-loader/test/load.test.ts), [`config-loader/test/to-structured-config-error.test.ts`](packages/1-framework/3-tooling/config-loader/test/to-structured-config-error.test.ts), and the unchanged CLI/integration suites. ## Notes for the reviewer - **`@prisma-next/config-loader` is the one substantive new package besides the language server.** It sits at the tooling layer (peer of the CLI and emitter), which is why it may import `@prisma-next/emitter`, `@prisma-next/errors`, and `@prisma-next/config` directly. `loadConfig` keeps the CLI's existing error codes (`PN-CLI-4001`/`4009`/…), so nothing user-facing changes for CLI consumers. - **The language server reads those error codes** (`4001`/`4009`) to decide graceful degradation rather than catching typed error classes. The codes are stable, documented constants (asserted in `@prisma-next/errors` tests), so this coupling is to a public contract, not an internal detail. - **`@prisma-next/config` lost its `c12` dependency** and its config-loading exports; it is back to types + validation + path-finalising. The path-finalising still takes the emitter collision check as a hook so `@prisma-next/config` keeps no edge to the emitter. - **`psl-parser` barrel gains a `parse` export.** The CST `parse` wasn't exported from any public barrel (only `parsePslDocument` was); [`psl-parser/src/exports/parser.ts`](packages/1-framework/2-authoring/psl-parser/src/exports/parser.ts) now exports it plus its `ParseDiagnostic`/`Range` types. `parsePslDocument` is untouched. - **Pre-existing CLI test flakes, NOT from this PR.** `cli/test/version.test.ts`, `removed-verb-redirects.test.ts`, and `migration-list-json-golden` fail on a hard-coded 500ms CLI-spawn timeout (a cold spawn measures ~1.2s on a dev machine); they fail identically on clean `main` with this branch's changes stashed. There is also a committed bare-`as` lint item in `cli/src/commands/init/hygiene-package-scripts.ts` that predates and is untouched here. If CI's runner is slow, those spawn tests may need a longer timeout — flagging so the red isn't mis-attributed. - **Project artefacts on disk.** `projects/lsp-scaffold/{spec,plan}.md` are included for review and removed at project close-out (the ADR recording the "version-matched CLI subcommand, one server per project" decision lands in `docs/` then). ## Compatibility / migration / risk - **No public API or behaviour change for existing CLI commands.** Config loading moved packages and the CLI wrapper was deleted, but `loadConfig`'s signature, behaviour, and thrown error codes are preserved. The additive public surface is the new `lsp` command, the `@prisma-next/config-loader` package, and the new `@prisma-next/psl-parser/parser` `parse` export. ## Testing performed Run on final HEAD: - `pnpm install --frozen-lockfile` — clean (lockfile and manifests agree). - `pnpm lint:deps` — clean (no dependency violations; the new package's imports are legal). - `pnpm --filter @prisma-next/config-loader test` — passes. - `pnpm --filter @prisma-next/language-server test` — passes (incl. config-watching + graceful-degradation cases). - `pnpm --filter @prisma-next/config test` — passes. - `pnpm --filter @prisma-next/psl-parser test` — passes. - `pnpm --filter @prisma-next/integration-tests test cli.db-verify` — passes (CLI structured-error behaviour preserved end-to-end). - `pnpm --filter @prisma-next/cli typecheck && build` — clean; `pnpm --filter prisma-next lint` (facade dependency sync) — clean. - Manual: scripted LSP `initialize` handshake against the built CLI (`prisma-next lsp --stdio`) returns `textDocumentSync: 2`. - Ruled pre-existing (reproduced on clean `main`): the CLI 500ms spawn-timeout flakes noted above. ## Skill update n/a — internal foundation work. No user-facing skill surface changes here; the `prisma-next lsp` command is documented in the package README and the project spec. A user-facing skill / docs update for the LSP will follow with the editor-extension work. ## Checklist - [x] All commits are signed off (`git commit -s`). - [x] I read CONTRIBUTING.md and the change is scoped to one logical concern. - [x] Tests are updated. - [x] The PR title is in `TML-NNNN: <sentence-case title>` form. - [x] The **Skill update** section is filled in. ## Alternatives considered - **Separate `prisma-next-lsp` binary** instead of a subcommand — rejected: a subcommand is version-matched to the project's own `@prisma-next` by construction and avoids a second package to keep pinned together. (`deno lsp` is the precedent.) - **Server inline in `@prisma-next/cli`** instead of its own package — rejected: a dedicated package keeps `vscode-languageserver`'s footprint off the core CLI surface and gives later LSP features a clean home. - **Keeping config loading in `@prisma-next/cli` (or moving it into `@prisma-next/config`)** — rejected: the CLI can't be imported by the language server (dependency rules), and `@prisma-next/config` sits below the tooling layer, so it can't reach the emitter or the CLI error factories the loader needs. A dedicated tooling-layer `@prisma-next/config-loader` package is the home that lets both the CLI and the language server share one `loadConfig`. - **Two load functions (structured errors for the CLI, typed errors for the language server)** — rejected: one `loadConfig` throwing structured errors, with the language server reading the stable error codes, keeps a single public entry point and avoids duplicating the loader's surface. - **Diagnose every configured input eagerly / watch schema files** — out of scope: diagnostics are parse-only, so a closed input's content has no bearing on any open document; only the config file is watched. Eager whole-schema diagnosis is a nameable later capability. - **Source diagnostics from `parsePslDocument`** — rejected: it's legacy and slated for removal; the CST `parse` is what `format` already consumes, so the language server and the formatter agree on what a diagnostic is. --------- Signed-off-by: Serhii Tatarintsev <tatarintsev@prisma.io>
1 parent c278ee2 commit a049b56

109 files changed

Lines changed: 2757 additions & 633 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

architecture.config.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,12 +114,24 @@
114114
"layer": "tooling",
115115
"plane": "migration"
116116
},
117+
{
118+
"glob": "packages/1-framework/3-tooling/config-loader/**",
119+
"domain": "framework",
120+
"layer": "tooling",
121+
"plane": "migration"
122+
},
117123
{
118124
"glob": "packages/1-framework/3-tooling/emitter/**",
119125
"domain": "framework",
120126
"layer": "tooling",
121127
"plane": "migration"
122128
},
129+
{
130+
"glob": "packages/1-framework/3-tooling/language-server/**",
131+
"domain": "framework",
132+
"layer": "tooling",
133+
"plane": "migration"
134+
},
123135
{
124136
"glob": "packages/1-framework/3-tooling/eslint-plugin/**",
125137
"domain": "framework",

packages/1-framework/1-core/config/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ This package owns the shared config contract used by tooling and authoring packa
2525

2626
## Non-responsibilities
2727

28-
- Config file discovery/loading (`c12`, file I/O) - handled by `@prisma-next/cli`
28+
- Config file discovery/loading (`c12`, file I/O) - handled by `@prisma-next/config-loader`
2929
- CLI error envelope formatting and rendering - handled by CLI/errors package error utilities
3030
- Control-plane migration operations and runtime actions
3131

@@ -54,5 +54,5 @@ validateConfig(config);
5454
Declare `source.inputs` only for source files that are not already covered by the config module
5555
graph, such as PSL schema paths or TypeScript contract paths passed as strings. Do not include
5656
emitted artifact paths derived from `contract.output` (for example `contract.json` or the
57-
colocated `contract.d.ts`); the CLI loader resolves and validates those paths before emit/watch
58-
commands run. Tooling should always treat the config module graph as watched by default.
57+
colocated `contract.d.ts`); `@prisma-next/config-loader` resolves and validates those paths
58+
before emit/watch commands run. Tooling should always treat the config module graph as watched by default.

packages/1-framework/1-core/config/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
"@prisma-next/contract": "workspace:0.14.0",
2020
"@prisma-next/framework-components": "workspace:0.14.0",
2121
"@prisma-next/utils": "workspace:0.14.0",
22-
"arktype": "^2.2.0"
22+
"arktype": "^2.2.0",
23+
"pathe": "^2.0.3"
2324
},
2425
"devDependencies": {
2526
"@prisma-next/tsconfig": "workspace:0.14.0",
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { ConfigValidationError } from '../src/errors';
3+
4+
describe('ConfigValidationError', () => {
5+
it('builds a default message from the field when why is omitted', () => {
6+
const error = new ConfigValidationError('contract');
7+
8+
expect(error).toBeInstanceOf(Error);
9+
expect(error.name).toBe('ConfigValidationError');
10+
expect(error.message).toBe('Config must have a "contract" field');
11+
expect(error.field).toBe('contract');
12+
expect(error.why).toBe('Config must have a "contract" field');
13+
});
14+
15+
it('uses the explicit why for both message and why when provided', () => {
16+
const error = new ConfigValidationError('contract.output', 'output collides with input');
17+
18+
expect(error.message).toBe('output collides with input');
19+
expect(error.field).toBe('contract.output');
20+
expect(error.why).toBe('output collides with input');
21+
});
22+
});

packages/1-framework/2-authoring/psl-parser/package.json

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,26 @@
4040
],
4141
"types": "./dist/index.d.mts",
4242
"exports": {
43-
".": "./dist/index.mjs",
44-
"./parser": "./dist/parser.mjs",
45-
"./format": "./dist/format.mjs",
46-
"./syntax": "./dist/syntax.mjs",
47-
"./tokenizer": "./dist/tokenizer.mjs",
43+
".": {
44+
"types": "./dist/index.d.mts",
45+
"import": "./dist/index.mjs"
46+
},
47+
"./format": {
48+
"types": "./dist/format.d.mts",
49+
"import": "./dist/format.mjs"
50+
},
51+
"./parser": {
52+
"types": "./dist/parser.d.mts",
53+
"import": "./dist/parser.mjs"
54+
},
55+
"./syntax": {
56+
"types": "./dist/syntax.d.mts",
57+
"import": "./dist/syntax.mjs"
58+
},
59+
"./tokenizer": {
60+
"types": "./dist/tokenizer.d.mts",
61+
"import": "./dist/tokenizer.mjs"
62+
},
4863
"./package.json": "./package.json"
4964
},
5065
"engines": {
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
1+
export type { ParseDiagnostic, ParseResult } from '../parse';
2+
export { parse } from '../parse';
13
export { parsePslDocument } from '../parser';
4+
export type { Position, Range } from '../source-file';

packages/1-framework/3-tooling/cli/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
> documented programmatic-API import target. Authors of build integrations,
99
> extension packs, and advanced config wiring import from
1010
> `@prisma-next/cli/config-types`, `@prisma-next/cli/control-api`,
11-
> `@prisma-next/cli/commands/*`, and `@prisma-next/cli/config-loader`. These
11+
> `@prisma-next/cli/commands/*`, and `@prisma-next/config-loader`. These
1212
> subpaths are less stable than the facade packages
1313
> (`@prisma-next/postgres/config`, `@prisma-next/mongo/config`); prefer those
1414
> for application-level config.
@@ -1470,7 +1470,7 @@ The CLI package exports several subpaths for different use cases:
14701470
- **`@prisma-next/cli/commands/migration-show`**: Exports `createMigrationShowCommand`
14711471
- **`@prisma-next/cli/commands/migration-status`**: Exports `createMigrationStatusCommand`
14721472
- **`@prisma-next/cli/commands/migrate`**: Exports `createMigrateCommand`
1473-
- **`@prisma-next/cli/config-loader`**: Exports `loadConfig` function
1473+
- **`@prisma-next/config-loader`**: Exports `loadConfig`
14741474

14751475
**Important**: `loadContractFromTs` is exported from the main package (`@prisma-next/cli`). See `.cursor/rules/cli-package-exports.mdc` for import patterns.
14761476

packages/1-framework/3-tooling/cli/package.json

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,18 @@
2222
"dependencies": {
2323
"@clack/prompts": "^1.4.0",
2424
"@prisma-next/config": "workspace:0.14.0",
25+
"@prisma-next/config-loader": "workspace:0.14.0",
2526
"@prisma-next/contract": "workspace:0.14.0",
2627
"@prisma-next/emitter": "workspace:0.14.0",
2728
"@prisma-next/errors": "workspace:0.14.0",
2829
"@prisma-next/framework-components": "workspace:0.14.0",
30+
"@prisma-next/language-server": "workspace:0.14.0",
2931
"@prisma-next/migration-tools": "workspace:0.14.0",
3032
"@prisma-next/psl-parser": "workspace:0.14.0",
3133
"@prisma-next/psl-printer": "workspace:0.14.0",
3234
"@prisma-next/cli-telemetry": "workspace:0.14.0",
3335
"@prisma-next/utils": "workspace:0.14.0",
3436
"arktype": "^2.2.0",
35-
"c12": "^3.3.4",
3637
"ci-info": "^4.3.1",
3738
"clipanion": "4.0.0-rc.4",
3839
"closest-match": "^1.3.3",
@@ -157,10 +158,6 @@
157158
"types": "./dist/commands/telemetry/index.d.mts",
158159
"import": "./dist/commands/telemetry/index.mjs"
159160
},
160-
"./config-loader": {
161-
"types": "./dist/config-loader.d.mts",
162-
"import": "./dist/config-loader.mjs"
163-
},
164161
"./migration-cli": {
165162
"types": "./dist/migration-cli.d.mts",
166163
"import": "./dist/migration-cli.mjs"

packages/1-framework/3-tooling/cli/src/cli.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { createDbSignCommand } from './commands/db-sign';
1414
import { createDbUpdateCommand } from './commands/db-update';
1515
import { createDbVerifyCommand } from './commands/db-verify';
1616
import { createFormatCommand } from './commands/format';
17+
import { createLspCommand } from './commands/lsp';
1718
import { createMigrateCommand } from './commands/migrate';
1819
import { createMigrationCheckCommand } from './commands/migration-check';
1920
import { createMigrationGraphCommand } from './commands/migration-graph';
@@ -312,6 +313,7 @@ const telemetryCommand = createTelemetryCommand();
312313
const initCommand = createInitCommand();
313314

314315
const formatCommand = createFormatCommand();
316+
const lspCommand = createLspCommand();
315317

316318
// Register top-level commands in the order the spec's intended-surface
317319
// diagram lists them: verbs (init, migrate) first, then subject
@@ -321,6 +323,7 @@ const formatCommand = createFormatCommand();
321323
program.addCommand(initCommand);
322324
program.addCommand(migrateCommand);
323325
program.addCommand(formatCommand);
326+
program.addCommand(lspCommand);
324327
program.addCommand(contractCommand);
325328
program.addCommand(dbCommand);
326329
program.addCommand(migrationCommand);

packages/1-framework/3-tooling/cli/src/commands/contract-emit.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1+
import { loadConfig } from '@prisma-next/config-loader';
12
import { getEmittedArtifactPaths } from '@prisma-next/emitter';
23
import { errorContractConfigMissing } from '@prisma-next/errors/control';
34
import { ifDefined } from '@prisma-next/utils/defined';
45
import { notOk, ok, type Result } from '@prisma-next/utils/result';
56
import { Command } from 'commander';
67
import { dirname, join, relative, resolve } from 'pathe';
7-
import { loadConfig } from '../config-loader';
88
import { executeContractEmit } from '../control-api/operations/contract-emit';
99
import type { ContractEmitResult } from '../control-api/types';
1010
import { CliStructuredError, errorUnexpected } from '../utils/cli-errors';

0 commit comments

Comments
 (0)