diff --git a/apps/lsp-playground/README.md b/apps/lsp-playground/README.md index daa14e9e01..bbf1b6df3b 100644 --- a/apps/lsp-playground/README.md +++ b/apps/lsp-playground/README.md @@ -41,8 +41,8 @@ Monaco editor + VS Code API shim --LSP/WebSocket--> ws bridge --spawn+stdio-- ``` - `src/bridge.ts` — `ws` + `vscode-ws-jsonrpc/server` (`createServerProcess` + `forward`), adapted from the TypeFox example (MIT). Each browser WebSocket connection spawns `node lsp --stdio` and forwards JSON-RPC between the browser and the language server process. -- `src/cli.ts` — arg parsing, config resolution, runtime module generation, and startup for the shared HTTP server that hosts Vite plus the LSP WebSocket bridge. -- `src/client/main.ts` — Monaco editor setup via `EditorApp`, VS Code API service overrides, and `LanguageClientWrapper` startup for the `prisma` language id. +- `src/cli.ts` — arg parsing, config resolution, startup for the shared HTTP server that hosts Vite plus the LSP WebSocket bridge, and serving launch-time client config as same-origin JSON at `/__psl_playground_runtime.json` without rewriting tracked source files. +- `src/client/main.ts` — Monaco editor setup via `EditorApp`, VS Code API service overrides, runtime config fetch/validation, and `LanguageClientWrapper` startup for the `prisma` language id. ## Semantic tokens diff --git a/apps/lsp-playground/src/cli.ts b/apps/lsp-playground/src/cli.ts index f86d392149..efbc3e85e9 100644 --- a/apps/lsp-playground/src/cli.ts +++ b/apps/lsp-playground/src/cli.ts @@ -11,6 +11,27 @@ import { findNearestConfig } from './find-config'; const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url))); const PORT = 5295; const LSP_PATH = '/psl'; +const RUNTIME_CONFIG_PATH = '/__psl_playground_runtime.json'; +const REQUEST_URL_BASE = 'http://localhost/'; + +interface RuntimeConfig { + readonly wsPath: string; + readonly documentUri: string; + readonly rootUri: string; + readonly schemaPath: string; + readonly schemaText: string; +} + +function requestPathname(requestUrl: string | undefined): string | undefined { + if (requestUrl === undefined) { + return undefined; + } + try { + return new URL(requestUrl, REQUEST_URL_BASE).pathname; + } catch { + return undefined; + } +} async function fileExists(path: string): Promise { try { @@ -132,22 +153,13 @@ async function main(): Promise { const documentUri = pathToFileURL(schemaPath).toString(); const rootUri = pathToFileURL(dirname(configPath)).toString(); - // Hand the browser client its runtime values via a generated module (rather - // than Vite `define`, whose bare-identifier substitution is unreliable in the - // programmatic dev-server path). The WS URL is relative so the editor and the - // LSP bridge share this single origin/port. - const runtimeModule = resolve(PACKAGE_ROOT, 'src/client/runtime.ts'); - await writeFile( - runtimeModule, - `// Generated by psl-playground at launch. Do not edit. -export const wsPath = ${JSON.stringify(LSP_PATH)}; -export const documentUri = ${JSON.stringify(documentUri)}; -export const rootUri = ${JSON.stringify(rootUri)}; -export const schemaPath = ${JSON.stringify(schemaPath)}; -export const schemaText = ${JSON.stringify(schemaText)}; -`, - 'utf8', - ); + const runtimeConfig: RuntimeConfig = { + wsPath: LSP_PATH, + documentUri, + rootUri, + schemaPath, + schemaText, + }; // One HTTP server hosts both the editor (Vite, in middleware mode) and the // LSP WebSocket bridge (on LSP_PATH). Vite's HMR WebSocket is bound to the @@ -192,7 +204,44 @@ export const schemaText = ${JSON.stringify(schemaText)}; })(), }, }); - httpServer.on('request', viteServer.middlewares); + httpServer.on( + 'request', + (request: nodeHttp.IncomingMessage, response: nodeHttp.ServerResponse) => { + const requestPath = requestPathname(request.url); + if (requestPath === undefined) { + response.statusCode = 400; + response.end('Bad Request'); + return; + } + if (requestPath === RUNTIME_CONFIG_PATH) { + if (request.method !== 'GET' && request.method !== 'HEAD') { + response.statusCode = 405; + response.setHeader('allow', 'GET, HEAD'); + response.end('Method Not Allowed'); + return; + } + response.statusCode = 200; + response.setHeader('content-type', 'application/json; charset=utf-8'); + response.setHeader('cache-control', 'no-store'); + response.end(request.method === 'HEAD' ? undefined : JSON.stringify(runtimeConfig)); + return; + } + + viteServer.middlewares(request, response, (error?: unknown) => { + if (response.writableEnded) { + return; + } + if (error !== undefined) { + console.error(error); + response.statusCode = 500; + response.end('Internal Server Error'); + return; + } + response.statusCode = 404; + response.end('Not Found'); + }); + }, + ); const stopBridge = attachBridge(httpServer, { cliEntry, path: LSP_PATH }); diff --git a/apps/lsp-playground/src/client/main.ts b/apps/lsp-playground/src/client/main.ts index f7bb7b8bcd..f02b90b9b9 100644 --- a/apps/lsp-playground/src/client/main.ts +++ b/apps/lsp-playground/src/client/main.ts @@ -20,9 +20,9 @@ import { } from 'monaco-languageclient/vscodeApiWrapper'; import { defineDefaultWorkerLoaders, useWorkerFactory } from 'monaco-languageclient/workerFactory'; import * as vscode from 'vscode'; -import { documentUri, rootUri, schemaPath, schemaText, wsPath } from './runtime'; const LANGUAGE_ID = 'prisma'; +const RUNTIME_CONFIG_PATH = '/__psl_playground_runtime.json'; const pslSemanticThemeExtension = { name: 'prisma-psl-semantic-theme-bridge', @@ -52,9 +52,41 @@ const pslSemanticThemeExtension = { registerExtension(pslSemanticThemeExtension, undefined, { system: true }); -const pathEl = document.getElementById('schema-path'); -if (pathEl !== null) { - pathEl.textContent = schemaPath; +interface RuntimeConfig { + readonly wsPath: string; + readonly documentUri: string; + readonly rootUri: string; + readonly schemaPath: string; + readonly schemaText: string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isRuntimeConfig(value: unknown): value is RuntimeConfig { + return ( + isRecord(value) && + typeof value['wsPath'] === 'string' && + typeof value['documentUri'] === 'string' && + typeof value['rootUri'] === 'string' && + typeof value['schemaPath'] === 'string' && + typeof value['schemaText'] === 'string' + ); +} + +async function loadRuntimeConfig(): Promise { + const response = await fetch(RUNTIME_CONFIG_PATH, { cache: 'no-store' }); + if (!response.ok) { + throw new Error( + `Failed to load playground runtime config: ${response.status} ${response.statusText}`, + ); + } + const value: unknown = await response.json(); + if (!isRuntimeConfig(value)) { + throw new Error('Invalid playground runtime config'); + } + return value; } function configureWorkerFactory(logger?: ILogger): void { @@ -64,13 +96,20 @@ function configureWorkerFactory(logger?: ILogger): void { useWorkerFactory(config); } -function buildWebSocketUrl(): string { +function buildWebSocketUrl(wsPath: string): string { const host = `${window.location.host}${wsPath}`; // nosemgrep: javascript.lang.security.detect-insecure-websocket.detect-insecure-websocket return window.location.protocol === 'https:' ? `wss://${host}` : `ws://${host}`; } async function main(): Promise { + const runtimeConfig = await loadRuntimeConfig(); + + const pathEl = document.getElementById('schema-path'); + if (pathEl !== null) { + pathEl.textContent = runtimeConfig.schemaPath; + } + const htmlContainer = document.getElementById('editor'); if (htmlContainer === null) { throw new Error('#editor mount point not found'); @@ -81,9 +120,9 @@ async function main(): Promise { throw new Error('#format-document button not found'); } - const fileUri = vscode.Uri.parse(documentUri); + const fileUri = vscode.Uri.parse(runtimeConfig.documentUri); const fileSystemProvider = new RegisteredFileSystemProvider(false); - fileSystemProvider.registerFile(new RegisteredMemoryFile(fileUri, schemaText)); + fileSystemProvider.registerFile(new RegisteredMemoryFile(fileUri, runtimeConfig.schemaText)); registerFileSystemOverlay(1, fileSystemProvider); const vscodeApiConfig: MonacoVscodeApiConfig = { @@ -111,7 +150,7 @@ async function main(): Promise { }, }; - const wsUrl = buildWebSocketUrl(); + const wsUrl = buildWebSocketUrl(runtimeConfig.wsPath); const languageClientConfig: LanguageClientConfig = { languageId: LANGUAGE_ID, connection: { @@ -133,7 +172,7 @@ async function main(): Promise { workspaceFolder: { index: 0, name: 'workspace', - uri: vscode.Uri.parse(rootUri), + uri: vscode.Uri.parse(runtimeConfig.rootUri), }, }, }; @@ -141,7 +180,7 @@ async function main(): Promise { const editorAppConfig: EditorAppConfig = { codeResources: { modified: { - text: schemaText, + text: runtimeConfig.schemaText, uri: fileUri.path, }, }, diff --git a/apps/lsp-playground/src/client/runtime.ts b/apps/lsp-playground/src/client/runtime.ts deleted file mode 100644 index 55ef45fba3..0000000000 --- a/apps/lsp-playground/src/client/runtime.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Placeholder, committed so the client typechecks in a fresh checkout. -// psl-playground overwrites this at launch with the resolved document URI, -// workspace root, schema path/text, and LSP WebSocket path. Do not edit. -export const wsPath = '/psl'; -export const documentUri = ''; -export const rootUri = ''; -export const schemaPath = ''; -export const schemaText = ''; diff --git a/packages/1-framework/2-authoring/psl-parser/src/exports/syntax.ts b/packages/1-framework/2-authoring/psl-parser/src/exports/syntax.ts index 204dcc2fae..8c6b2ade3f 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/exports/syntax.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/exports/syntax.ts @@ -41,12 +41,26 @@ export { export { IdentifierAst } from '../syntax/ast/identifier'; export { QualifiedNameAst } from '../syntax/ast/qualified-name'; export { TypeAnnotationAst } from '../syntax/ast/type-annotation'; -export type { AstNode } from '../syntax/ast-helpers'; -export { filterChildren, findChildToken, findFirstChild, printSyntax } from '../syntax/ast-helpers'; +export type { AstNode, BracedBlock } from '../syntax/ast-helpers'; +export { + any, + filterChildren, + findChildToken, + findFirstChild, + printSyntax, +} from '../syntax/ast-helpers'; export type { GreenElement, GreenNode, GreenToken } from '../syntax/green'; export { greenNode, greenToken } from '../syntax/green'; export { GreenNodeBuilder } from '../syntax/green-builder'; +// Navigation helpers +export type { Direction } from '../syntax/navigation'; +export { + isTrivia, + isTriviaKind, + nonTriviaSibling, + skipTriviaToken, +} from '../syntax/navigation'; // Red layer -export type { SyntaxElement, SyntaxToken } from '../syntax/red'; -export { createSyntaxTree, SyntaxNode } from '../syntax/red'; +export type { SyntaxElement } from '../syntax/red'; +export { createSyntaxTree, SyntaxNode, SyntaxToken, TokenAtOffset } from '../syntax/red'; export type { SyntaxKind } from '../syntax/syntax-kind'; diff --git a/packages/1-framework/2-authoring/psl-parser/src/parse.ts b/packages/1-framework/2-authoring/psl-parser/src/parse.ts index 4e6bf50f1c..4b9ae1ee22 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/parse.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/parse.ts @@ -399,14 +399,24 @@ function parseParenArgs(cursor: Cursor): void { } } -export function parseAttributeArg(cursor: Cursor): GreenNode { +export function parseAttributeArg(cursor: Cursor): void { + const kind = cursor.peekKind(); + if ( + kind !== 'Ident' && + kind !== 'StringLiteral' && + kind !== 'NumberLiteral' && + kind !== 'LBracket' && + kind !== 'LBrace' + ) { + return; + } cursor.startNode('AttributeArg'); if (cursor.peekKind() === 'Ident' && cursor.peekKind(1) === 'Colon') { parseIdentifier(cursor); cursor.bump(); } parseArgValue(cursor); - return cursor.finishNode(); + cursor.finishNode(); } function parseArgValue(cursor: Cursor): void { @@ -435,8 +445,16 @@ export function parseAttribute(cursor: Cursor): GreenNode { return cursor.finishNode(); } -/** A type annotation: `QualifiedName (argList)? ([])? (?)?`, e.g. `pgvector.Vector(1536)[]?`. */ -export function parseTypeAnnotation(cursor: Cursor): GreenNode { +/** + * A type annotation: `QualifiedName (argList)? ([])? (?)?`, e.g. + * `pgvector.Vector(1536)[]?`. When the field has no type, no node is emitted — + * a missing type is the absence of a `TypeAnnotation`, not a zero-width one. + */ +export function parseTypeAnnotation(cursor: Cursor): void { + const kind = cursor.peekKind(); + if (kind !== 'Ident' && kind !== 'LBracket' && kind !== 'Question') { + return; + } cursor.startNode('TypeAnnotation'); if (cursor.peekKind() === 'Ident') { parseQualifiedName(cursor); @@ -453,7 +471,7 @@ export function parseTypeAnnotation(cursor: Cursor): GreenNode { if (cursor.peekKind() === 'Question') { cursor.bump(); } - return cursor.finishNode(); + cursor.finishNode(); } type MemberParser = (cursor: Cursor) => void; diff --git a/packages/1-framework/2-authoring/psl-parser/src/syntax/ast-helpers.ts b/packages/1-framework/2-authoring/psl-parser/src/syntax/ast-helpers.ts index 5e49586ee5..ad4eadb832 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/syntax/ast-helpers.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/syntax/ast-helpers.ts @@ -5,6 +5,11 @@ export interface AstNode { readonly syntax: SyntaxNode; } +export interface BracedBlock extends AstNode { + lbrace(): SyntaxToken | undefined; + rbrace(): SyntaxToken | undefined; +} + export function findChildToken(node: SyntaxNode, kind: TokenKind): SyntaxToken | undefined { for (const child of node.children()) { if (!(child instanceof SyntaxNode) && child.kind === kind) { @@ -35,6 +40,25 @@ export function* filterChildren( } } +type CastTarget = C extends (node: SyntaxNode) => infer R ? Exclude : never; + +export function any unknown)[]>( + ...casts: Casts +): (node: SyntaxNode) => CastTarget | undefined; +export function any( + ...casts: ReadonlyArray<(node: SyntaxNode) => unknown> +): (node: SyntaxNode) => unknown { + return (node) => { + for (const cast of casts) { + const result = cast(node); + if (result !== undefined) { + return result; + } + } + return undefined; + }; +} + /** * Raw source text of a CST node, verbatim (quotes and brackets preserved). For * the decoded value of a string literal, decode it instead. diff --git a/packages/1-framework/2-authoring/psl-parser/src/syntax/ast/declarations.ts b/packages/1-framework/2-authoring/psl-parser/src/syntax/ast/declarations.ts index 6f919fddd7..58e890ae37 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/syntax/ast/declarations.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/syntax/ast/declarations.ts @@ -1,4 +1,4 @@ -import type { AstNode } from '../ast-helpers'; +import type { AstNode, BracedBlock } from '../ast-helpers'; import { filterChildren, findChildToken, findFirstChild } from '../ast-helpers'; import { SyntaxNode, type SyntaxToken } from '../red'; import { FieldAttributeAst, ModelAttributeAst } from './attributes'; @@ -50,7 +50,7 @@ export class DocumentAst implements AstNode { } } -export class ModelDeclarationAst implements AstNode { +export class ModelDeclarationAst implements BracedBlock { readonly syntax: SyntaxNode; constructor(syntax: SyntaxNode) { @@ -93,7 +93,7 @@ export class ModelDeclarationAst implements AstNode { } } -export class CompositeTypeDeclarationAst implements AstNode { +export class CompositeTypeDeclarationAst implements BracedBlock { readonly syntax: SyntaxNode; constructor(syntax: SyntaxNode) { @@ -138,7 +138,7 @@ export class CompositeTypeDeclarationAst implements AstNode { } } -export class NamespaceDeclarationAst implements AstNode { +export class NamespaceDeclarationAst implements BracedBlock { readonly syntax: SyntaxNode; constructor(syntax: SyntaxNode) { @@ -170,7 +170,7 @@ export class NamespaceDeclarationAst implements AstNode { } } -export class TypesBlockAst implements AstNode { +export class TypesBlockAst implements BracedBlock { readonly syntax: SyntaxNode; constructor(syntax: SyntaxNode) { @@ -198,7 +198,7 @@ export class TypesBlockAst implements AstNode { } } -export class GenericBlockDeclarationAst implements AstNode { +export class GenericBlockDeclarationAst implements BracedBlock { readonly syntax: SyntaxNode; constructor(syntax: SyntaxNode) { diff --git a/packages/1-framework/2-authoring/psl-parser/src/syntax/ast/qualified-name.ts b/packages/1-framework/2-authoring/psl-parser/src/syntax/ast/qualified-name.ts index 6499542e36..7a908a1190 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/syntax/ast/qualified-name.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/syntax/ast/qualified-name.ts @@ -11,22 +11,20 @@ export class QualifiedNameAst implements AstNode { this.syntax = syntax; } - #lastSegment(): IdentifierAst | undefined { - let last: IdentifierAst | undefined; + #segmentBefore(boundary: number): IdentifierAst | undefined { + let found: IdentifierAst | undefined; for (const segment of filterChildren(this.syntax, IdentifierAst.cast)) { - last = segment; + if (segment.syntax.offset >= boundary) break; + found = segment; } - return last; + return found; } - #penultimateSegment(): IdentifierAst | undefined { - let last: IdentifierAst | undefined; - let penultimate: IdentifierAst | undefined; + #segmentAfter(boundary: number): IdentifierAst | undefined { for (const segment of filterChildren(this.syntax, IdentifierAst.cast)) { - penultimate = last; - last = segment; + if (segment.syntax.offset > boundary) return segment; } - return penultimate; + return undefined; } #separatorCount(kind: 'Dot' | 'Colon'): number { @@ -46,17 +44,23 @@ export class QualifiedNameAst implements AstNode { } space(): IdentifierAst | undefined { - if (!this.colon()) return undefined; - return findFirstChild(this.syntax, IdentifierAst.cast); + const colon = this.colon(); + if (!colon) return undefined; + return this.#segmentBefore(colon.offset); } namespace(): IdentifierAst | undefined { - if (!this.dot()) return undefined; - return this.#penultimateSegment(); + const dot = this.dot(); + if (!dot) return undefined; + return this.#segmentBefore(dot.offset); } identifier(): IdentifierAst | undefined { - return this.#lastSegment(); + const dot = this.dot(); + if (dot) return this.#segmentAfter(dot.offset); + const colon = this.colon(); + if (colon) return this.#segmentAfter(colon.offset); + return findFirstChild(this.syntax, IdentifierAst.cast); } /** diff --git a/packages/1-framework/2-authoring/psl-parser/src/syntax/navigation.ts b/packages/1-framework/2-authoring/psl-parser/src/syntax/navigation.ts new file mode 100644 index 0000000000..e5ec705cd0 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/src/syntax/navigation.ts @@ -0,0 +1,55 @@ +import type { TokenKind } from '../tokenizer'; +import { type SyntaxElement, SyntaxToken } from './red'; + +/** Direction of a sibling/token walk. */ +export type Direction = 'next' | 'prev'; + +const TRIVIA_KINDS: ReadonlySet = new Set([ + 'Whitespace', + 'Newline', + 'Comment', +]); + +/** Whether a token kind is trivia (whitespace, newline, or comment). */ +export function isTriviaKind(kind: TokenKind): boolean { + return TRIVIA_KINDS.has(kind); +} + +/** Whether a token is trivia. */ +export function isTrivia(token: SyntaxToken): boolean { + return isTriviaKind(token.kind); +} + +/** + * The first non-trivia token at or beyond `token` in `direction`. Returns + * `token` itself when it is already significant. + */ +export function skipTriviaToken(token: SyntaxToken, direction: Direction): SyntaxToken | undefined { + let current: SyntaxToken | undefined = token; + while (current !== undefined && isTrivia(current)) { + current = direction === 'next' ? current.nextToken : current.prevToken; + } + return current; +} + +/** + * The nearest sibling of `element` (within the same parent) in `direction` that + * is not a trivia token. Nodes are always significant. + */ +export function nonTriviaSibling( + element: SyntaxElement, + direction: Direction, +): SyntaxElement | undefined { + let sibling = step(element, direction); + while (sibling !== undefined) { + if (!(sibling instanceof SyntaxToken) || !isTrivia(sibling)) { + return sibling; + } + sibling = step(sibling, direction); + } + return undefined; +} + +function step(element: SyntaxElement, direction: Direction): SyntaxElement | undefined { + return direction === 'next' ? element.nextSiblingOrToken : element.prevSiblingOrToken; +} diff --git a/packages/1-framework/2-authoring/psl-parser/src/syntax/red.ts b/packages/1-framework/2-authoring/psl-parser/src/syntax/red.ts index 9330920e66..19181cbf17 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/syntax/red.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/syntax/red.ts @@ -1,27 +1,154 @@ -import type { Token } from '../tokenizer'; -import type { GreenElement, GreenNode } from './green'; +import type { Token, TokenKind } from '../tokenizer'; +import type { GreenElement, GreenNode, GreenToken } from './green'; import type { SyntaxKind } from './syntax-kind'; /** * A token in the red tree. Unlike the green-layer {@link Token} (kind + text - * only), a red token also carries its absolute `offset` within the source, - * computed lazily as the tree is walked. + * only), a red token also carries its absolute `offset` within the source and a + * link back to its `parent` {@link SyntaxNode}, so a cursor anchored on a token + * can walk outward (parent, previous/next token, siblings) without re-scanning + * from the document root. */ -export interface SyntaxToken extends Token { +export class SyntaxToken implements Token { + readonly green: GreenToken; + readonly kind: TokenKind; + readonly text: string; readonly offset: number; + readonly parent: SyntaxNode; + /** Position within the parent's children, enabling O(1) sibling navigation without rescanning the green layer. */ + readonly index: number; + + constructor(green: GreenToken, offset: number, parent: SyntaxNode, index: number) { + this.green = green; + this.kind = green.kind; + this.text = green.text; + this.offset = offset; + this.parent = parent; + this.index = index; + } + + get textLength(): number { + return this.text.length; + } + + get endOffset(): number { + return this.offset + this.textLength; + } + + /** Whether `offset` falls within this token, inclusive of both ends. */ + isInside(offset: number): boolean { + return offset >= this.offset && offset <= this.endOffset; + } + + isOutside(offset: number): boolean { + return !this.isInside(offset); + } + + /** The sibling element immediately after this token within its parent. */ + get nextSiblingOrToken(): SyntaxElement | undefined { + return childAt(this.parent, this.index + 1); + } + + /** The sibling element immediately before this token within its parent. */ + get prevSiblingOrToken(): SyntaxElement | undefined { + return childAt(this.parent, this.index - 1); + } + + /** The next token in document order, crossing node boundaries. */ + get nextToken(): SyntaxToken | undefined { + for (let el = climbingNext(this); el !== undefined; el = climbingNext(el)) { + const token = firstToken(el); + if (token !== undefined) return token; + } + return undefined; + } + + /** The previous token in document order, crossing node boundaries. */ + get prevToken(): SyntaxToken | undefined { + for (let el = climbingPrev(this); el !== undefined; el = climbingPrev(el)) { + const token = lastToken(el); + if (token !== undefined) return token; + } + return undefined; + } } export type SyntaxElement = SyntaxNode | SyntaxToken; +/** + * The result of {@link SyntaxNode.tokenAtOffset}: an offset can fall outside + * every token (`none`), strictly inside a single token (`single`), or exactly on + * the seam between two adjacent tokens (`between`). `leftBiased` / `rightBiased` + * collapse the seam case to one side; for `single` both return the same token, + * for `none` both return `undefined`. + */ +type TokenAtOffsetState = + | { readonly kind: 'none' } + | { readonly kind: 'single'; readonly token: SyntaxToken } + | { readonly kind: 'between'; readonly left: SyntaxToken; readonly right: SyntaxToken }; + +export class TokenAtOffset { + readonly #state: TokenAtOffsetState; + + private constructor(state: TokenAtOffsetState) { + this.#state = state; + } + + static none(): TokenAtOffset { + return new TokenAtOffset({ kind: 'none' }); + } + + static single(token: SyntaxToken): TokenAtOffset { + return new TokenAtOffset({ kind: 'single', token }); + } + + static between(left: SyntaxToken, right: SyntaxToken): TokenAtOffset { + return new TokenAtOffset({ kind: 'between', left, right }); + } + + get isEmpty(): boolean { + return this.#state.kind === 'none'; + } + + get isBetween(): boolean { + return this.#state.kind === 'between'; + } + + leftBiased(): SyntaxToken | undefined { + switch (this.#state.kind) { + case 'none': + return undefined; + case 'single': + return this.#state.token; + case 'between': + return this.#state.left; + } + } + + rightBiased(): SyntaxToken | undefined { + switch (this.#state.kind) { + case 'none': + return undefined; + case 'single': + return this.#state.token; + case 'between': + return this.#state.right; + } + } +} + export class SyntaxNode { readonly green: GreenNode; readonly offset: number; readonly parent: SyntaxNode | undefined; + /** Position within the parent's children, enabling O(1) sibling navigation without rescanning the green layer. */ + readonly index: number; - constructor(green: GreenNode, offset: number, parent: SyntaxNode | undefined) { + constructor(green: GreenNode, offset: number, parent: SyntaxNode | undefined, index: number) { this.green = green; this.offset = offset; this.parent = parent; + this.index = index; } get kind(): SyntaxKind { @@ -32,6 +159,19 @@ export class SyntaxNode { return this.green.textLength; } + get endOffset(): number { + return this.offset + this.textLength; + } + + /** Whether `offset` falls within this node, inclusive of both ends. */ + isInside(offset: number): boolean { + return offset >= this.offset && offset <= this.endOffset; + } + + isOutside(offset: number): boolean { + return !this.isInside(offset); + } + get firstChild(): SyntaxElement | undefined { return childAt(this, 0); } @@ -43,44 +183,40 @@ export class SyntaxNode { } get nextSibling(): SyntaxElement | undefined { - if (!this.parent) return undefined; - const siblings = this.parent.green.children; - let offset = this.parent.offset; - let found = false; - for (const child of siblings) { - if (found) { - return wrapElement(child, offset, this.parent); - } - const childLen = elementTextLength(child); - if (child.type === 'node' && offset === this.offset && child === this.green) { - found = true; - } - offset += childLen; - } - return undefined; + return this.parent === undefined ? undefined : childAt(this.parent, this.index + 1); } get prevSibling(): SyntaxElement | undefined { - if (!this.parent) return undefined; - const siblings = this.parent.green.children; - let offset = this.parent.offset; - let prev: { green: GreenElement; offset: number } | undefined; - for (const child of siblings) { - if (child.type === 'node' && offset === this.offset && child === this.green) { - if (!prev) return undefined; - return wrapElement(prev.green, prev.offset, this.parent); - } - prev = { green: child, offset }; - offset += elementTextLength(child); - } - return undefined; + return this.parent === undefined ? undefined : childAt(this.parent, this.index - 1); + } + + /** The sibling element immediately after this node within its parent. */ + get nextSiblingOrToken(): SyntaxElement | undefined { + return this.nextSibling; + } + + /** The sibling element immediately before this node within its parent. */ + get prevSiblingOrToken(): SyntaxElement | undefined { + return this.prevSibling; + } + + /** The first token in this subtree (depth-first), or `undefined` if empty. */ + get firstToken(): SyntaxToken | undefined { + return firstToken(this); + } + + /** The last token in this subtree (depth-first), or `undefined` if empty. */ + get lastToken(): SyntaxToken | undefined { + return lastToken(this); } *children(): Iterable { let offset = this.offset; + let index = 0; for (const child of this.green.children) { - yield wrapElement(child, offset, this); + yield wrapElement(child, offset, this, index); offset += elementTextLength(child); + index++; } } @@ -98,6 +234,21 @@ export class SyntaxNode { } } + /** The nearest match, testing this node itself before walking its ancestors. */ + findAncestor(cast: (node: SyntaxNode) => T | undefined): T | undefined { + const self = cast(this); + if (self !== undefined) { + return self; + } + for (const ancestor of this.ancestors()) { + const result = cast(ancestor); + if (result !== undefined) { + return result; + } + } + return undefined; + } + *descendants(): Iterable { const stack: SyntaxElement[] = [this]; for (let el = stack.pop(); el !== undefined; el = stack.pop()) { @@ -116,23 +267,147 @@ export class SyntaxNode { *tokens(): Iterable { for (const el of this.descendants()) { - if (!(el instanceof SyntaxNode)) { + if (el instanceof SyntaxToken) { yield el; } } } + + /** + * The token(s) at `offset`. The between-two-tokens case (offset exactly on a + * token seam) is represented explicitly so callers can left/right bias. + */ + tokenAtOffset(offset: number): TokenAtOffset { + return tokenAtOffsetOf(this, offset); + } + + /** + * The smallest element fully containing the range `[start, end]`. At a seam + * (and for empty ranges) the left-hand element is preferred, matching + * {@link containsOffset}'s inclusive span. + */ + coveringElement(start: number, end: number): SyntaxElement { + let result: SyntaxElement = this; + for (;;) { + if (result instanceof SyntaxToken) return result; + let next: SyntaxElement | undefined; + for (const child of result.children()) { + if (containsRange(child, start, end)) { + next = child; + break; + } + } + if (next === undefined) return result; + result = next; + } + } } function elementTextLength(el: GreenElement): number { return el.type === 'token' ? el.text.length : el.textLength; } -function wrapElement(green: GreenElement, offset: number, parent: SyntaxNode): SyntaxElement { +function elementLength(el: SyntaxElement): number { + return el instanceof SyntaxToken ? el.text.length : el.textLength; +} + +/** + * Whether `el` contains `offset`. The span is inclusive on both ends so a seam + * offset touches both neighbours. + */ +function containsOffset(el: SyntaxElement, offset: number): boolean { + const start = el.offset; + const len = elementLength(el); + return offset >= start && offset <= start + len; +} + +function containsRange(el: SyntaxElement, start: number, end: number): boolean { + const elStart = el.offset; + const len = elementLength(el); + return elStart <= start && end <= elStart + len; +} + +function tokenAtOffsetOf(el: SyntaxElement, offset: number): TokenAtOffset { + if (el instanceof SyntaxToken) { + return TokenAtOffset.single(el); + } + let left: SyntaxElement | undefined; + let right: SyntaxElement | undefined; + for (const child of el.children()) { + if (!containsOffset(child, offset)) continue; + if (left === undefined) { + left = child; + } else { + right = child; + break; + } + } + if (left === undefined) return TokenAtOffset.none(); + if (right === undefined) return tokenAtOffsetOf(left, offset); + const leftToken = tokenAtOffsetOf(left, offset).rightBiased(); + const rightToken = tokenAtOffsetOf(right, offset).leftBiased(); + if (leftToken !== undefined && rightToken !== undefined) { + return TokenAtOffset.between(leftToken, rightToken); + } + if (leftToken !== undefined) return TokenAtOffset.single(leftToken); + if (rightToken !== undefined) return TokenAtOffset.single(rightToken); + return TokenAtOffset.none(); +} + +function firstToken(el: SyntaxElement): SyntaxToken | undefined { + if (el instanceof SyntaxToken) return el; + for (const child of el.children()) { + const token = firstToken(child); + if (token !== undefined) return token; + } + return undefined; +} + +function lastToken(el: SyntaxElement): SyntaxToken | undefined { + if (el instanceof SyntaxToken) return el; + const children = Array.from(el.children()); + for (let i = children.length - 1; i >= 0; i--) { + const child = children[i]; + if (child !== undefined) { + const token = lastToken(child); + if (token !== undefined) return token; + } + } + return undefined; +} + +function climbingNext(el: SyntaxElement): SyntaxElement | undefined { + let current: SyntaxElement = el; + for (;;) { + const parent = current.parent; + if (parent === undefined) return undefined; + const sibling = childAt(parent, current.index + 1); + if (sibling !== undefined) return sibling; + current = parent; + } +} + +function climbingPrev(el: SyntaxElement): SyntaxElement | undefined { + let current: SyntaxElement = el; + for (;;) { + const parent = current.parent; + if (parent === undefined) return undefined; + const sibling = childAt(parent, current.index - 1); + if (sibling !== undefined) return sibling; + current = parent; + } +} + +function wrapElement( + green: GreenElement, + offset: number, + parent: SyntaxNode, + index: number, +): SyntaxElement { if (green.type === 'token') { - const token: SyntaxToken = { kind: green.kind, text: green.text, offset }; - return token; + return new SyntaxToken(green, offset, parent, index); } - return new SyntaxNode(green, offset, parent); + return new SyntaxNode(green, offset, parent, index); } function childAt(node: SyntaxNode, index: number): SyntaxElement | undefined { @@ -146,9 +421,9 @@ function childAt(node: SyntaxNode, index: number): SyntaxElement | undefined { offset += elementTextLength(child); } } - return wrapElement(target, offset, node); + return wrapElement(target, offset, node, index); } export function createSyntaxTree(green: GreenNode): SyntaxNode { - return new SyntaxNode(green, 0, undefined); + return new SyntaxNode(green, 0, undefined, 0); } diff --git a/packages/1-framework/2-authoring/psl-parser/test/parse-leaf.test.ts b/packages/1-framework/2-authoring/psl-parser/test/parse-leaf.test.ts index 297d89ca7d..10772e9eb4 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/parse-leaf.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/parse-leaf.test.ts @@ -37,7 +37,7 @@ describe('offset tracking', () => { // start offset is only correct if every consumed token (the leading // segments and that trivia) advanced the running offset counter. const source = 'a.b\n.c'; - const { diagnostics, cursor } = parse(source, parseTypeAnnotation); + const { diagnostics, cursor } = parseTypeAnnotationTree(source); expect(diagnostics).toHaveLength(1); expect(diagnostics[0]!.code).toBe('PSL_INVALID_QUALIFIED_NAME'); @@ -104,6 +104,36 @@ function parse(source: string, run: (cursor: Cursor) => GreenNode) { return { node, diagnostics: cursor.diagnostics, cursor }; } +// `parseTypeAnnotation` returns void and emits no node for an empty type, so +// these well-formed cases are wrapped in a synthetic root to recover the +// emitted `TypeAnnotation` subtree. +function parseTypeAnnotationTree(source: string) { + const cursor = new Cursor(source); + cursor.startNode('Document'); + parseTypeAnnotation(cursor); + const root = cursor.finishNode(); + const node = root.children[0]; + if (node === undefined || node.type !== 'node') { + throw new Error('expected parseTypeAnnotation to emit a TypeAnnotation node'); + } + return { node, diagnostics: cursor.diagnostics, cursor }; +} + +// `parseAttributeArg` returns void and emits no node for an empty argument, so +// these content-bearing cases are wrapped in a synthetic root to recover the +// emitted `AttributeArg` subtree. +function parseAttributeArgTree(source: string) { + const cursor = new Cursor(source); + cursor.startNode('Document'); + parseAttributeArg(cursor); + const root = cursor.finishNode(); + const node = root.children[0]; + if (node === undefined || node.type !== 'node') { + throw new Error('expected parseAttributeArg to emit an AttributeArg node'); + } + return { node, diagnostics: cursor.diagnostics, cursor }; +} + describe('parseAttribute well-formed', () => { it('parses a simple field attribute', () => { const source = '@id'; @@ -182,7 +212,7 @@ describe('parseAttribute well-formed', () => { describe('parseAttributeArg well-formed', () => { it('parses a positional identifier argument', () => { const source = 'id'; - const { node, diagnostics } = parse(source, parseAttributeArg); + const { node, diagnostics } = parseAttributeArgTree(source); expect(printTree(node)).toMatchInlineSnapshot(` "AttributeArg @@ -195,7 +225,7 @@ describe('parseAttributeArg well-formed', () => { it('parses a named argument with a colon and array value', () => { const source = 'fields: [id]'; - const { node, diagnostics } = parse(source, parseAttributeArg); + const { node, diagnostics } = parseAttributeArgTree(source); expect(printTree(node)).toMatchInlineSnapshot(` "AttributeArg @@ -311,7 +341,7 @@ describe('parseExpression well-formed', () => { describe('parseTypeAnnotation well-formed', () => { it('parses a bare reference', () => { const source = 'String'; - const { node, diagnostics } = parse(source, parseTypeAnnotation); + const { node, diagnostics } = parseTypeAnnotationTree(source); expect(printTree(node)).toMatchInlineSnapshot(` "TypeAnnotation @@ -325,7 +355,7 @@ describe('parseTypeAnnotation well-formed', () => { it('parses a dot-qualified reference', () => { const source = 'auth.User'; - const { node, diagnostics } = parse(source, parseTypeAnnotation); + const { node, diagnostics } = parseTypeAnnotationTree(source); expect(printTree(node)).toMatchInlineSnapshot(` "TypeAnnotation @@ -342,7 +372,7 @@ describe('parseTypeAnnotation well-formed', () => { it('parses a colon-prefixed cross-space reference with namespace and optional suffix', () => { const source = 'supabase:auth.User?'; - const { node, diagnostics } = parse(source, parseTypeAnnotation); + const { node, diagnostics } = parseTypeAnnotationTree(source); expect(printTree(node)).toMatchInlineSnapshot(` "TypeAnnotation @@ -363,7 +393,7 @@ describe('parseTypeAnnotation well-formed', () => { it('parses a colon-prefixed reference without namespace', () => { const source = 'supabase:User'; - const { node, diagnostics } = parse(source, parseTypeAnnotation); + const { node, diagnostics } = parseTypeAnnotationTree(source); expect(printTree(node)).toMatchInlineSnapshot(` "TypeAnnotation @@ -380,7 +410,7 @@ describe('parseTypeAnnotation well-formed', () => { it('parses an inline constructor call', () => { const source = 'Vector(1536)'; - const { node, diagnostics } = parse(source, parseTypeAnnotation); + const { node, diagnostics } = parseTypeAnnotationTree(source); expect(printTree(node)).toMatchInlineSnapshot(` "TypeAnnotation @@ -400,7 +430,7 @@ describe('parseTypeAnnotation well-formed', () => { it('parses a namespace-qualified constructor call into a single FunctionCall chain', () => { const source = 'pgvector.Vector(1536)'; - const { node, diagnostics } = parse(source, parseTypeAnnotation); + const { node, diagnostics } = parseTypeAnnotationTree(source); expect(printTree(node)).toMatchInlineSnapshot(` "TypeAnnotation @@ -423,7 +453,7 @@ describe('parseTypeAnnotation well-formed', () => { it('parses a qualified constructor with a named argument and an optional suffix', () => { const source = 'pgvector.Vector(length: 1536)?'; - const { node, diagnostics } = parse(source, parseTypeAnnotation); + const { node, diagnostics } = parseTypeAnnotationTree(source); expect(printTree(node)).toMatchInlineSnapshot(` "TypeAnnotation @@ -451,7 +481,7 @@ describe('parseTypeAnnotation well-formed', () => { it('parses a list suffix', () => { const source = 'String[]'; - const { node, diagnostics } = parse(source, parseTypeAnnotation); + const { node, diagnostics } = parseTypeAnnotationTree(source); expect(printTree(node)).toMatchInlineSnapshot(` "TypeAnnotation @@ -469,7 +499,7 @@ describe('parseTypeAnnotation well-formed', () => { describe('parseTypeAnnotation fault tolerance', () => { it('flags triple-dot over-qualification but still yields a subtree that round-trips', () => { const source = 'a.b.c'; - const { node, diagnostics, cursor } = parse(source, parseTypeAnnotation); + const { node, diagnostics, cursor } = parseTypeAnnotationTree(source); expect(node.kind).toBe('TypeAnnotation'); expect(greenText(node)).toBe(source); @@ -486,7 +516,7 @@ describe('parseTypeAnnotation fault tolerance', () => { it('flags double-colon over-qualification but still yields a subtree', () => { const source = 'a:b:c'; - const { node, diagnostics, cursor } = parse(source, parseTypeAnnotation); + const { node, diagnostics, cursor } = parseTypeAnnotationTree(source); expect(node.kind).toBe('TypeAnnotation'); expect(greenText(node)).toBe(source); @@ -503,7 +533,7 @@ describe('parseTypeAnnotation fault tolerance', () => { it('flags a trailing dot with no following segment', () => { const source = 'Int.'; - const { node, diagnostics } = parse(source, parseTypeAnnotation); + const { node, diagnostics } = parseTypeAnnotationTree(source); expect(node.kind).toBe('TypeAnnotation'); expect(greenText(node)).toBe(source); @@ -514,7 +544,7 @@ describe('parseTypeAnnotation fault tolerance', () => { it('flags a trailing colon with no following segment', () => { const source = 'supabase:'; - const { node, diagnostics } = parse(source, parseTypeAnnotation); + const { node, diagnostics } = parseTypeAnnotationTree(source); expect(node.kind).toBe('TypeAnnotation'); expect(greenText(node)).toBe(source); @@ -599,7 +629,7 @@ describe('parseAttribute fault tolerance', () => { describe('argument-position object literal', () => { it('parses an object literal argument into an ObjectLiteralExpr queryable via fields()', () => { const source = '{ a: 1, b: "x" }'; - const { node, diagnostics } = parse(source, parseAttributeArg); + const { node, diagnostics } = parseAttributeArgTree(source); expect(node.kind).toBe('AttributeArg'); expect(greenText(node)).toBe(source); @@ -619,7 +649,7 @@ describe('argument-position object literal', () => { it('parses a nested object literal recursively, round-tripping losslessly', () => { const source = '{ a: { b: 1 } }'; - const { node, diagnostics } = parse(source, parseAttributeArg); + const { node, diagnostics } = parseAttributeArgTree(source); expect(greenText(node)).toBe(source); expect(diagnostics).toHaveLength(0); @@ -633,7 +663,7 @@ describe('argument-position object literal', () => { it('allows a trailing comma', () => { const source = '{ a: 1, }'; - const { node, diagnostics } = parse(source, parseAttributeArg); + const { node, diagnostics } = parseAttributeArgTree(source); expect(greenText(node)).toBe(source); expect(diagnostics).toHaveLength(0); @@ -646,7 +676,7 @@ describe('argument-position object literal', () => { it('reports a missing colon but still yields a best-effort node and round-trips', () => { const source = '{ a 1 }'; - const { node, diagnostics } = parse(source, parseAttributeArg); + const { node, diagnostics } = parseAttributeArgTree(source); expect(greenText(node)).toBe(source); expect(diagnostics).toHaveLength(1); expect(diagnostics[0]!.code).toBe('PSL_INVALID_OBJECT_LITERAL'); @@ -658,7 +688,7 @@ describe('argument-position object literal', () => { it('reports a missing value but still yields a best-effort node and round-trips', () => { const source = '{ a: }'; - const { node, diagnostics } = parse(source, parseAttributeArg); + const { node, diagnostics } = parseAttributeArgTree(source); expect(greenText(node)).toBe(source); expect(diagnostics).toHaveLength(1); expect(diagnostics[0]!.code).toBe('PSL_INVALID_OBJECT_LITERAL'); @@ -667,7 +697,7 @@ describe('argument-position object literal', () => { it('reports an unterminated object literal anchored on the opening brace', () => { const source = '{ a: 1'; - const { node, diagnostics, cursor } = parse(source, parseAttributeArg); + const { node, diagnostics, cursor } = parseAttributeArgTree(source); expect(greenText(node)).toBe(source); expect(diagnostics).toHaveLength(1); expect(diagnostics[0]!.code).toBe('PSL_INVALID_OBJECT_LITERAL'); @@ -682,7 +712,7 @@ describe('argument-position object literal', () => { it('accepts a string-literal key, exposing the unquoted name', () => { const source = '{ "k": 1 }'; - const { node, diagnostics } = parse(source, parseAttributeArg); + const { node, diagnostics } = parseAttributeArgTree(source); expect(greenText(node)).toBe(source); // round-trip holds, object terminated expect(diagnostics).toEqual([]); @@ -697,7 +727,7 @@ describe('argument-position object literal', () => { it('accepts a mix of identifier and string-literal keys with no diagnostics', () => { const source = '{ a: 1, "k": 2 }'; - const { node, diagnostics } = parse(source, parseAttributeArg); + const { node, diagnostics } = parseAttributeArgTree(source); expect(greenText(node)).toBe(source); // round-trip holds expect(diagnostics).toEqual([]); diff --git a/packages/1-framework/2-authoring/psl-parser/test/syntax/ast.test.ts b/packages/1-framework/2-authoring/psl-parser/test/syntax/ast.test.ts index 8d18d65d54..d0bf641f36 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/syntax/ast.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/syntax/ast.test.ts @@ -28,6 +28,7 @@ import { import { IdentifierAst } from '../../src/syntax/ast/identifier'; import { QualifiedNameAst } from '../../src/syntax/ast/qualified-name'; import { TypeAnnotationAst } from '../../src/syntax/ast/type-annotation'; +import { any } from '../../src/syntax/ast-helpers'; import { GreenNodeBuilder } from '../../src/syntax/green-builder'; import { createSyntaxTree, type SyntaxNode } from '../../src/syntax/red'; import type { SyntaxKind } from '../../src/syntax/syntax-kind'; @@ -62,6 +63,39 @@ describe('IdentifierAst', () => { }); }); +describe('any', () => { + it('returns the result of the first matching cast', () => { + const b = new GreenNodeBuilder(); + b.startNode('ModelDeclaration'); + const model = createSyntaxTree(b.finishNode()); + const first = (node: SyntaxNode) => node.kind; + const second = (node: SyntaxNode) => node.offset; + expect(any(first, second)(model)).toBe('ModelDeclaration'); + }); + + it('classifies different node kinds through the combined predicate', () => { + const classify = any(ModelDeclarationAst.cast, FieldDeclarationAst.cast); + + const modelBuilder = new GreenNodeBuilder(); + modelBuilder.startNode('ModelDeclaration'); + const model = createSyntaxTree(modelBuilder.finishNode()); + + const fieldBuilder = new GreenNodeBuilder(); + fieldBuilder.startNode('FieldDeclaration'); + const field = createSyntaxTree(fieldBuilder.finishNode()); + + expect(classify(model)).toBeInstanceOf(ModelDeclarationAst); + expect(classify(field)).toBeInstanceOf(FieldDeclarationAst); + }); + + it('returns undefined when no cast matches', () => { + const b = new GreenNodeBuilder(); + b.startNode('Document'); + const document = createSyntaxTree(b.finishNode()); + expect(any(ModelDeclarationAst.cast, FieldDeclarationAst.cast)(document)).toBeUndefined(); + }); +}); + describe('static cast', () => { it('DocumentAst.cast matches Document kind', () => { const b = new GreenNodeBuilder(); @@ -1180,6 +1214,53 @@ describe('QualifiedNameAst', () => { expect(qn.path()).toEqual(['supabase', 'auth', 'User']); }); + it('reads a colon-prefixed space:name without a namespace', () => { + const b = new GreenNodeBuilder(); + b.startNode('QualifiedName'); + ident(b, 'supabase'); + b.token('Colon', ':'); + ident(b, 'User'); + const qn = QualifiedNameAst.cast(createSyntaxTree(b.finishNode()))!; + expect(qn.space()?.token()?.text).toBe('supabase'); + expect(qn.namespace()).toBeUndefined(); + expect(qn.identifier()?.token()?.text).toBe('User'); + }); + + it('reads a trailing dot as a namespace with no name', () => { + const b = new GreenNodeBuilder(); + b.startNode('QualifiedName'); + ident(b, 'auth'); + b.token('Dot', '.'); + const qn = QualifiedNameAst.cast(createSyntaxTree(b.finishNode()))!; + expect(qn.space()).toBeUndefined(); + expect(qn.namespace()?.token()?.text).toBe('auth'); + expect(qn.identifier()).toBeUndefined(); + }); + + it('reads a trailing dot after a space as namespace with no name', () => { + const b = new GreenNodeBuilder(); + b.startNode('QualifiedName'); + ident(b, 'supabase'); + b.token('Colon', ':'); + ident(b, 'auth'); + b.token('Dot', '.'); + const qn = QualifiedNameAst.cast(createSyntaxTree(b.finishNode()))!; + expect(qn.space()?.token()?.text).toBe('supabase'); + expect(qn.namespace()?.token()?.text).toBe('auth'); + expect(qn.identifier()).toBeUndefined(); + }); + + it('reads a trailing colon as a space with no name', () => { + const b = new GreenNodeBuilder(); + b.startNode('QualifiedName'); + ident(b, 'supabase'); + b.token('Colon', ':'); + const qn = QualifiedNameAst.cast(createSyntaxTree(b.finishNode()))!; + expect(qn.space()?.token()?.text).toBe('supabase'); + expect(qn.namespace()).toBeUndefined(); + expect(qn.identifier()).toBeUndefined(); + }); + it('flags a second dot or colon as over-qualified', () => { const b = new GreenNodeBuilder(); b.startNode('QualifiedName'); diff --git a/packages/1-framework/2-authoring/psl-parser/test/syntax/navigation.test.ts b/packages/1-framework/2-authoring/psl-parser/test/syntax/navigation.test.ts new file mode 100644 index 0000000000..2f3ad79c73 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/test/syntax/navigation.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest'; +import { GreenNodeBuilder } from '../../src/syntax/green-builder'; +import { + isTrivia, + isTriviaKind, + nonTriviaSibling, + skipTriviaToken, +} from '../../src/syntax/navigation'; +import { createSyntaxTree, SyntaxNode, type SyntaxToken } from '../../src/syntax/red'; +import type { SyntaxKind } from '../../src/syntax/syntax-kind'; + +/** Builds a tree for: model User {\n id Int @id\n} */ +function buildSampleTree() { + const b = new GreenNodeBuilder(); + b.startNode('Document'); + b.startNode('ModelDeclaration'); + b.token('Ident', 'model'); + b.token('Whitespace', ' '); + b.startNode('Identifier'); + b.token('Ident', 'User'); + b.finishNode(); + b.token('Whitespace', ' '); + b.token('LBrace', '{'); + b.token('Newline', '\n'); + b.token('Whitespace', ' '); + b.startNode('FieldDeclaration'); + b.startNode('Identifier'); + b.token('Ident', 'id'); + b.finishNode(); + b.token('Whitespace', ' '); + b.startNode('TypeAnnotation'); + b.token('Ident', 'Int'); + b.finishNode(); + b.token('Whitespace', ' '); + b.startNode('FieldAttribute'); + b.token('At', '@'); + b.startNode('Identifier'); + b.token('Ident', 'id'); + b.finishNode(); + b.finishNode(); + b.finishNode(); + b.token('Newline', '\n'); + b.token('RBrace', '}'); + b.finishNode(); + return b.finishNode(); +} + +function firstNodeOfKind(root: SyntaxNode, kind: SyntaxKind): SyntaxNode { + for (const el of root.descendants()) { + if (el instanceof SyntaxNode && el.kind === kind) return el; + } + throw new Error(`no ${kind} node in tree`); +} + +/** + * Tokens of {@link buildSampleTree} in document order: + * 0 `model` 1 ` ` 2 `User` 3 ` ` 4 `{` 5 `\n` 6 ` ` 7 `id` 8 ` ` 9 `Int` + * 10 ` ` 11 `@` 12 `id` 13 `\n` 14 `}` + */ +function sampleTokens(root: SyntaxNode): SyntaxToken[] { + return Array.from(root.tokens()); +} + +describe('isTriviaKind / isTrivia', () => { + it('classifies whitespace, newline, and comment as trivia', () => { + expect(isTriviaKind('Whitespace')).toBe(true); + expect(isTriviaKind('Newline')).toBe(true); + expect(isTriviaKind('Comment')).toBe(true); + expect(isTriviaKind('Ident')).toBe(false); + }); + + it('classifies a token instance', () => { + const tokens = sampleTokens(createSyntaxTree(buildSampleTree())); + expect(isTrivia(tokens[9])).toBe(false); // Int + expect(isTrivia(tokens[8])).toBe(true); // space before Int + }); +}); + +describe('skipTriviaToken', () => { + it('returns the token itself when already significant', () => { + const intToken = sampleTokens(createSyntaxTree(buildSampleTree()))[9]; + expect(skipTriviaToken(intToken, 'next')).toBe(intToken); + expect(skipTriviaToken(intToken, 'prev')).toBe(intToken); + }); + + it('skips forward to the next significant token', () => { + const tokens = sampleTokens(createSyntaxTree(buildSampleTree())); + const space = tokens[1]; // between `model` and `User` + expect(space.kind).toBe('Whitespace'); + expect(skipTriviaToken(space, 'next')?.text).toBe('User'); + }); + + it('skips backward to the previous significant token', () => { + const tokens = sampleTokens(createSyntaxTree(buildSampleTree())); + expect(skipTriviaToken(tokens[1], 'prev')?.text).toBe('model'); + }); + + it('returns undefined when only trivia remains', () => { + const b = new GreenNodeBuilder(); + b.startNode('Document'); + b.token('Whitespace', ' '); + const root = createSyntaxTree(b.finishNode()); + const trivia = root.firstToken; + expect(trivia?.kind).toBe('Whitespace'); + if (trivia !== undefined) { + expect(skipTriviaToken(trivia, 'next')).toBeUndefined(); + expect(skipTriviaToken(trivia, 'prev')).toBeUndefined(); + } + }); +}); + +describe('nonTriviaSibling', () => { + it('skips trivia tokens between sibling nodes', () => { + const root = createSyntaxTree(buildSampleTree()); + const field = firstNodeOfKind(root, 'FieldDeclaration'); + const name = field.firstChild; + expect(name).toBeInstanceOf(SyntaxNode); + if (name instanceof SyntaxNode) { + const next = nonTriviaSibling(name, 'next'); + expect(next).toBeInstanceOf(SyntaxNode); + if (next instanceof SyntaxNode) { + expect(next.kind).toBe('TypeAnnotation'); + } + } + }); + + it('walks backward past trivia', () => { + const root = createSyntaxTree(buildSampleTree()); + const field = firstNodeOfKind(root, 'FieldDeclaration'); + const attr = firstNodeOfKind(field, 'FieldAttribute'); + const prev = nonTriviaSibling(attr, 'prev'); + expect(prev).toBeInstanceOf(SyntaxNode); + if (prev instanceof SyntaxNode) { + expect(prev.kind).toBe('TypeAnnotation'); + } + }); + + it('returns undefined when no significant sibling exists', () => { + const root = createSyntaxTree(buildSampleTree()); + const typeAnnotation = firstNodeOfKind(root, 'TypeAnnotation'); + const intToken = typeAnnotation.firstChild; + expect(intToken).not.toBeInstanceOf(SyntaxNode); + if (intToken !== undefined && !(intToken instanceof SyntaxNode)) { + expect(nonTriviaSibling(intToken, 'next')).toBeUndefined(); + } + }); +}); diff --git a/packages/1-framework/2-authoring/psl-parser/test/syntax/red.test.ts b/packages/1-framework/2-authoring/psl-parser/test/syntax/red.test.ts index 2d86e0e6a0..597e2d758f 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/syntax/red.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/syntax/red.test.ts @@ -1,6 +1,19 @@ import { describe, expect, it } from 'vitest'; +import { parse } from '../../src/parse'; +import { FieldDeclarationAst, ModelDeclarationAst } from '../../src/syntax/ast/declarations'; import { GreenNodeBuilder } from '../../src/syntax/green-builder'; -import { createSyntaxTree, SyntaxNode } from '../../src/syntax/red'; +import { createSyntaxTree, SyntaxNode, SyntaxToken, TokenAtOffset } from '../../src/syntax/red'; +import type { SyntaxKind } from '../../src/syntax/syntax-kind'; + +/** Source rendered by {@link buildSampleTree}, with token offsets used below. */ +const SAMPLE_SOURCE = 'model User {\n id Int @id\n}'; + +function firstNodeOfKind(root: SyntaxNode, kind: SyntaxKind): SyntaxNode { + for (const el of root.descendants()) { + if (el instanceof SyntaxNode && el.kind === kind) return el; + } + throw new Error(`no ${kind} node in tree`); +} /** Builds a tree for: model User {\n id Int @id\n} */ function buildSampleTree() { @@ -181,6 +194,57 @@ describe('SyntaxNode.nextSibling / prevSibling', () => { }); }); +describe('SyntaxElement.index', () => { + it('assigns 0 to the root and increasing indices along the sibling chain', () => { + const root = createSyntaxTree(buildSampleTree()); + expect(root.index).toBe(0); + + const model = firstNodeOfKind(root, 'ModelDeclaration'); + const first = model.firstChild; + expect(first).toBeDefined(); + expect(first?.index).toBe(0); + + const indices: number[] = []; + for (let el = first; el !== undefined; el = el.nextSiblingOrToken) { + indices.push(el.index); + } + const expected = Array.from(model.children(), (_, i) => i); + expect(indices).toEqual(expected); + }); + + it('distinguishes a zero-width child from its offset-colliding neighbour', () => { + const b = new GreenNodeBuilder(); + b.startNode('Document'); + b.startNode('Identifier'); + b.token('Ident', 'A'); + b.finishNode(); + b.startNode('TypeAnnotation'); // empty, zero-width at offset 1 + b.finishNode(); + b.startNode('Identifier'); + b.token('Ident', 'B'); + b.finishNode(); + const root = createSyntaxTree(b.finishNode()); + + const children = Array.from(root.children()); + const empty = children[1]; + const second = children[2]; + expect(empty).toBeInstanceOf(SyntaxNode); + expect(second).toBeInstanceOf(SyntaxNode); + // The zero-width node and its neighbour share a start offset... + expect(empty?.offset).toBe(1); + expect(second?.offset).toBe(1); + // ...but distinct indices let navigation step between them unambiguously. + expect(empty?.index).toBe(1); + expect(second?.index).toBe(2); + if (empty instanceof SyntaxNode && second instanceof SyntaxNode) { + expect(empty.nextSibling?.index).toBe(2); + expect(empty.nextSibling?.kind).toBe('Identifier'); + expect(second.prevSibling?.index).toBe(1); + expect(second.prevSibling?.kind).toBe('TypeAnnotation'); + } + }); +}); + describe('SyntaxNode.textLength', () => { it('returns total text length of the subtree', () => { const source = 'model User {\n id Int @id\n}'; @@ -190,6 +254,73 @@ describe('SyntaxNode.textLength', () => { }); }); +describe('SyntaxNode.endOffset', () => { + it('is the sum of offset and textLength', () => { + const root = createSyntaxTree(buildSampleTree()); + const node = firstNodeOfKind(root, 'FieldDeclaration'); + expect(node.endOffset).toBe(node.offset + node.textLength); + }); +}); + +describe('SyntaxToken.endOffset', () => { + it('is the sum of offset and text length', () => { + const root = createSyntaxTree(buildSampleTree()); + const token = root.tokenAtOffset(19).leftBiased(); + expect(token).toBeInstanceOf(SyntaxToken); + if (token instanceof SyntaxToken) { + expect(token.endOffset).toBe(token.offset + token.text.length); + } + }); +}); + +describe('SyntaxNode.isInside / isOutside', () => { + it('is inclusive at start, interior, and end, exclusive just outside', () => { + const root = createSyntaxTree(buildSampleTree()); + const field = firstNodeOfKind(root, 'FieldDeclaration'); + const start = field.offset; + const end = field.endOffset; + expect(field.isInside(start)).toBe(true); + expect(field.isInside(start + 1)).toBe(true); + expect(field.isInside(end)).toBe(true); + expect(field.isInside(start - 1)).toBe(false); + expect(field.isInside(end + 1)).toBe(false); + }); + + it('isOutside is the negation of isInside', () => { + const root = createSyntaxTree(buildSampleTree()); + const field = firstNodeOfKind(root, 'FieldDeclaration'); + expect(field.isOutside(field.offset)).toBe(false); + expect(field.isOutside(field.endOffset + 1)).toBe(true); + }); +}); + +describe('SyntaxToken.isInside / isOutside', () => { + it('is inclusive at start, interior, and end, exclusive just outside', () => { + const root = createSyntaxTree(buildSampleTree()); + const token = root.tokenAtOffset(19).leftBiased(); + expect(token).toBeInstanceOf(SyntaxToken); + if (token instanceof SyntaxToken) { + const start = token.offset; + const end = token.offset + token.text.length; + expect(token.isInside(start)).toBe(true); + expect(token.isInside(start + 1)).toBe(true); + expect(token.isInside(end)).toBe(true); + expect(token.isInside(start - 1)).toBe(false); + expect(token.isInside(end + 1)).toBe(false); + } + }); + + it('isOutside is the negation of isInside', () => { + const root = createSyntaxTree(buildSampleTree()); + const token = root.tokenAtOffset(19).leftBiased(); + expect(token).toBeInstanceOf(SyntaxToken); + if (token instanceof SyntaxToken) { + expect(token.isOutside(token.offset)).toBe(false); + expect(token.isOutside(token.offset + token.text.length + 1)).toBe(true); + } + }); +}); + describe('SyntaxNode.ancestors', () => { it('walks from node to root', () => { const root = createSyntaxTree(buildSampleTree()); @@ -210,6 +341,210 @@ describe('SyntaxNode.ancestors', () => { }); }); +describe('SyntaxNode.findAncestor', () => { + it('returns the node itself when it satisfies the cast', () => { + const root = createSyntaxTree(buildSampleTree()); + const model = firstNodeOfKind(root, 'ModelDeclaration'); + const found = model.findAncestor(ModelDeclarationAst.cast); + expect(found?.syntax).toBe(model); + }); + + it('returns the nearest matching ancestor of a descendant', () => { + const root = createSyntaxTree(buildSampleTree()); + const field = firstNodeOfKind(root, 'FieldDeclaration'); + const identifier = firstNodeOfKind(field, 'Identifier'); + const found = identifier.findAncestor(FieldDeclarationAst.cast); + expect(found?.syntax).toBe(field); + }); + + it('returns undefined when neither the node nor its ancestors match', () => { + const root = createSyntaxTree(buildSampleTree()); + expect(root.findAncestor(FieldDeclarationAst.cast)).toBeUndefined(); + }); +}); + +describe('SyntaxToken navigation', () => { + it('exposes the parent node', () => { + const root = createSyntaxTree(buildSampleTree()); + const intToken = root.tokenAtOffset(19).leftBiased(); + expect(intToken).toBeInstanceOf(SyntaxToken); + expect(intToken?.text).toBe('Int'); + expect(intToken?.parent.kind).toBe('TypeAnnotation'); + }); + + it('walks nextToken across node boundaries in document order', () => { + const root = createSyntaxTree(buildSampleTree()); + const expected = Array.from(root.tokens()); + + const walked: SyntaxToken[] = []; + let token = root.firstToken; + while (token !== undefined) { + walked.push(token); + token = token.nextToken; + } + + expect(walked.map((t) => t.text)).toEqual(expected.map((t) => t.text)); + expect(walked.map((t) => t.offset)).toEqual(expected.map((t) => t.offset)); + }); + + it('walks prevToken back to the first token', () => { + const root = createSyntaxTree(buildSampleTree()); + const expected = Array.from(root.tokens()).reverse(); + + const walked: SyntaxToken[] = []; + let token = root.lastToken; + while (token !== undefined) { + walked.push(token); + token = token.prevToken; + } + + expect(walked.map((t) => t.text)).toEqual(expected.map((t) => t.text)); + }); + + it('returns undefined past the tree edges', () => { + const root = createSyntaxTree(buildSampleTree()); + expect(root.firstToken?.prevToken).toBeUndefined(); + expect(root.lastToken?.nextToken).toBeUndefined(); + }); + + it('navigates sibling-or-token within a node', () => { + const root = createSyntaxTree(buildSampleTree()); + const field = firstNodeOfKind(root, 'FieldDeclaration'); + const name = field.firstChild; + expect(name).toBeInstanceOf(SyntaxNode); + const afterName = name?.nextSiblingOrToken; + expect(afterName).toBeInstanceOf(SyntaxToken); + if (afterName instanceof SyntaxToken) { + expect(afterName.kind).toBe('Whitespace'); + const back = afterName.prevSiblingOrToken; + expect(back).toBeInstanceOf(SyntaxNode); + if (back instanceof SyntaxNode) { + expect(back.kind).toBe('Identifier'); + expect(back.offset).toBe(name?.offset); + } + } + }); +}); + +describe('SyntaxNode.tokenAtOffset', () => { + it('returns a single token for an offset strictly inside it', () => { + const root = createSyntaxTree(buildSampleTree()); + const at = root.tokenAtOffset(19); + expect(at.isBetween).toBe(false); + expect(at.isEmpty).toBe(false); + expect(at.leftBiased()?.text).toBe('Int'); + expect(at.rightBiased()).toBe(at.leftBiased()); + }); + + it('represents the between-two-tokens seam with left/right bias', () => { + const root = createSyntaxTree(buildSampleTree()); + // offset 5 sits exactly on the seam between `model` and the following space. + const at = root.tokenAtOffset(5); + expect(at.isBetween).toBe(true); + expect(at.leftBiased()?.text).toBe('model'); + expect(at.leftBiased()?.kind).toBe('Ident'); + expect(at.rightBiased()?.kind).toBe('Whitespace'); + }); + + it('left-biases to the final significant token at EOF', () => { + const root = createSyntaxTree(buildSampleTree()); + const at = root.tokenAtOffset(SAMPLE_SOURCE.length); + expect(at.leftBiased()?.kind).toBe('RBrace'); + expect(at.leftBiased()?.text).toBe('}'); + }); + + it('returns none for an offset outside the tree', () => { + const root = createSyntaxTree(buildSampleTree()); + const at = root.tokenAtOffset(SAMPLE_SOURCE.length + 50); + expect(at.isEmpty).toBe(true); + expect(at.leftBiased()).toBeUndefined(); + expect(at.rightBiased()).toBeUndefined(); + }); + + it('returns none for an empty document', () => { + const b = new GreenNodeBuilder(); + b.startNode('Document'); + const root = createSyntaxTree(b.finishNode()); + expect(root.tokenAtOffset(0).isEmpty).toBe(true); + }); +}); + +describe('TokenAtOffset public surface', () => { + it('none exposes no token on either bias', () => { + const none = TokenAtOffset.none(); + expect(none.isEmpty).toBe(true); + expect(none.isBetween).toBe(false); + expect(none.leftBiased()).toBeUndefined(); + expect(none.rightBiased()).toBeUndefined(); + }); + + it('single collapses both biases to the same token and is never between', () => { + const root = createSyntaxTree(buildSampleTree()); + const token = root.tokenAtOffset(19).leftBiased(); + expect(token).toBeInstanceOf(SyntaxToken); + if (token instanceof SyntaxToken) { + const single = TokenAtOffset.single(token); + expect(single.isEmpty).toBe(false); + expect(single.isBetween).toBe(false); + expect(single.leftBiased()).toBe(token); + expect(single.rightBiased()).toBe(token); + } + }); + + it('between exposes both tokens and is never empty', () => { + const root = createSyntaxTree(buildSampleTree()); + const seam = root.tokenAtOffset(5); + const left = seam.leftBiased(); + const right = seam.rightBiased(); + expect(left).toBeInstanceOf(SyntaxToken); + expect(right).toBeInstanceOf(SyntaxToken); + if (left instanceof SyntaxToken && right instanceof SyntaxToken) { + const between = TokenAtOffset.between(left, right); + expect(between.isEmpty).toBe(false); + expect(between.isBetween).toBe(true); + expect(between.leftBiased()).toBe(left); + expect(between.rightBiased()).toBe(right); + } + }); +}); + +describe('SyntaxNode.coveringElement', () => { + it('descends to the smallest element covering a range', () => { + const root = createSyntaxTree(buildSampleTree()); + // `Int` token spans [18, 21). + const covering = root.coveringElement(18, 21); + expect(covering).toBeInstanceOf(SyntaxToken); + if (covering instanceof SyntaxToken) { + expect(covering.text).toBe('Int'); + } + }); + + it('left-biases an empty range at a seam', () => { + const b = new GreenNodeBuilder(); + b.startNode('Document'); + b.startNode('Identifier'); + b.token('Ident', 'A'); + b.finishNode(); + b.startNode('Identifier'); + b.token('Ident', 'B'); + b.finishNode(); + const root = createSyntaxTree(b.finishNode()); + + const covering = root.coveringElement(1, 1); + expect(covering).toBeInstanceOf(SyntaxToken); + if (covering instanceof SyntaxToken) { + expect(covering.text).toBe('A'); + } + }); + + it('returns the root when no child covers the range', () => { + const b = new GreenNodeBuilder(); + b.startNode('Document'); + const root = createSyntaxTree(b.finishNode()); + expect(root.coveringElement(0, 0)).toBe(root); + }); +}); + describe('SyntaxNode.descendants', () => { it('yields elements in depth-first pre-order', () => { const b = new GreenNodeBuilder(); @@ -243,3 +578,36 @@ describe('SyntaxNode.descendants', () => { ]); }); }); + +describe('zero-width node precondition', () => { + // The red-tree navigation relies on no non-root node ever being zero-width: + // the only legitimately empty node is the root `Document` of an empty file, + // and a root is never reached as a child during descent. Malformed inputs + // are the cases historically prone to materializing empty placeholder nodes. + const sources = [ + '', + 'model User { id Int @id }', + 'model A {', + 'model A { id }', + 'model A { id Int @default(,) }', + 'model A { id Int @default() }', + 'model A { id Int @foo(@) }', + 'model A { vec Vector( }', + 'model A { id @id }', + 'type T = Vector(', + 'enum E { A B', + 'model A { id Int @default(autoincrement()) @', + 'model A { name String @db. }', + ]; + + for (const source of sources) { + it(`emits no zero-width non-root node for ${JSON.stringify(source)}`, () => { + const { document } = parse(source); + for (const el of document.syntax.descendants()) { + if (el instanceof SyntaxNode && el.parent !== undefined) { + expect(el.textLength).toBeGreaterThan(0); + } + } + }); + } +}); diff --git a/packages/1-framework/3-tooling/language-server/README.md b/packages/1-framework/3-tooling/language-server/README.md index 7d9d2d8e51..d905acf901 100644 --- a/packages/1-framework/3-tooling/language-server/README.md +++ b/packages/1-framework/3-tooling/language-server/README.md @@ -6,12 +6,12 @@ The Prisma Next language server speaks the Language Server Protocol over stdio f ## Scope -Supported capabilities are intentionally narrow: parse diagnostics, whole-document formatting, folding ranges, and full/range semantic tokens for configured PSL inputs. Formatting is only available for documents listed in `contract.source.inputs`, uses `@prisma-next/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. Hover, completion, 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. +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 `@prisma-next/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. ## 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, and full/range semantic tokens for configured PSL inputs. +- Publish parse diagnostics and serve 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. @@ -31,7 +31,8 @@ Supported capabilities are intentionally narrow: parse diagnostics, whole-docume 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. -7. **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 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. +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. ## Module layout @@ -42,7 +43,9 @@ Supported capabilities are intentionally narrow: parse diagnostics, whole-docume - `project-artifacts.ts` — `createProjectArtifacts()`, the per-project store that preserves the per-URI ASTs and the single project symbol table across edits. - `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. -- `server.ts` — `createServer(connection)` wires diagnostics, whole-document formatting, folding ranges, semantic-token handlers, config watching, and project-artifact access onto an injected connection. +- `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()` creates a stdio connection and starts the server. This is what the CLI delegates to. ## Package Location diff --git a/packages/1-framework/3-tooling/language-server/src/completion-context.ts b/packages/1-framework/3-tooling/language-server/src/completion-context.ts new file mode 100644 index 0000000000..449ac6ee37 --- /dev/null +++ b/packages/1-framework/3-tooling/language-server/src/completion-context.ts @@ -0,0 +1,432 @@ +import { + AttributeArgListAst, + any, + type BracedBlock, + CompositeTypeDeclarationAst, + type DocumentAst, + FieldAttributeAst, + FieldDeclarationAst, + GenericBlockDeclarationAst, + KeyValuePairAst, + ModelAttributeAst, + ModelDeclarationAst, + NamespaceDeclarationAst, + type Position, + type QualifiedNameAst, + type SourceFile, + type SyntaxNode, + type SyntaxToken, + skipTriviaToken, + type TokenAtOffset, + TypesBlockAst, +} from '@prisma-next/psl-parser/syntax'; + +export interface ClassifyPslCompletionContextInput { + readonly document: DocumentAst; + readonly sourceFile: SourceFile; + readonly position: Position; +} + +export interface ModelTypeCompletionContext { + readonly kind: 'modelType'; + readonly offset: number; + readonly fieldName: string; + readonly replacementStartOffset: number; +} + +export interface SpaceMemberCompletionContext { + readonly kind: 'spaceMember'; + readonly offset: number; + readonly fieldName: string; + readonly replacementStartOffset: number; + readonly space: string; +} + +export interface NamespaceMemberCompletionContext { + readonly kind: 'namespaceMember'; + readonly offset: number; + readonly fieldName: string; + readonly replacementStartOffset: number; + readonly namespace: string; + readonly space?: string; +} + +export interface GenericBlockKeyCompletionContext { + readonly kind: 'genericBlockKey'; + readonly offset: number; + readonly blockKeyword: string; + readonly replacementStartOffset: number; + readonly block: GenericBlockDeclarationAst; +} + +export interface GenericBlockValueCompletionContext { + readonly kind: 'genericBlockValue'; + readonly offset: number; + readonly blockKeyword: string; + readonly replacementStartOffset: number; +} + +export type DeclarationKeywordCompletionScope = 'document' | 'namespace'; + +export interface DeclarationKeywordCompletionContext { + readonly kind: 'declarationKeyword'; + readonly offset: number; + readonly scope: DeclarationKeywordCompletionScope; + readonly replacementStartOffset: number; +} + +export interface UnsupportedPslCompletionContext { + readonly kind: 'unsupported'; +} + +export type PslCompletionContext = + | DeclarationKeywordCompletionContext + | GenericBlockKeyCompletionContext + | GenericBlockValueCompletionContext + | ModelTypeCompletionContext + | NamespaceMemberCompletionContext + | SpaceMemberCompletionContext + | UnsupportedPslCompletionContext; + +const UNSUPPORTED: UnsupportedPslCompletionContext = { kind: 'unsupported' }; + +export function classifyPslCompletionContext( + input: ClassifyPslCompletionContextInput, +): PslCompletionContext { + const root = input.document.syntax; + const offset = input.sourceFile.offsetAt(input.position); + const at = root.tokenAtOffset(offset); + + // Completion is never offered when the cursor sits inside a comment. + if (at.leftBiased()?.kind === 'Comment') { + return UNSUPPORTED; + } + + // The edit replaces the identifier under the cursor, or is empty when the + // cursor sits in trivia. + const edit = cursorIdentifier(at, offset); + + // Anchor on the significant token preceding the cursor and navigate outward + // via `token.parent` rather than scanning the whole tree. + const preceding = precedingToken(at, edit); + const precedingNode = preceding?.parent; + const replacementStartOffset = edit?.offset ?? offset; + + const declarationKeywordContext = classifyDeclarationKeyword({ + node: precedingNode, + offset, + replacementStartOffset, + }); + if (declarationKeywordContext !== undefined) { + return declarationKeywordContext; + } + + const genericBlockContext = classifyGenericBlockParameter({ + offset, + at, + precedingToken: preceding, + replacementStartOffset, + }); + if (genericBlockContext !== undefined) { + return genericBlockContext; + } + + const field = fieldForTypeSlot(precedingNode); + if (field === undefined) { + return UNSUPPORTED; + } + if ( + field.syntax.findAncestor(any(ModelDeclarationAst.cast, CompositeTypeDeclarationAst.cast)) === + undefined + ) { + return UNSUPPORTED; + } + + return classifyModelFieldType({ + field, + offset, + replacementStartOffset, + precedingToken: preceding, + }); +} + +/** + * Locates the field whose type position the cursor occupies. The preceding token + * climbs to the field whether the cursor sits inside a present type (the type + * identifier's predecessor still belongs to the field) or in the empty type slot + * of a typeless field (whose trailing trivia lives in the enclosing block, so + * the nearest significant token to the left is the field's own name). + */ +function fieldForTypeSlot(precedingNode: SyntaxNode | undefined): FieldDeclarationAst | undefined { + return precedingNode?.findAncestor(FieldDeclarationAst.cast); +} + +function classifyModelFieldType(input: { + readonly field: FieldDeclarationAst; + readonly offset: number; + readonly replacementStartOffset: number; + readonly precedingToken: SyntaxToken | undefined; +}): PslCompletionContext { + const fieldName = input.field.name(); + if (fieldName === undefined) { + return UNSUPPORTED; + } + const fieldNameText = fieldName.name(); + if (fieldNameText === undefined) { + return UNSUPPORTED; + } + + if (fieldName.syntax.isInside(input.offset)) { + return UNSUPPORTED; + } + + const typeAnnotation = input.field.typeAnnotation(); + if (typeAnnotation === undefined) { + if ( + input.precedingToken !== undefined && + fieldName.syntax.isInside(input.precedingToken.offset) + ) { + return { + kind: 'modelType', + offset: input.offset, + fieldName: fieldNameText, + replacementStartOffset: input.offset, + }; + } + return UNSUPPORTED; + } + + if (typeAnnotation.syntax.isOutside(input.offset)) { + return UNSUPPORTED; + } + + const constructorArgList = typeAnnotation.argList(); + if (constructorArgList?.syntax.isInside(input.offset)) { + return UNSUPPORTED; + } + + const name = typeAnnotation.name(); + if (name === undefined) { + return UNSUPPORTED; + } + if (name.syntax.isOutside(input.offset)) { + return UNSUPPORTED; + } + if (name.isOverQualified()) { + return UNSUPPORTED; + } + + return classifyTypePosition(name, input.offset, fieldNameText, input.replacementStartOffset); +} + +/** + * Builds the type-completion context for a qualified name. Roles are read + * straight off the separator-positional accessors: a populated namespace + * segment is a `.`-qualified name, a populated space segment is a `:`-qualified + * name, and the absence of both is a bare model type. + * + * Behaviour change: a `:`-qualified name with no `.` (e.g. `supabase:`, + * `supabase:U`) is a `spaceMember` position rather than falling through to bare + * model-type completions. A malformed leading-separator name (`:User`, `.User`) + * carries no populated segment and resolves to `modelType` rather than + * `unsupported`. + */ +function classifyTypePosition( + name: QualifiedNameAst, + offset: number, + fieldName: string, + replacementStartOffset: number, +): ModelTypeCompletionContext | SpaceMemberCompletionContext | NamespaceMemberCompletionContext { + const namespace = name.namespace()?.name(); + if (namespace !== undefined && namespace.length > 0) { + const namespaceSpace = name.space()?.name(); + return { + kind: 'namespaceMember', + offset, + fieldName, + replacementStartOffset, + namespace, + ...(namespaceSpace !== undefined && namespaceSpace.length > 0 + ? { space: namespaceSpace } + : {}), + }; + } + const space = name.space()?.name(); + if (space !== undefined && space.length > 0) { + return { kind: 'spaceMember', offset, fieldName, replacementStartOffset, space }; + } + return { kind: 'modelType', offset, fieldName, replacementStartOffset }; +} + +const declarationCast = any( + ModelDeclarationAst.cast, + CompositeTypeDeclarationAst.cast, + TypesBlockAst.cast, + GenericBlockDeclarationAst.cast, + NamespaceDeclarationAst.cast, +); + +type DeclarationAst = NonNullable>; + +function classifyDeclarationKeyword(input: { + readonly node: SyntaxNode | undefined; + readonly offset: number; + readonly replacementStartOffset: number; +}): DeclarationKeywordCompletionContext | undefined { + const precedingDeclaration = input.node?.findAncestor(declarationCast); + const namespace = input.node?.findAncestor(NamespaceDeclarationAst.cast); + const inNamespaceBody = blockBodyContainsOffset(namespace, input.offset); + + if ( + precedingDeclaration !== undefined && + !canCompleteDeclaration(precedingDeclaration, input.offset, inNamespaceBody) + ) { + return undefined; + } + + return { + kind: 'declarationKeyword', + offset: input.offset, + scope: inNamespaceBody ? 'namespace' : 'document', + replacementStartOffset: input.replacementStartOffset, + }; +} + +/** + * Whether a new declaration can begin at the cursor, given the nearest enclosing + * declaration. Allowed when that declaration is still nascent (only its keyword + * typed, no name or body yet), when it is a namespace whose body holds further + * declarations, or when the cursor sits past its closing `}`. + */ +function canCompleteDeclaration( + precedingDeclaration: DeclarationAst, + offset: number, + inNamespaceBody: boolean, +): boolean { + const keywordOnly = + precedingDeclaration.lbrace() === undefined && + (precedingDeclaration instanceof TypesBlockAst || precedingDeclaration.name() === undefined); + if (keywordOnly) { + return true; + } + if (precedingDeclaration instanceof NamespaceDeclarationAst && inNamespaceBody) { + return true; + } + const rbrace = precedingDeclaration.rbrace(); + return rbrace !== undefined && offset >= rbrace.endOffset; +} + +function blockBodyContainsOffset(block: BracedBlock | undefined, offset: number): boolean { + if (block === undefined) { + return false; + } + const lbrace = block.lbrace(); + if (lbrace === undefined) { + return false; + } + const bodyStart = lbrace.endOffset; + const bodyEnd = block.rbrace()?.offset ?? block.syntax.endOffset; + return offset >= bodyStart && offset <= bodyEnd; +} + +function classifyGenericBlockParameter(input: { + readonly offset: number; + readonly at: TokenAtOffset; + readonly precedingToken: SyntaxToken | undefined; + readonly replacementStartOffset: number; +}): PslCompletionContext | undefined { + // Whether the cursor sits in a key, value, or attribute slot is a structural + // question, so it anchors on the cursor's own node — including any in-progress + // identifier — rather than the edit-skipped `precedingToken` used for gaps. + const node = input.at.leftBiased()?.parent; + const block = node?.findAncestor(GenericBlockDeclarationAst.cast); + if (block === undefined) { + return undefined; + } + + if (hasUnsupportedAncestor(node)) { + return UNSUPPORTED; + } + + if (!blockBodyContainsOffset(block, input.offset)) { + return UNSUPPORTED; + } + + const field = node?.findAncestor(FieldDeclarationAst.cast); + if (field?.syntax.isInside(input.offset)) { + return UNSUPPORTED; + } + + const keyword = block.keyword()?.text; + if (keyword === undefined || keyword.length === 0) { + return UNSUPPORTED; + } + + // Value position: the cursor follows a `=`. The position is now classified + // distinctly from keys; populating value candidates is the provider's concern. + if (input.precedingToken?.kind === 'Equals') { + return { + kind: 'genericBlockValue', + offset: input.offset, + blockKeyword: keyword, + replacementStartOffset: input.replacementStartOffset, + }; + } + + const activePair = activeKeyValuePair(node, input.offset); + if (activePair !== undefined && isAfterEquals(activePair, input.offset)) { + return UNSUPPORTED; + } + + return { + kind: 'genericBlockKey', + offset: input.offset, + blockKeyword: keyword, + replacementStartOffset: input.replacementStartOffset, + block, + }; +} + +function activeKeyValuePair( + node: SyntaxNode | undefined, + offset: number, +): KeyValuePairAst | undefined { + const pair = node?.findAncestor(KeyValuePairAst.cast); + if (pair === undefined || pair.syntax.isOutside(offset)) { + return undefined; + } + return pair; +} + +function isAfterEquals(pair: KeyValuePairAst, offset: number): boolean { + const equals = pair.equals(); + return equals !== undefined && offset > equals.offset; +} + +function hasUnsupportedAncestor(node: SyntaxNode | undefined): boolean { + return ( + node?.findAncestor( + any(AttributeArgListAst.cast, FieldAttributeAst.cast, ModelAttributeAst.cast), + ) !== undefined + ); +} + +/** The significant token preceding the cursor — the in-progress edit identifier + * is skipped, so the result is the token the classifier anchors on. */ +function precedingToken(at: TokenAtOffset, edit: SyntaxToken | undefined): SyntaxToken | undefined { + const start = edit !== undefined ? edit.prevToken : at.leftBiased(); + return start === undefined ? undefined : skipTriviaToken(start, 'prev'); +} + +/** The identifier token the cursor is editing, if any. */ +function cursorIdentifier(at: TokenAtOffset, offset: number): SyntaxToken | undefined { + const right = at.rightBiased(); + if (right?.kind === 'Ident' && offset < right.endOffset) { + return right; + } + const left = at.leftBiased(); + if (left?.kind === 'Ident' && left.endOffset === offset) { + return left; + } + return undefined; +} diff --git a/packages/1-framework/3-tooling/language-server/src/completion-provider.ts b/packages/1-framework/3-tooling/language-server/src/completion-provider.ts new file mode 100644 index 0000000000..fbc5bb37f1 --- /dev/null +++ b/packages/1-framework/3-tooling/language-server/src/completion-provider.ts @@ -0,0 +1,454 @@ +import { + type AuthoringPslBlockDescriptorNamespace, + isAuthoringPslBlockDescriptor, +} from '@prisma-next/framework-components/authoring'; +import { + findBlockDescriptor, + type NamespaceSymbol, + type SymbolTable, +} from '@prisma-next/psl-parser'; +import type { GenericBlockDeclarationAst, SourceFile } from '@prisma-next/psl-parser/syntax'; +import { type CompletionItem, CompletionItemKind, InsertTextFormat } from 'vscode-languageserver'; +import type { + DeclarationKeywordCompletionContext, + GenericBlockKeyCompletionContext, + ModelTypeCompletionContext, + NamespaceMemberCompletionContext, + PslCompletionContext, +} from './completion-context'; + +export interface PslCompletionCandidateSource { + readonly scalarTypes: readonly string[]; + readonly pslBlockDescriptors: AuthoringPslBlockDescriptorNamespace; + readonly symbolTable?: SymbolTable; +} + +export interface ProvidePslCompletionItemsInput { + readonly context: PslCompletionContext; + readonly sourceFile: SourceFile; + readonly candidates: PslCompletionCandidateSource; + readonly clientSupportsSnippets: boolean; +} + +type DeclarationKeywordCompletionCandidateCategory = 'native' | 'genericBlock'; + +type ModelTypeCompletionCandidateCategory = + | 'configuredScalar' + | 'topLevelModel' + | 'topLevelCompositeType' + | 'scalar' + | 'typeAlias' + | 'namespace' + | 'namespaceModel' + | 'namespaceCompositeType'; + +interface DeclarationKeywordCompletionCandidate { + readonly category: DeclarationKeywordCompletionCandidateCategory; + readonly label: string; + readonly insertText: string; + readonly snippetText: string; + readonly detail: string; + readonly kind: CompletionItemKind; +} + +interface ModelTypeCompletionCandidate { + readonly category: ModelTypeCompletionCandidateCategory; + readonly label: string; + readonly insertText: string; + readonly filterText: string; + readonly detail: string; + readonly kind: CompletionItemKind; +} + +const categoryOrder: Record = { + configuredScalar: 0, + topLevelModel: 1, + topLevelCompositeType: 2, + scalar: 3, + typeAlias: 4, + namespace: 5, + namespaceModel: 6, + namespaceCompositeType: 7, +}; + +const declarationKeywordCategoryOrder: Record< + DeclarationKeywordCompletionCandidateCategory, + number +> = { + native: 0, + genericBlock: 1, +}; + +const nameSnippetPlaceholder = '$' + '{1:Name}'; +const namespaceSnippetPlaceholder = '$' + '{1:name}'; + +const documentNativeDeclarationKeywords: readonly DeclarationKeywordCompletionCandidate[] = [ + nativeDeclarationKeyword('model', 'model ', `model ${nameSnippetPlaceholder} {\n $0\n}`), + nativeDeclarationKeyword('type', 'type ', `type ${nameSnippetPlaceholder} {\n $0\n}`), + nativeDeclarationKeyword('types', 'types ', 'types {\n $0\n}'), + nativeDeclarationKeyword( + 'namespace', + 'namespace ', + `namespace ${namespaceSnippetPlaceholder} {\n $0\n}`, + ), +]; + +const namespaceNativeDeclarationKeywords: readonly DeclarationKeywordCompletionCandidate[] = [ + nativeDeclarationKeyword('model', 'model ', `model ${nameSnippetPlaceholder} {\n $0\n}`), + nativeDeclarationKeyword('type', 'type ', `type ${nameSnippetPlaceholder} {\n $0\n}`), +]; + +export function providePslCompletionItems( + input: ProvidePslCompletionItemsInput, +): readonly CompletionItem[] { + const { context } = input; + switch (context.kind) { + case 'unsupported': + return []; + // Foreign contract-space members require the multi-input symbol table; no + // contract-space registry exists today, so this position yields nothing yet. + case 'spaceMember': + return []; + // Parameter-value completion (option allowed-values / ref scopes) is future + // work. + case 'genericBlockValue': + return []; + case 'declarationKeyword': + return provideDeclarationKeywordCompletionItems( + context, + input.sourceFile, + input.candidates, + input.clientSupportsSnippets, + ); + case 'genericBlockKey': + return provideGenericBlockKeyCompletionItems(context, input.sourceFile, input.candidates); + case 'modelType': + return provideModelTypeCompletionItems(context, input.sourceFile, input.candidates); + case 'namespaceMember': + return provideNamespaceMemberCompletionItems(context, input.sourceFile, input.candidates); + } +} + +function provideDeclarationKeywordCompletionItems( + context: DeclarationKeywordCompletionContext, + sourceFile: SourceFile, + source: PslCompletionCandidateSource, + clientSupportsSnippets: boolean, +): readonly CompletionItem[] { + const replacementRange = { + start: sourceFile.positionAt(context.replacementStartOffset), + end: sourceFile.positionAt(context.offset), + }; + + return declarationKeywordCandidates(context.scope, source).map((candidate) => ({ + label: candidate.label, + kind: candidate.kind, + detail: candidate.detail, + sortText: declarationKeywordSortText(candidate), + filterText: candidate.label, + ...(clientSupportsSnippets ? { insertTextFormat: InsertTextFormat.Snippet } : {}), + textEdit: { + range: replacementRange, + newText: clientSupportsSnippets ? candidate.snippetText : candidate.insertText, + }, + })); +} + +function declarationKeywordCandidates( + scope: DeclarationKeywordCompletionContext['scope'], + source: PslCompletionCandidateSource, +): readonly DeclarationKeywordCompletionCandidate[] { + const nativeCandidates = + scope === 'namespace' ? namespaceNativeDeclarationKeywords : documentNativeDeclarationKeywords; + return [ + ...nativeCandidates, + ...genericBlockDeclarationKeywordCandidates(source.pslBlockDescriptors), + ]; +} + +function nativeDeclarationKeyword( + label: string, + insertText: string, + snippetText: string, +): DeclarationKeywordCompletionCandidate { + return { + category: 'native', + label, + insertText, + snippetText, + detail: 'PSL declaration keyword', + kind: CompletionItemKind.Keyword, + }; +} + +function genericBlockDeclarationKeywordCandidates( + descriptors: AuthoringPslBlockDescriptorNamespace, +): readonly DeclarationKeywordCompletionCandidate[] { + return descriptorBlockKeywords(descriptors).map((keyword) => ({ + category: 'genericBlock', + label: keyword, + insertText: `${keyword} `, + snippetText: `${keyword} ${nameSnippetPlaceholder} {\n $0\n}`, + detail: 'Generic block keyword', + kind: CompletionItemKind.Keyword, + })); +} + +function descriptorBlockKeywords( + descriptors: AuthoringPslBlockDescriptorNamespace, +): readonly string[] { + const keywords: string[] = []; + collectDescriptorBlockKeywords(descriptors, keywords); + return sortedUnique(keywords); +} + +function collectDescriptorBlockKeywords( + descriptors: AuthoringPslBlockDescriptorNamespace, + keywords: string[], +): void { + for (const value of Object.values(descriptors)) { + if (isAuthoringPslBlockDescriptor(value)) { + keywords.push(value.keyword); + continue; + } + collectDescriptorBlockKeywords(value, keywords); + } +} + +function declarationKeywordSortText(candidate: DeclarationKeywordCompletionCandidate): string { + return `${declarationKeywordCategoryOrder[candidate.category]}:${candidate.label}`; +} + +function provideGenericBlockKeyCompletionItems( + context: GenericBlockKeyCompletionContext, + sourceFile: SourceFile, + source: PslCompletionCandidateSource, +): readonly CompletionItem[] { + const descriptor = findBlockDescriptor(source.pslBlockDescriptors, context.blockKeyword); + if (descriptor === undefined) { + return []; + } + + const existing = existingGenericBlockParameterNames(context.block, context.offset); + const replacementRange = { + start: sourceFile.positionAt(context.replacementStartOffset), + end: sourceFile.positionAt(context.offset), + }; + + return Object.keys(descriptor.parameters) + .filter((parameterName) => !existing.has(parameterName)) + .map((parameterName, index) => ({ + label: parameterName, + kind: CompletionItemKind.Property, + detail: 'Generic block parameter', + sortText: genericBlockParameterSortText(index, parameterName), + filterText: parameterName, + textEdit: { + range: replacementRange, + newText: parameterName, + }, + })); +} + +function existingGenericBlockParameterNames( + block: GenericBlockDeclarationAst, + cursorOffset: number, +): Set { + const names = new Set(); + for (const entry of block.entries()) { + if (!entry.syntax.isOutside(cursorOffset)) { + continue; + } + const name = entry.key()?.name(); + if (name !== undefined) { + names.add(name); + } + } + return names; +} + +function provideModelTypeCompletionItems( + context: ModelTypeCompletionContext, + sourceFile: SourceFile, + source: PslCompletionCandidateSource, +): readonly CompletionItem[] { + return modelTypeCompletionItems(context, sourceFile, [ + ...configuredScalarCandidates(source.scalarTypes), + ...topLevelSymbolCandidates(source.symbolTable), + ...allNamespaceCandidates(source.symbolTable), + ]); +} + +function provideNamespaceMemberCompletionItems( + context: NamespaceMemberCompletionContext, + sourceFile: SourceFile, + source: PslCompletionCandidateSource, +): readonly CompletionItem[] { + // A foreign contract-space reference resolves against external symbols that no + // registry exposes yet; local namespace members must not stand in for them. + if (context.space !== undefined) { + return []; + } + return modelTypeCompletionItems( + context, + sourceFile, + namespaceCandidates(source.symbolTable?.topLevel.namespaces[context.namespace]), + ); +} + +function modelTypeCompletionItems( + context: ModelTypeCompletionContext | NamespaceMemberCompletionContext, + sourceFile: SourceFile, + candidates: readonly ModelTypeCompletionCandidate[], +): readonly CompletionItem[] { + const replacementRange = { + start: sourceFile.positionAt(context.replacementStartOffset), + end: sourceFile.positionAt(context.offset), + }; + + return candidates.map((candidate) => ({ + label: candidate.label, + kind: candidate.kind, + detail: candidate.detail, + sortText: sortText(candidate), + filterText: candidate.filterText, + textEdit: { + range: replacementRange, + newText: candidate.insertText, + }, + })); +} + +function configuredScalarCandidates( + scalarTypes: readonly string[], +): readonly ModelTypeCompletionCandidate[] { + return sortedUnique(scalarTypes).map((name) => ({ + category: 'configuredScalar', + label: name, + insertText: name, + filterText: name, + detail: 'Configured scalar type', + kind: CompletionItemKind.Keyword, + })); +} + +function topLevelSymbolCandidates( + symbolTable: SymbolTable | undefined, +): readonly ModelTypeCompletionCandidate[] { + if (symbolTable === undefined) { + return []; + } + const { topLevel } = symbolTable; + return [ + ...symbolCandidates( + recordNames(topLevel.models), + 'topLevelModel', + 'Model', + CompletionItemKind.Class, + ), + ...symbolCandidates( + recordNames(topLevel.compositeTypes), + 'topLevelCompositeType', + 'Composite type', + CompletionItemKind.Struct, + ), + ...symbolCandidates( + recordNames(topLevel.scalars), + 'scalar', + 'Scalar type', + CompletionItemKind.Unit, + ), + ...symbolCandidates( + recordNames(topLevel.typeAliases), + 'typeAlias', + 'Type alias', + CompletionItemKind.Reference, + ), + ]; +} + +function allNamespaceCandidates( + symbolTable: SymbolTable | undefined, +): readonly ModelTypeCompletionCandidate[] { + if (symbolTable === undefined) { + return []; + } + return Object.values(symbolTable.topLevel.namespaces) + .sort((left, right) => compareNames(left.name, right.name)) + .map(namespaceQualifierCandidate); +} + +function namespaceCandidates( + namespace: NamespaceSymbol | undefined, +): readonly ModelTypeCompletionCandidate[] { + if (namespace === undefined) { + return []; + } + return [ + ...symbolCandidates( + recordNames(namespace.models), + 'namespaceModel', + `Model in namespace ${namespace.name}`, + CompletionItemKind.Class, + ), + ...symbolCandidates( + recordNames(namespace.compositeTypes), + 'namespaceCompositeType', + `Composite type in namespace ${namespace.name}`, + CompletionItemKind.Struct, + ), + ]; +} + +function namespaceQualifierCandidate(namespace: NamespaceSymbol): ModelTypeCompletionCandidate { + return { + category: 'namespace', + label: namespace.name, + insertText: namespace.name, + filterText: namespace.name, + detail: 'Namespace', + kind: CompletionItemKind.Module, + }; +} + +function symbolCandidates( + names: readonly string[], + category: ModelTypeCompletionCandidateCategory, + detail: string, + kind: CompletionItemKind, +): readonly ModelTypeCompletionCandidate[] { + return names.map((name) => ({ + category, + label: name, + insertText: name, + filterText: name, + detail, + kind, + })); +} + +function recordNames( + record: Record, +): readonly string[] { + return Object.values(record) + .map((symbol) => symbol.name) + .sort(compareNames); +} + +function sortedUnique(names: readonly string[]): readonly string[] { + return [...new Set(names)].sort(compareNames); +} + +function sortText(candidate: ModelTypeCompletionCandidate): string { + return `${categoryOrder[candidate.category]}:${candidate.label}`; +} + +function genericBlockParameterSortText(index: number, label: string): string { + return `${index.toString().padStart(4, '0')}:${label}`; +} + +function compareNames(left: string, right: string): number { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} diff --git a/packages/1-framework/3-tooling/language-server/src/semantic-tokens.ts b/packages/1-framework/3-tooling/language-server/src/semantic-tokens.ts index be476faace..7f226edea0 100644 --- a/packages/1-framework/3-tooling/language-server/src/semantic-tokens.ts +++ b/packages/1-framework/3-tooling/language-server/src/semantic-tokens.ts @@ -13,6 +13,7 @@ import { FieldDeclarationAst, FunctionCallAst, filterChildren, + findChildToken, type GenericBlockMemberAst, IdentifierAst, ModelDeclarationAst, @@ -352,13 +353,15 @@ function collectAttribute( tokens: PendingSemanticToken[], namespace: string | undefined, ): void { - collectDecoratorName(attribute.name(), source.sourceFile.text, tokens); + const marker = + findChildToken(attribute.syntax, 'At') ?? findChildToken(attribute.syntax, 'DoubleAt'); + collectDecoratorName(attribute.name(), marker, tokens); collectAttributeArgList(attribute.argList(), source, tokens, namespace); } function collectDecoratorName( name: QualifiedNameAst | undefined, - sourceText: string, + marker: SyntaxToken | undefined, tokens: PendingSemanticToken[], ): void { if (name === undefined) { @@ -366,7 +369,7 @@ function collectDecoratorName( } const segments = identifierSegments(name); for (const [index, segment] of segments.entries()) { - tokens.push(rangeForDecoratorIdentifier(segment.identifier, sourceText, index === 0)); + tokens.push(rangeForDecoratorIdentifier(segment.identifier, index === 0 ? marker : undefined)); } } @@ -622,20 +625,14 @@ function rangeForIdentifier( function rangeForDecoratorIdentifier( identifier: IdentifierAst, - sourceText: string, - includePrefix: boolean, + marker: SyntaxToken | undefined, ): PendingSemanticToken { const range = rangeForIdentifier(identifier, 'decorator'); - if (!includePrefix) { + if (marker === undefined || marker.offset >= range.startOffset) { return range; } - - let startOffset = range.startOffset; - while (startOffset > 0 && sourceText.charAt(startOffset - 1) === '@') { - startOffset--; - } return createPendingSemanticToken( - startOffset, + marker.offset, range.endOffset, 'decorator', range.modifierBitset, 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 8936237dba..916fc04bda 100644 --- a/packages/1-framework/3-tooling/language-server/src/server.ts +++ b/packages/1-framework/3-tooling/language-server/src/server.ts @@ -4,6 +4,7 @@ import { findNearestConfigPathForFile } from '@prisma-next/config-loader'; import type { SymbolTable } from '@prisma-next/psl-parser'; import { type FormatOptions, format } from '@prisma-next/psl-parser/format'; import { + type CompletionItem, type Connection, type Diagnostic, DiagnosticSeverity, @@ -11,6 +12,8 @@ import { type FoldingRange, type InitializeParams, type InitializeResult, + type Position, + type PublishDiagnosticsParams, type Range, RegistrationRequest, type SemanticTokens, @@ -19,6 +22,8 @@ import { type TextEdit, } from 'vscode-languageserver'; 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 { computeFoldingRanges } from './folding-ranges'; @@ -63,6 +68,22 @@ export function createServer(connection: Connection): LanguageServer { let rootPath = process.cwd(); let watchedConfigGlob = join(rootPath, '**', CONFIG_FILENAME); let supportsWatchedFilesRegistration = false; + let clientSupportsSnippets = false; + let disposed = false; + + function sendDiagnostics(params: PublishDiagnosticsParams): void { + if (disposed) { + return; + } + void connection.sendDiagnostics(params); + } + + function logWarn(message: string): void { + if (disposed) { + return; + } + connection.console.warn(message); + } async function publish(uri: string): Promise { const project = await resolveProjectForDocument(uri); @@ -81,7 +102,7 @@ export function createServer(connection: Connection): LanguageServer { project.controlStack, ); if (computed === null) { - void connection.sendDiagnostics({ uri, diagnostics: [] }); + sendDiagnostics({ uri, diagnostics: [] }); return; } const diagnostics: Diagnostic[] = computed.map((diagnostic) => ({ @@ -91,7 +112,7 @@ export function createServer(connection: Connection): LanguageServer { severity: toLspSeverity(diagnostic.severity), source: 'prisma-next', })); - void connection.sendDiagnostics({ uri, diagnostics }); + sendDiagnostics({ uri, diagnostics }); } async function resolveProjectForDocument(uri: string): Promise { @@ -191,7 +212,7 @@ export function createServer(connection: Connection): LanguageServer { if (documentConfigPaths.get(document.uri) === configPath) { documentConfigPaths.delete(document.uri); if (hadProject) { - void connection.sendDiagnostics({ uri: document.uri, diagnostics: [] }); + sendDiagnostics({ uri: document.uri, diagnostics: [] }); } } } @@ -219,6 +240,9 @@ export function createServer(connection: Connection): LanguageServer { function publishSafely(uri: string): void { void publish(uri).catch((error: unknown) => { + if (disposed) { + return; + } connection.console.error(error instanceof Error ? error.message : String(error)); }); } @@ -288,10 +312,72 @@ export function createServer(connection: Connection): LanguageServer { return buildSemanticTokens(source, range); } + async function completeDocument(uri: string, position: Position): Promise { + const document = documents.get(uri); + if (document === undefined) { + return []; + } + + let project: ProjectState | undefined; + try { + project = await resolveProjectForDocument(uri); + } catch { + return []; + } + if (project === undefined || !project.inputs.includes(uri)) { + return []; + } + + const cached = currentDocumentArtifact(project, uri, document.getText()); + const symbolTable = project.artifacts.getSymbolTable(); + if (cached === undefined || symbolTable === undefined) { + return []; + } + + try { + const context = classifyPslCompletionContext({ + document: cached.document, + sourceFile: cached.sourceFile, + position, + }); + return [ + ...providePslCompletionItems({ + context, + sourceFile: cached.sourceFile, + candidates: { + scalarTypes: project.controlStack.scalarTypes, + pslBlockDescriptors: project.controlStack.pslBlockDescriptors, + symbolTable, + }, + clientSupportsSnippets, + }), + ]; + } catch { + return []; + } + } + + 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) { + return undefined; + } + return project.artifacts.getDocument(uri); + } + connection.onInitialize(async (params): Promise => { rootPath = resolveRootPath(params.rootUri, params.rootPath); watchedConfigGlob = join(rootPath, '**', CONFIG_FILENAME); supportsWatchedFilesRegistration = clientSupportsWatchedFilesRegistration(params); + clientSupportsSnippets = clientSupportsCompletionSnippets(params); return { capabilities: { @@ -303,6 +389,7 @@ export function createServer(connection: Connection): LanguageServer { full: true, range: true, }, + completionProvider: { triggerCharacters: ['.'] }, }, }; }); @@ -321,7 +408,7 @@ export function createServer(connection: Connection): LanguageServer { }) .catch(() => undefined); } else { - connection.console.warn( + logWarn( 'Client does not support dynamic file-watcher registration; Prisma Next config changes will not be picked up without a restart.', ); } @@ -343,6 +430,7 @@ export function createServer(connection: Connection): LanguageServer { }); connection.onDocumentFormatting((params) => formatDocument(params.textDocument.uri)); + connection.onCompletion((params) => completeDocument(params.textDocument.uri, params.position)); connection.languages.semanticTokens.on((params) => semanticTokensForDocument(params.textDocument.uri), @@ -381,7 +469,7 @@ export function createServer(connection: Connection): LanguageServer { projects.get(configPath)?.artifacts.remove(uri); } documentConfigPaths.delete(uri); - void connection.sendDiagnostics({ uri, diagnostics: [] }); + sendDiagnostics({ uri, diagnostics: [] }); }); documents.listen(connection); @@ -394,6 +482,7 @@ export function createServer(connection: Connection): LanguageServer { return { dispose: () => { + disposed = true; connection.dispose(); }, getDocumentAst: (uri) => artifactsForDocument(uri)?.getDocument(uri), @@ -422,6 +511,10 @@ function clientSupportsWatchedFilesRegistration(params: InitializeParams): boole return params.capabilities.workspace?.didChangeWatchedFiles?.dynamicRegistration === true; } +function clientSupportsCompletionSnippets(params: InitializeParams): boolean { + return params.capabilities.textDocument?.completion?.completionItem?.snippetSupport === true; +} + function resolveRootPath( rootUri: string | null | undefined, rootPath: string | null | undefined, diff --git a/packages/1-framework/3-tooling/language-server/test/completion-context.test.ts b/packages/1-framework/3-tooling/language-server/test/completion-context.test.ts new file mode 100644 index 0000000000..d553d8bb4c --- /dev/null +++ b/packages/1-framework/3-tooling/language-server/test/completion-context.test.ts @@ -0,0 +1,320 @@ +import { parse } from '@prisma-next/psl-parser/syntax'; +import { describe, expect, it } from 'vitest'; +import { classifyPslCompletionContext } from '../src/completion-context'; + +function classify(markedSource: string): ReturnType { + const cursorOffset = markedSource.indexOf('|'); + expect(cursorOffset).toBeGreaterThanOrEqual(0); + const source = `${markedSource.slice(0, cursorOffset)}${markedSource.slice(cursorOffset + 1)}`; + const { document, sourceFile } = parse(source); + + return classifyPslCompletionContext({ + document, + sourceFile, + position: sourceFile.positionAt(cursorOffset), + }); +} + +function expectUnsupported(markedSource: string): void { + expect(classify(markedSource)).toMatchObject({ kind: 'unsupported' }); +} + +describe('classifyPslCompletionContext', () => { + it('classifies blank document-level declaration keyword positions', () => { + const context = classify('|'); + + expect(context).toMatchObject({ + kind: 'declarationKeyword', + scope: 'document', + replacementStartOffset: 0, + offset: 0, + }); + }); + + it('classifies partial document-level declaration keyword prefixes', () => { + const context = classify('mo|'); + + expect(context).toMatchObject({ + kind: 'declarationKeyword', + scope: 'document', + replacementStartOffset: 0, + offset: 2, + }); + }); + + it('offers a declaration keyword after a closing brace on the same line', () => { + expect(classify('model User {} mo|')).toMatchObject({ + kind: 'declarationKeyword', + scope: 'document', + }); + }); + + it('offers a declaration keyword immediately after a complete declaration', () => { + expect(classify('model A {}|')).toMatchObject({ + kind: 'declarationKeyword', + scope: 'document', + }); + }); + + it('returns unsupported mid-header of an incomplete declaration', () => { + expectUnsupported('model A|'); + }); + + it('offers a document-level declaration keyword on a fresh line after a closed namespace', () => { + expect(classify(['namespace auth {', ' model A {}', '}', '|'].join('\n'))).toMatchObject({ + kind: 'declarationKeyword', + scope: 'document', + }); + }); + + it('classifies blank namespace-body declaration keyword positions', () => { + const context = classify(['namespace auth {', ' |', '}'].join('\n')); + + expect(context).toMatchObject({ + kind: 'declarationKeyword', + scope: 'namespace', + }); + }); + + it('classifies partial namespace-body declaration keyword prefixes', () => { + const context = classify(['namespace auth {', ' ty|', '}'].join('\n')); + + expect(context).toMatchObject({ + kind: 'declarationKeyword', + scope: 'namespace', + }); + }); + + it('classifies a blank model field type position', () => { + const context = classify(['model Post {', ' author |', '}'].join('\n')); + + expect(context).toMatchObject({ + kind: 'modelType', + fieldName: 'author', + }); + }); + + it('treats the next indented line as a blank model field type position', () => { + expect(classify(['model Post {', ' author', ' |', '}'].join('\n'))).toMatchObject({ + kind: 'modelType', + fieldName: 'author', + }); + }); + + it('does not treat a comment after the field name as a blank model field type position', () => { + expectUnsupported(['model Post {', ' author // |', '}'].join('\n')); + }); + + it('offers a model field type before a trailing comment', () => { + expect(classify(['model Post {', ' author |// note', '}'].join('\n'))).toMatchObject({ + kind: 'modelType', + fieldName: 'author', + }); + }); + + it('bounds the empty type slot at a field attribute', () => { + expect(classify(['model Post {', ' author |@id', '}'].join('\n'))).toMatchObject({ + kind: 'modelType', + fieldName: 'author', + }); + }); + + it('returns unsupported once the cursor is past a field attribute on a typeless field', () => { + expectUnsupported(['model Post {', ' author @id|', '}'].join('\n')); + }); + + it('returns unsupported when the cursor sits inside a typeless field attribute', () => { + expectUnsupported(['model Post {', ' author @i|d', '}'].join('\n')); + }); + + it('does not treat the cursor glued to the field name as a type slot', () => { + expectUnsupported(['model Post {', ' author|', '}'].join('\n')); + }); + + it('classifies a partial bare model field type prefix', () => { + const context = classify(['model Post {', ' reviewer U|', '}'].join('\n')); + + expect(context).toMatchObject({ + kind: 'modelType', + fieldName: 'reviewer', + }); + }); + + it('classifies the type position when the cursor sits inside a present field type', () => { + expect(classify(['model Post {', ' author In|t', '}'].join('\n'))).toMatchObject({ + kind: 'modelType', + fieldName: 'author', + }); + }); + + it('classifies namespace-qualified model field type prefixes', () => { + expect(classify(['model Post {', ' owner auth.|', '}'].join('\n'))).toMatchObject({ + kind: 'namespaceMember', + fieldName: 'owner', + namespace: 'auth', + }); + + expect(classify(['model Post {', ' editor auth.U|', '}'].join('\n'))).toMatchObject({ + kind: 'namespaceMember', + fieldName: 'editor', + namespace: 'auth', + }); + }); + + it('classifies contract-space-qualified model field type prefixes', () => { + expect(classify(['model Post {', ' external supabase:|', '}'].join('\n'))).toMatchObject({ + kind: 'spaceMember', + fieldName: 'external', + space: 'supabase', + }); + + expect( + classify(['model Post {', ' externalUser supabase:auth.|', '}'].join('\n')), + ).toMatchObject({ + kind: 'namespaceMember', + fieldName: 'externalUser', + namespace: 'auth', + space: 'supabase', + }); + + expect(classify(['model Post {', ' owner supabase:auth.U|', '}'].join('\n'))).toMatchObject({ + kind: 'namespaceMember', + fieldName: 'owner', + namespace: 'auth', + space: 'supabase', + }); + }); + + it('carries no space for a locally namespace-qualified prefix', () => { + const context = classify(['model Post {', ' owner auth.|', '}'].join('\n')); + + expect(context).toMatchObject({ + kind: 'namespaceMember', + fieldName: 'owner', + namespace: 'auth', + }); + expect(context).not.toHaveProperty('space'); + }); + + it('classifies a contract-space-qualified prefix without a namespace segment as a space member', () => { + expect(classify(['model Post {', ' external supabase:U|', '}'].join('\n'))).toMatchObject({ + kind: 'spaceMember', + fieldName: 'external', + space: 'supabase', + }); + }); + + it('resolves the namespace when the cursor sits mid-name', () => { + expect(classify(['model Post {', ' owner auth.Use|r', '}'].join('\n'))).toMatchObject({ + kind: 'namespaceMember', + fieldName: 'owner', + namespace: 'auth', + }); + }); + + it('returns unsupported in comments and trivia outside type positions', () => { + expectUnsupported(['model Post {', ' // U|', ' id Int', '}'].join('\n')); + expectUnsupported(['model Post {', ' |', ' id Int', '}'].join('\n')); + }); + + it('returns unsupported for ordinary field and block attributes', () => { + expectUnsupported(['model Post {', ' id Int @|', '}'].join('\n')); + expectUnsupported(['model Post {', ' id Int', ' @@|', '}'].join('\n')); + }); + + it('returns unsupported inside attribute arguments', () => { + expectUnsupported(['model Post {', ' id Int @default(|)', '}'].join('\n')); + expectUnsupported(['model Post {', ' authorId Int @relation(fields: [|])', '}'].join('\n')); + }); + + it('returns unsupported inside an attribute within a generic block', () => { + expectUnsupported(['policy Foo {', ' @@bar(baz|)', '}'].join('\n')); + }); + + it('returns unsupported inside field and model attribute arguments', () => { + expectUnsupported(['model M {', ' id Int @map(baz|)', '}'].join('\n')); + expectUnsupported(['model M {', ' @@map(baz|)', '}'].join('\n')); + }); + + it('classifies a composite-type field type position', () => { + expect(classify(['type Address {', ' city |', '}'].join('\n'))).toMatchObject({ + kind: 'modelType', + fieldName: 'city', + }); + + expect(classify(['type Address {', ' city U|', '}'].join('\n'))).toMatchObject({ + kind: 'modelType', + fieldName: 'city', + }); + }); + + it('classifies blank generic block key positions', () => { + const context = classify(['policy UserAccess {', ' |', '}'].join('\n')); + expect(context).toMatchObject({ + kind: 'genericBlockKey', + blockKeyword: 'policy', + }); + if (context.kind !== 'genericBlockKey') throw new Error('expected genericBlockKey'); + expect(context.block.keyword()?.text).toBe('policy'); + }); + + it('carries the enclosing block AST node for generic block key prefixes', () => { + const context = classify(['policy UserAccess {', ' on = User', ' wh|', '}'].join('\n')); + expect(context).toMatchObject({ + kind: 'genericBlockKey', + blockKeyword: 'policy', + }); + if (context.kind !== 'genericBlockKey') throw new Error('expected genericBlockKey'); + expect(context.block.keyword()?.text).toBe('policy'); + expect([...context.block.entries()].map((entry) => entry.key()?.name())).toEqual(['on', 'wh']); + }); + + it('classifies generic block value positions after the equals sign', () => { + expect(classify(['datasource db {', ' provider = |', '}'].join('\n'))).toMatchObject({ + kind: 'genericBlockValue', + blockKeyword: 'datasource', + }); + }); + + it('classifies a generic block value position flush against the equals sign', () => { + expect(classify(['datasource db {', ' provider =|', '}'].join('\n'))).toMatchObject({ + kind: 'genericBlockValue', + blockKeyword: 'datasource', + }); + }); + + it('classifies a partial generic block value prefix', () => { + expect(classify(['datasource db {', ' provider = fo|', '}'].join('\n'))).toMatchObject({ + kind: 'genericBlockValue', + blockKeyword: 'datasource', + }); + }); + + it('classifies the gap before = as a generic block key with an empty source range', () => { + const context = classify(['datasource db {', ' url |= "x"', '}'].join('\n')); + + expect(context).toMatchObject({ + kind: 'genericBlockKey', + blockKeyword: 'datasource', + }); + // rust-analyzer `source_range()` shape: the cursor sits in whitespace, so the + // edit range is empty at the cursor rather than synthesising the `url` key. + if (context.kind === 'genericBlockKey') { + expect(context.replacementStartOffset).toBe(context.offset); + } + }); + + it('returns unsupported inside type constructor arguments', () => { + expectUnsupported(['model Embedding {', ' vector Vector(|)', '}'].join('\n')); + }); + + it('returns unsupported outside model field type prefixes', () => { + expectUnsupported(['model Post {', ' |id Int', '}'].join('\n')); + expectUnsupported(['model Post {', ' id Int |', '}'].join('\n')); + }); + + it('returns unsupported for invalid over-qualified names', () => { + expectUnsupported(['model Post {', ' owner auth.domain.U|', '}'].join('\n')); + expectUnsupported(['model Post {', ' owner supabase:auth:U|', '}'].join('\n')); + }); +}); diff --git a/packages/1-framework/3-tooling/language-server/test/completion-provider.test.ts b/packages/1-framework/3-tooling/language-server/test/completion-provider.test.ts new file mode 100644 index 0000000000..a0d1fe8cce --- /dev/null +++ b/packages/1-framework/3-tooling/language-server/test/completion-provider.test.ts @@ -0,0 +1,414 @@ +import type { AuthoringPslBlockDescriptorNamespace } from '@prisma-next/framework-components/authoring'; +import { buildSymbolTable } from '@prisma-next/psl-parser'; +import { parse } from '@prisma-next/psl-parser/syntax'; +import { describe, expect, it } from 'vitest'; +import { CompletionItemKind, InsertTextFormat } from 'vscode-languageserver'; +import { classifyPslCompletionContext } from '../src/completion-context'; +import { providePslCompletionItems } from '../src/completion-provider'; + +const scalarTypes = ['String', 'Int', 'Boolean', 'DateTime'] as const; +const nameSnippetPlaceholder = '$' + '{1:Name}'; + +const pslBlockDescriptors: AuthoringPslBlockDescriptorNamespace = { + policy: { + kind: 'pslBlock', + keyword: 'policy', + discriminator: 'fixture-policy', + name: { required: true }, + parameters: { + on: { kind: 'ref', refKind: 'model', scope: 'same-space' }, + where: { kind: 'value', codecId: 'fixture/text@1' }, + mode: { kind: 'option', values: ['permissive', 'restrictive'] }, + using: { kind: 'value', codecId: 'fixture/text@1' }, + }, + }, + access: { + audit: { + kind: 'pslBlock', + keyword: 'audit', + discriminator: 'fixture-audit', + name: { required: true }, + parameters: { + on: { kind: 'ref', refKind: 'model', scope: 'same-space' }, + }, + }, + }, +}; + +const candidateSource = [ + 'types {', + ' Email = String', + ' UserId = User', + '}', + 'model User {', + ' id Int', + '}', + 'type Address {', + ' street String', + '}', + 'policy Audit {', + ' on = read', + '}', + 'namespace auth {', + ' model Account {', + ' id Int', + ' }', + ' model User {', + ' id Int', + ' }', + ' type Profile {', + ' displayName String', + ' }', + ' policy ScopedAudit {', + ' on = read', + ' }', + '}', +].join('\n'); + +function complete( + markedFieldSource: string, + options: { readonly clientSupportsSnippets?: boolean } = {}, +) { + const markedSource = `${candidateSource}\n${markedFieldSource}`; + const cursorOffset = markedSource.indexOf('|'); + expect(cursorOffset).toBeGreaterThanOrEqual(0); + const source = `${markedSource.slice(0, cursorOffset)}${markedSource.slice(cursorOffset + 1)}`; + const { document, sourceFile } = parse(source); + const { table: symbolTable } = buildSymbolTable({ + document, + sourceFile, + scalarTypes, + pslBlockDescriptors, + }); + const context = classifyPslCompletionContext({ + document, + sourceFile, + position: sourceFile.positionAt(cursorOffset), + }); + + return { + items: providePslCompletionItems({ + context, + sourceFile, + candidates: { scalarTypes, pslBlockDescriptors, symbolTable }, + clientSupportsSnippets: options.clientSupportsSnippets === true, + }), + sourceFile, + cursorOffset, + }; +} + +describe('providePslCompletionItems', () => { + it('returns document-level declaration keyword candidates with stable plain-text edits', () => { + const { items, sourceFile, cursorOffset } = complete('|'); + + expect(items.map((item) => item.label)).toEqual([ + 'model', + 'type', + 'types', + 'namespace', + 'audit', + 'policy', + ]); + expect(items.map((item) => item.detail)).toEqual([ + 'PSL declaration keyword', + 'PSL declaration keyword', + 'PSL declaration keyword', + 'PSL declaration keyword', + 'Generic block keyword', + 'Generic block keyword', + ]); + expect(items[0]).toMatchObject({ + kind: CompletionItemKind.Keyword, + filterText: 'model', + textEdit: { + range: { + start: sourceFile.positionAt(cursorOffset), + end: sourceFile.positionAt(cursorOffset), + }, + newText: 'model ', + }, + }); + expect(items[0]?.insertTextFormat).toBeUndefined(); + }); + + it('returns the full document-level declaration keyword set with a replace range over the typed segment', () => { + const { items, sourceFile, cursorOffset } = complete('mo|'); + + expect(items.map((item) => item.label)).toEqual([ + 'model', + 'type', + 'types', + 'namespace', + 'audit', + 'policy', + ]); + expect(items[0]).toMatchObject({ + filterText: 'model', + textEdit: { + range: { + start: sourceFile.positionAt(cursorOffset - 'mo'.length), + end: sourceFile.positionAt(cursorOffset), + }, + newText: 'model ', + }, + }); + }); + + it('returns namespace-body declaration keywords without document-only native keywords', () => { + const { items } = complete(['namespace feature {', ' |', '}'].join('\n')); + + expect(items.map((item) => item.label)).toEqual(['model', 'type', 'audit', 'policy']); + expect(items.map((item) => item.label)).not.toContain('types'); + expect(items.map((item) => item.label)).not.toContain('namespace'); + }); + + it('returns the full namespace-body declaration keyword set with a replace range over the typed segment', () => { + const { items, sourceFile, cursorOffset } = complete( + ['namespace feature {', ' po|', '}'].join('\n'), + ); + + expect(items.map((item) => item.label)).toEqual(['model', 'type', 'audit', 'policy']); + expect(items.find((item) => item.label === 'policy')).toMatchObject({ + filterText: 'policy', + textEdit: { + range: { + start: sourceFile.positionAt(cursorOffset - 'po'.length), + end: sourceFile.positionAt(cursorOffset), + }, + newText: 'policy ', + }, + }); + }); + + it('returns snippet declaration keyword edits only when the client supports snippets', () => { + const { items } = complete('|', { clientSupportsSnippets: true }); + + expect(items.find((item) => item.label === 'model')).toMatchObject({ + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { newText: `model ${nameSnippetPlaceholder} {\n $0\n}` }, + }); + expect(items.find((item) => item.label === 'policy')).toMatchObject({ + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { newText: `policy ${nameSnippetPlaceholder} {\n $0\n}` }, + }); + }); + + it('returns an empty list for ordinary model attribute contexts', () => { + const { items } = complete(['model Post {', ' id Int', ' @@|', '}'].join('\n')); + + expect(items).toEqual([]); + }); + + it('returns stable bare model field type completion candidates', () => { + const { items, sourceFile, cursorOffset } = complete( + ['model Post {', ' author |', '}'].join('\n'), + ); + + expect(items.map((item) => item.label)).toEqual([ + 'Boolean', + 'DateTime', + 'Int', + 'String', + 'Post', + 'User', + 'Address', + 'Email', + 'UserId', + 'auth', + ]); + expect(items.map((item) => item.detail)).toEqual([ + 'Configured scalar type', + 'Configured scalar type', + 'Configured scalar type', + 'Configured scalar type', + 'Model', + 'Model', + 'Composite type', + 'Scalar type', + 'Type alias', + 'Namespace', + ]); + expect(items[0]?.textEdit).toEqual({ + range: { + start: sourceFile.positionAt(cursorOffset), + end: sourceFile.positionAt(cursorOffset), + }, + newText: 'Boolean', + }); + }); + + it('returns the full bare candidate set with a replace range over the typed segment', () => { + const { items, sourceFile, cursorOffset } = complete( + ['model Post {', ' reviewer U|', '}'].join('\n'), + ); + + expect(items.map((item) => item.label)).toEqual([ + 'Boolean', + 'DateTime', + 'Int', + 'String', + 'Post', + 'User', + 'Address', + 'Email', + 'UserId', + 'auth', + ]); + expect(items.find((item) => item.label === 'User')).toMatchObject({ + filterText: 'User', + textEdit: { + range: { + start: sourceFile.positionAt(cursorOffset - 'U'.length), + end: sourceFile.positionAt(cursorOffset), + }, + newText: 'User', + }, + }); + }); + + it('returns the full bare candidate set including the namespace qualifier with a replace range over the typed segment', () => { + const { items, sourceFile, cursorOffset } = complete( + ['model Post {', ' reviewer a|', '}'].join('\n'), + ); + + expect(items.map((item) => item.label)).toEqual([ + 'Boolean', + 'DateTime', + 'Int', + 'String', + 'Post', + 'User', + 'Address', + 'Email', + 'UserId', + 'auth', + ]); + expect(items.find((item) => item.label === 'auth')).toMatchObject({ + kind: CompletionItemKind.Module, + detail: 'Namespace', + filterText: 'auth', + textEdit: { + range: { + start: sourceFile.positionAt(cursorOffset - 'a'.length), + end: sourceFile.positionAt(cursorOffset), + }, + newText: 'auth', + }, + }); + }); + + it('returns namespace members after a namespace qualifier', () => { + const { items, sourceFile, cursorOffset } = complete( + ['model Post {', ' owner auth.|', '}'].join('\n'), + ); + + expect(items.map((item) => item.label)).toEqual(['Account', 'User', 'Profile']); + expect(items[0]?.textEdit).toEqual({ + range: { + start: sourceFile.positionAt(cursorOffset), + end: sourceFile.positionAt(cursorOffset), + }, + newText: 'Account', + }); + }); + + it('returns the full namespace member set with replacement metadata for the typed segment', () => { + const { items, sourceFile, cursorOffset } = complete( + ['model Post {', ' owner auth.U|', '}'].join('\n'), + ); + + expect(items.map((item) => item.label)).toEqual(['Account', 'User', 'Profile']); + expect(items.find((item) => item.label === 'User')).toMatchObject({ + filterText: 'User', + detail: 'Model in namespace auth', + textEdit: { + range: { + start: sourceFile.positionAt(cursorOffset - 'U'.length), + end: sourceFile.positionAt(cursorOffset), + }, + newText: 'User', + }, + }); + }); + + it('does not leak local namespace members into a foreign contract-space reference', () => { + const { items } = complete(['model Post {', ' owner supabase:auth.P|', '}'].join('\n')); + + expect(items).toEqual([]); + }); + + it('returns no completions for a contract-space-qualified position', () => { + const { items } = complete(['model Post {', ' external supabase:|', '}'].join('\n')); + + expect(items).toEqual([]); + }); + + it('returns no completions for a generic block value position', () => { + const { items } = complete(['policy Rule {', ' on = |', '}'].join('\n')); + + expect(items).toEqual([]); + }); + + it('returns descriptor-backed generic block parameter completions', () => { + const { items, sourceFile, cursorOffset } = complete(['policy Rule {', ' |', '}'].join('\n')); + + expect(items.map((item) => item.label)).toEqual(['on', 'where', 'mode', 'using']); + expect(items.map((item) => item.detail)).toEqual([ + 'Generic block parameter', + 'Generic block parameter', + 'Generic block parameter', + 'Generic block parameter', + ]); + expect(items[0]?.textEdit).toEqual({ + range: { + start: sourceFile.positionAt(cursorOffset), + end: sourceFile.positionAt(cursorOffset), + }, + newText: 'on', + }); + }); + + it('returns the full descriptor-backed generic block parameter set excluding already-present sibling keys', () => { + const { items, sourceFile, cursorOffset } = complete( + ['policy Rule {', ' on = User', ' wh|', '}'].join('\n'), + ); + + expect(items.map((item) => item.label)).toEqual(['where', 'mode', 'using']); + expect(items.find((item) => item.label === 'where')).toMatchObject({ + filterText: 'where', + textEdit: { + range: { + start: sourceFile.positionAt(cursorOffset - 'wh'.length), + end: sourceFile.positionAt(cursorOffset), + }, + newText: 'where', + }, + }); + }); + + it('still offers the in-progress key while excluding an already-present sibling key', () => { + const { items } = complete(['policy Rule {', ' where = "x"', ' on|', '}'].join('\n')); + + expect(items.map((item) => item.label)).toEqual(['on', 'mode', 'using']); + }); + + it('returns no generic block parameter completions without a matching descriptor', () => { + const { items } = complete(['extension Rule {', ' |', '}'].join('\n')); + + expect(items).toEqual([]); + }); + + it('returns an empty list for unsupported classifier contexts', () => { + const { items } = complete(['model Post {', ' id Int @|', '}'].join('\n')); + + expect(items).toEqual([]); + }); + + it('does not return generic block symbols as model field type candidates', () => { + const { items } = complete(['model Post {', ' audit |', '}'].join('\n')); + + expect(items.map((item) => item.label)).not.toContain('Audit'); + expect(items.map((item) => item.label)).not.toContain('auth.ScopedAudit'); + }); +}); 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 984ce8aa76..014a17a51e 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 @@ -2,6 +2,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { PassThrough } from 'node:stream'; import { pathToFileURL } from 'node:url'; +import type { AuthoringPslBlockDescriptorNamespace } from '@prisma-next/framework-components/authoring'; import { buildSymbolTable, type SymbolTable } from '@prisma-next/psl-parser'; import type { FormatOptions } from '@prisma-next/psl-parser/format'; import { type ParseDiagnostic, parse } from '@prisma-next/psl-parser/syntax'; @@ -9,6 +10,9 @@ import { timeouts } from '@prisma-next/test-utils'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { type ClientCapabilities, + type CompletionItem, + type CompletionList, + CompletionRequest, createConnection, type Diagnostic, DiagnosticSeverity, @@ -21,8 +25,10 @@ import { InitializedNotification, InitializeRequest, type InitializeResult, + InsertTextFormat, LogMessageNotification, MessageType, + type Position, PublishDiagnosticsNotification, type Range, type RegistrationParams, @@ -74,16 +80,32 @@ const unformattedPsl = 'model User {\nid Int\n}'; const formattedPsl = 'model User {\n id Int\n}\n'; const scalarTypes = ['String', 'Int', 'Boolean', 'DateTime'] as const; +const nameSnippetPlaceholder = '$' + '{1:Name}'; + +const pslBlockDescriptors: AuthoringPslBlockDescriptorNamespace = { + policy: { + kind: 'pslBlock', + keyword: 'policy', + discriminator: 'fixture-policy', + name: { required: true }, + parameters: { + on: { kind: 'ref', refKind: 'model', scope: 'same-space' }, + where: { kind: 'value', codecId: 'fixture/text@1' }, + mode: { kind: 'option', values: ['permissive', 'restrictive'] }, + }, + }, +}; function resolutionForInputs( inputs: readonly string[], formatter?: FormatOptions, + descriptors: AuthoringPslBlockDescriptorNamespace = {}, ): ConfigResolutionWithFormatter { const resolution = { inputs: resolveSchemaInputs({ contract: { source: { sourceFormat: 'psl', inputs } }, }), - controlStack: { scalarTypes: [...scalarTypes], pslBlockDescriptors: {} }, + controlStack: { scalarTypes: [...scalarTypes], pslBlockDescriptors: descriptors }, }; return formatter === undefined ? resolution : { ...resolution, formatter }; } @@ -96,6 +118,8 @@ function emptyResolution(): ConfigResolution { } const resolveToSchema: ResolveInputs = async () => resolutionForInputs([schemaPath]); +const resolveToSchemaWithPslBlockDescriptors: ResolveInputs = async () => + resolutionForInputs([schemaPath], undefined, pslBlockDescriptors); function resolveToSchemaWithFormatter(formatter: FormatOptions): ResolveInputs { return async () => resolutionForInputs([schemaPath], formatter); @@ -131,6 +155,10 @@ const watchedFilesCapabilities: ClientCapabilities = { workspace: { didChangeWatchedFiles: { dynamicRegistration: true } }, }; +const snippetCompletionCapabilities: ClientCapabilities = { + textDocument: { completion: { completionItem: { snippetSupport: true } } }, +}; + interface Harness { readonly client: ReturnType; readonly initialize: () => Promise; @@ -388,6 +416,43 @@ function semanticTokenChunks(tokens: SemanticTokens | null): readonly (readonly return chunks; } +function requestCompletion( + harness: Harness, + uri: string, + position: Position, +): Promise { + return harness.client.sendRequest(CompletionRequest.type, { + textDocument: { uri }, + position, + }); +} + +function completionItems( + result: CompletionItem[] | CompletionList | null, +): readonly CompletionItem[] { + if (result === null) { + return []; + } + return Array.isArray(result) ? result : result.items; +} + +function sourceWithCursor(markedSource: string): { + readonly source: string; + readonly position: Position; +} { + const cursorOffset = markedSource.indexOf('|'); + if (cursorOffset < 0) { + throw new Error('Missing cursor marker'); + } + const prefix = markedSource.slice(0, cursorOffset); + const source = `${prefix}${markedSource.slice(cursorOffset + 1)}`; + const lines = prefix.split('\n'); + return { + source, + position: { line: lines.length - 1, character: (lines[lines.length - 1] ?? '').length }, + }; +} + function deferred(): { readonly promise: Promise; readonly resolve: (value: T) => void } { let resolvePromise: (value: T) => void = () => undefined; const promise = new Promise((resolve) => { @@ -396,11 +461,28 @@ function deferred(): { readonly promise: Promise; readonly resolve: (value return { promise, resolve: resolvePromise }; } +function deferredSettleable(): { + readonly promise: Promise; + readonly resolve: (value: T) => void; + readonly reject: (reason: unknown) => void; +} { + let resolvePromise: (value: T) => void = () => undefined; + let rejectPromise: (reason: unknown) => void = () => undefined; + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }); + return { promise, resolve: resolvePromise, reject: rejectPromise }; +} + let harness: Harness | undefined; afterEach(async () => { - // Let any in-flight JSON-RPC writes settle before tearing the streams down, - // so disposing the connections doesn't reject a notification mid-transmission. + // The server's `disposed` guard (see `createServer`) is what prevents an + // in-flight `publish` from sending on a disposed connection. This tick is a + // separate concern: it lets any in-flight JSON-RPC request/response write + // flush before the streams are torn down, so vscode-jsonrpc's own internal + // error logging doesn't reject a notification mid-transmission. await new Promise((resolve) => setTimeout(resolve, 0)); harness?.dispose(); harness = undefined; @@ -409,7 +491,7 @@ afterEach(async () => { }); describe('language server', { timeout: timeouts.databaseOperation }, () => { - it('answers initialize and advertises text-document features', async () => { + it('answers initialize and advertises text-document features plus completion support', async () => { harness = startHarness(resolveToSchema); const result = await harness.initialize(); expect(result.capabilities.textDocumentSync).toBeDefined(); @@ -420,6 +502,171 @@ describe('language server', { timeout: timeouts.databaseOperation }, () => { full: true, range: true, }); + expect(result.capabilities.completionProvider).toEqual({ triggerCharacters: ['.'] }); + }); + + it('returns model field type completions for configured PSL inputs', async () => { + harness = startHarness(resolveToSchema); + await harness.initialize(); + const { source, position } = sourceWithCursor( + [ + 'model User {', + ' id Int @id', + '}', + '', + 'type Address {', + ' street String', + '}', + '', + 'model Post {', + ' id Int @id', + ' author |', + '}', + ].join('\n'), + ); + openDocument(harness, schemaUri, source); + await harness.waitForDiagnostics(schemaUri); + + const items = completionItems(await requestCompletion(harness, schemaUri, position)); + expect(items.map((item) => item.label)).toEqual([ + 'Boolean', + 'DateTime', + 'Int', + 'String', + 'Post', + 'User', + 'Address', + ]); + }); + + 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 updated = sourceWithCursor( + ['model User {', ' id Int @id', '}', '', 'model Post {', ' author U|', '}'].join('\n'), + ); + openDocument(harness, schemaUri, initial); + await harness.waitForDiagnostics(schemaUri); + await harness.waitForDiagnosticsCount(schemaUri, 2); + + 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; + }); + + 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')); + openDocument(harness, schemaUri, source); + await harness.waitForDiagnostics(schemaUri); + + const items = completionItems(await requestCompletion(harness, schemaUri, position)); + expect(items.map((item) => item.label)).toEqual(['on', 'where', 'mode']); + }); + + it('returns declaration keyword completions with plain-text edits by default', async () => { + harness = startHarness(resolveToSchemaWithPslBlockDescriptors); + await harness.initialize(); + const { source, position } = sourceWithCursor('|'); + openDocument(harness, schemaUri, source); + await harness.waitForDiagnostics(schemaUri); + + const items = completionItems(await requestCompletion(harness, schemaUri, position)); + expect(items.map((item) => item.label)).toEqual([ + 'model', + 'type', + 'types', + 'namespace', + 'policy', + ]); + expect(items.find((item) => item.label === 'model')).toMatchObject({ + textEdit: { newText: 'model ' }, + }); + expect(items.find((item) => item.label === 'model')?.insertTextFormat).toBeUndefined(); + }); + + it('returns declaration keyword snippets when the client supports snippets', async () => { + harness = startHarness(resolveToSchemaWithPslBlockDescriptors, snippetCompletionCapabilities); + await harness.initialize(); + const { source, position } = sourceWithCursor('|'); + openDocument(harness, schemaUri, source); + await harness.waitForDiagnostics(schemaUri); + + const items = completionItems(await requestCompletion(harness, schemaUri, position)); + expect(items.find((item) => item.label === 'model')).toMatchObject({ + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { newText: `model ${nameSnippetPlaceholder} {\n $0\n}` }, + }); + expect(items.find((item) => item.label === 'policy')).toMatchObject({ + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { newText: `policy ${nameSnippetPlaceholder} {\n $0\n}` }, + }); + }); + + 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')); + openDocument(harness, schemaUri, source); + await harness.waitForDiagnostics(schemaUri); + + const items = completionItems(await requestCompletion(harness, schemaUri, position)); + expect(items.map((item) => item.label)).toEqual(['model', 'type', 'policy']); + expect(items.map((item) => item.label)).not.toContain('types'); + expect(items.map((item) => item.label)).not.toContain('namespace'); + }); + + it('returns no completion items for unconfigured PSL documents', async () => { + harness = startHarness(resolveToSchema); + 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'), + ); + openDocument(harness, otherUri, source); + expect(await harness.waitForDiagnostics(otherUri)).toEqual([]); + + const items = completionItems(await requestCompletion(harness, otherUri, position)); + expect(items).toEqual([]); + }); + + 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')); + openDocument(harness, schemaUri, source); + await harness.waitForDiagnostics(schemaUri); + + const items = completionItems(await requestCompletion(harness, schemaUri, position)); + expect(items).toEqual([]); + }); + + it('returns no completion items for ordinary model attribute contexts', async () => { + harness = startHarness(resolveToSchema); + await harness.initialize(); + const { source, position } = sourceWithCursor( + ['model User {', ' id Int @id', ' @@|', '}'].join('\n'), + ); + openDocument(harness, schemaUri, source); + await harness.waitForDiagnostics(schemaUri); + + const items = completionItems(await requestCompletion(harness, schemaUri, position)); + expect(items).toEqual([]); }); it('publishes parser diagnostics for an opened configured PSL input', async () => { @@ -1393,3 +1640,47 @@ describe('language server preserved artifacts', { timeout: timeouts.databaseOper expect(harness.getProjectSymbolTable(schemaUri)).toBeUndefined(); }); }); + +describe('language server disposal', { timeout: timeouts.databaseOperation }, () => { + async function assertNoUnhandledRejection( + settle: (load: { + readonly resolve: (value: ConfigResolution) => void; + readonly reject: (reason: unknown) => void; + }) => void, + ): Promise { + const load = deferredSettleable(); + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown): void => { + unhandled.push(reason); + }; + process.on('unhandledRejection', onUnhandled); + try { + harness = startHarness(async () => load.promise); + await harness.initialize(); + + openDocument(harness, schemaUri, duplicateModelSource); + await waitUntil(() => configResolutionMock.resolveConfigInputs.mock.calls.length > 0); + + harness.dispose(); + harness = undefined; + + settle(load); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(unhandled).toEqual([]); + } finally { + process.off('unhandledRejection', onUnhandled); + } + } + + it('does not reject when an in-flight publish resolves after dispose', async () => { + await assertNoUnhandledRejection((load) => load.resolve(resolutionForInputs([schemaPath]))); + }); + + it('does not reject when an in-flight publish rejects after dispose', async () => { + await assertNoUnhandledRejection((load) => + load.reject(new Error('config load failed after dispose')), + ); + }); +});