From 1857e2e35088eae9e0587170b9eb80b0e1326cff Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Thu, 25 Jun 2026 13:28:46 +0000 Subject: [PATCH 01/54] TML-2946: add PSL model type completions Signed-off-by: Serhii Tatarintsev --- .../3-tooling/language-server/README.md | 11 +- .../language-server/src/completion-context.ts | 397 ++++++++++++++++++ .../src/completion-provider.ts | 253 +++++++++++ .../3-tooling/language-server/src/server.ts | 49 +++ .../test/completion-context.test.ts | 165 ++++++++ .../test/completion-provider.test.ts | 165 ++++++++ .../language-server/test/server.test.ts | 116 ++++- projects/lsp-autocomplete/plan.md | 41 ++ .../01-cursor-classifier-substrate.md | 72 ++++ .../dispatches/02-model-type-provider.md | 70 +++ .../dispatches/03-lsp-completion-route.md | 75 ++++ .../04-scope-docs-and-final-guardrails.md | 70 +++ .../slices/model-type-completions/plan.md | 82 ++++ .../slices/model-type-completions/spec.md | 106 +++++ projects/lsp-autocomplete/spec.md | 110 +++++ projects/lsp-autocomplete/trace.jsonl | 27 ++ 16 files changed, 1804 insertions(+), 5 deletions(-) create mode 100644 packages/1-framework/3-tooling/language-server/src/completion-context.ts create mode 100644 packages/1-framework/3-tooling/language-server/src/completion-provider.ts create mode 100644 packages/1-framework/3-tooling/language-server/test/completion-context.test.ts create mode 100644 packages/1-framework/3-tooling/language-server/test/completion-provider.test.ts create mode 100644 projects/lsp-autocomplete/plan.md create mode 100644 projects/lsp-autocomplete/slices/model-type-completions/dispatches/01-cursor-classifier-substrate.md create mode 100644 projects/lsp-autocomplete/slices/model-type-completions/dispatches/02-model-type-provider.md create mode 100644 projects/lsp-autocomplete/slices/model-type-completions/dispatches/03-lsp-completion-route.md create mode 100644 projects/lsp-autocomplete/slices/model-type-completions/dispatches/04-scope-docs-and-final-guardrails.md create mode 100644 projects/lsp-autocomplete/slices/model-type-completions/plan.md create mode 100644 projects/lsp-autocomplete/slices/model-type-completions/spec.md create mode 100644 projects/lsp-autocomplete/spec.md create mode 100644 projects/lsp-autocomplete/trace.jsonl diff --git a/packages/1-framework/3-tooling/language-server/README.md b/packages/1-framework/3-tooling/language-server/README.md index 7d9d2d8e51..b2525db23b 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, and model field type 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 and only at model field type positions. It suggests configured scalar types plus visible model, composite type, scalar, and type-alias candidates from the current project symbol table, including namespace-qualified and contract-space-qualified type-position syntax when those candidates are visible in the current cached artifacts. Generic block entry or parameter completions, ordinary PSL `@` / `@@` attribute completions, attribute argument 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 from cached parse artifacts. It classifies the cursor using the cached AST/source file, reads the current project symbol table, and returns `[]` for missing or closed documents, unconfigured documents, unavailable artifacts, unsupported contexts, ordinary attributes or attribute arguments, generic block contexts, 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 and marking everything outside slice scope unsupported. +- `completion-provider.ts` — pure completion item provider for supported model field type 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..0ea195930b --- /dev/null +++ b/packages/1-framework/3-tooling/language-server/src/completion-context.ts @@ -0,0 +1,397 @@ +import { + AttributeArgListAst, + type DocumentAst, + FieldAttributeAst, + FieldDeclarationAst, + GenericBlockDeclarationAst, + KeyValuePairAst, + ModelAttributeAst, + ModelDeclarationAst, + type Position, + type QualifiedNameAst, + type SourceFile, + type SyntaxNode, + type SyntaxToken, + TypeAnnotationAst, +} from '@prisma-next/psl-parser/syntax'; + +export interface ClassifyPslCompletionContextInput { + readonly document: DocumentAst; + readonly sourceFile: SourceFile; + readonly position: Position; +} + +export type UnsupportedPslCompletionReason = + | 'attribute' + | 'attributeArgument' + | 'comment' + | 'constructorArgument' + | 'fieldName' + | 'genericBlock' + | 'invalidQualifiedType' + | 'notTypePrefix' + | 'outsideModelField'; + +export interface TypeNamePrefix { + readonly path: readonly string[]; + readonly contractSpace?: string; + readonly namespace?: string; + readonly name: string; +} + +export interface ModelFieldTypeCompletionContext { + readonly kind: 'modelFieldType'; + readonly offset: number; + readonly fieldName: string; + readonly prefix: TypeNamePrefix; +} + +export interface UnsupportedPslCompletionContext { + readonly kind: 'unsupported'; + readonly offset: number; + readonly reason: UnsupportedPslCompletionReason; +} + +export type PslCompletionContext = + | ModelFieldTypeCompletionContext + | UnsupportedPslCompletionContext; + +interface TokenContext { + readonly current?: SyntaxToken; + readonly previous?: SyntaxToken; + readonly previousSignificant?: SyntaxToken; + readonly touching?: SyntaxToken; +} + +export function classifyPslCompletionContext( + input: ClassifyPslCompletionContextInput, +): PslCompletionContext { + const offset = input.sourceFile.offsetAt(input.position); + const tokenContext = findTokenContext(input.document.syntax, offset); + if (tokenContext.current?.kind === 'Comment' || tokenContext.touching?.kind === 'Comment') { + return unsupported(offset, 'comment'); + } + + const node = findDeepestNodeAtOffset(input.document.syntax, offset); + const previousNode = + tokenContext.previousSignificant === undefined + ? undefined + : findDeepestNodeAtOffset(input.document.syntax, tokenContext.previousSignificant.offset); + const contextNode = nodeForContext(node, previousNode); + const ancestorReason = unsupportedAncestorReason(contextNode); + if (ancestorReason !== undefined) { + return unsupported(offset, ancestorReason); + } + + const field = closestAst(contextNode, FieldDeclarationAst.cast); + if (field === undefined) { + return unsupported(offset, 'outsideModelField'); + } + if (closestAst(field.syntax, ModelDeclarationAst.cast) === undefined) { + return unsupported(offset, 'outsideModelField'); + } + + return classifyModelFieldType({ + field, + offset, + sourceFile: input.sourceFile, + }); +} + +function classifyModelFieldType(input: { + readonly field: FieldDeclarationAst; + readonly offset: number; + readonly sourceFile: SourceFile; +}): PslCompletionContext { + const fieldName = input.field.name(); + const fieldNameText = fieldName?.name(); + if (fieldName === undefined || fieldNameText === undefined) { + return unsupported(input.offset, 'outsideModelField'); + } + + const fieldNameStart = fieldName.syntax.offset; + const fieldNameEnd = endOffset(fieldName.syntax); + if (input.offset >= fieldNameStart && input.offset <= fieldNameEnd) { + return unsupported(input.offset, 'fieldName'); + } + + const typeAnnotation = input.field.typeAnnotation(); + if (typeAnnotation === undefined) { + return unsupported(input.offset, 'outsideModelField'); + } + + const typeStart = typeAnnotation.syntax.offset; + const typeEnd = endOffset(typeAnnotation.syntax); + if (typeAnnotation.syntax.textLength === 0) { + if ( + input.offset > fieldNameEnd && + input.offset <= typeStart && + hasOnlyHorizontalWhitespace(input.sourceFile.text, fieldNameEnd, input.offset) + ) { + return modelFieldType(input.offset, fieldNameText, { path: [], name: '' }); + } + return unsupported(input.offset, 'notTypePrefix'); + } + + if (input.offset < typeStart || input.offset > typeEnd) { + return unsupported(input.offset, 'notTypePrefix'); + } + + const constructorArgList = typeAnnotation.argList(); + if (constructorArgList !== undefined && containsOffset(constructorArgList.syntax, input.offset)) { + return unsupported(input.offset, 'constructorArgument'); + } + + const name = typeAnnotation.name(); + if (name === undefined) { + return unsupported(input.offset, 'notTypePrefix'); + } + if (!containsOffset(name.syntax, input.offset)) { + return unsupported(input.offset, 'notTypePrefix'); + } + if (name.isOverQualified()) { + return unsupported(input.offset, 'invalidQualifiedType'); + } + + const prefix = typeNamePrefix(name, input.offset, input.sourceFile.text); + if (prefix === undefined) { + return unsupported(input.offset, 'invalidQualifiedType'); + } + + return modelFieldType(input.offset, fieldNameText, prefix); +} + +function modelFieldType( + offset: number, + fieldName: string, + prefix: TypeNamePrefix, +): ModelFieldTypeCompletionContext { + return { kind: 'modelFieldType', offset, fieldName, prefix }; +} + +function unsupported( + offset: number, + reason: UnsupportedPslCompletionReason, +): UnsupportedPslCompletionContext { + return { kind: 'unsupported', offset, reason }; +} + +function unsupportedAncestorReason( + node: SyntaxNode | undefined, +): UnsupportedPslCompletionReason | undefined { + const argList = closestAst(node, AttributeArgListAst.cast); + if (argList !== undefined) { + return closestAst(argList.syntax, TypeAnnotationAst.cast) === undefined + ? 'attributeArgument' + : 'constructorArgument'; + } + if ( + closestAst(node, FieldAttributeAst.cast) !== undefined || + closestAst(node, ModelAttributeAst.cast) !== undefined + ) { + return 'attribute'; + } + if ( + closestAst(node, GenericBlockDeclarationAst.cast) !== undefined || + closestAst(node, KeyValuePairAst.cast) !== undefined + ) { + return 'genericBlock'; + } + return undefined; +} + +function typeNamePrefix( + name: QualifiedNameAst, + offset: number, + source: string, +): TypeNamePrefix | undefined { + const end = Math.min(offset, endOffset(name.syntax)); + const raw = splitQualifiedPrefix(source.slice(name.syntax.offset, end)); + if (raw.colonCount > 1 || raw.dotCount > 1) { + return undefined; + } + + if (raw.colonCount === 0 && raw.dotCount === 0) { + const nameSegment = segmentAt(raw.segments, 0); + if (nameSegment === undefined) return undefined; + return { path: pathFromSegments(raw.segments), name: nameSegment }; + } + + if (raw.colonCount === 0 && raw.dotCount === 1) { + const namespace = segmentAt(raw.segments, 0); + const nameSegment = segmentAt(raw.segments, 1); + if (namespace === undefined || namespace.length === 0 || nameSegment === undefined) { + return undefined; + } + return { path: pathFromSegments(raw.segments), namespace, name: nameSegment }; + } + + if (raw.colonCount === 1 && raw.dotCount === 0) { + const contractSpace = segmentAt(raw.segments, 0); + const nameSegment = segmentAt(raw.segments, 1); + if (contractSpace === undefined || contractSpace.length === 0 || nameSegment === undefined) { + return undefined; + } + return { path: pathFromSegments(raw.segments), contractSpace, name: nameSegment }; + } + + const contractSpace = segmentAt(raw.segments, 0); + const namespace = segmentAt(raw.segments, 1); + const nameSegment = segmentAt(raw.segments, 2); + if ( + contractSpace === undefined || + contractSpace.length === 0 || + namespace === undefined || + namespace.length === 0 || + nameSegment === undefined + ) { + return undefined; + } + return { + path: pathFromSegments(raw.segments), + contractSpace, + namespace, + name: nameSegment, + }; +} + +function splitQualifiedPrefix(text: string): { + readonly segments: readonly string[]; + readonly colonCount: number; + readonly dotCount: number; +} { + const segments = ['']; + let colonCount = 0; + let dotCount = 0; + for (let index = 0; index < text.length; index++) { + const char = text.charAt(index); + if (char === ':') { + colonCount++; + segments.push(''); + continue; + } + if (char === '.') { + dotCount++; + segments.push(''); + continue; + } + const lastIndex = segments.length - 1; + segments[lastIndex] = `${segments[lastIndex] ?? ''}${char}`; + } + return { segments, colonCount, dotCount }; +} + +function pathFromSegments(segments: readonly string[]): readonly string[] { + return segments.filter((segment) => segment.length > 0); +} + +function segmentAt(segments: readonly string[], index: number): string | undefined { + return segments[index]; +} + +function nodeForContext( + node: SyntaxNode | undefined, + previousNode: SyntaxNode | undefined, +): SyntaxNode | undefined { + if (node === undefined || node.kind === 'Document' || node.kind === 'ModelDeclaration') { + return previousNode ?? node; + } + return node; +} + +function closestAst( + node: SyntaxNode | undefined, + cast: (node: SyntaxNode) => T | undefined, +): T | undefined { + for (let current = node; current !== undefined; current = current.parent) { + const result = cast(current); + if (result !== undefined) return result; + } + return undefined; +} + +function findDeepestNodeAtOffset(node: SyntaxNode, offset: number): SyntaxNode | undefined { + if (!containsOffset(node, offset)) { + return undefined; + } + let deepest = node; + for (const child of node.childNodes()) { + const childMatch = findDeepestNodeAtOffset(child, offset); + if (childMatch !== undefined) { + deepest = childMatch; + } + } + return deepest; +} + +function findTokenContext(root: SyntaxNode, offset: number): TokenContext { + let current: SyntaxToken | undefined; + let previous: SyntaxToken | undefined; + let previousSignificant: SyntaxToken | undefined; + let touching: SyntaxToken | undefined; + + for (const token of root.tokens()) { + const tokenEnd = token.offset + token.text.length; + if (offset >= token.offset && offset < tokenEnd) { + current = token; + } + if (offset > token.offset && offset <= tokenEnd) { + touching = token; + } + if (tokenEnd <= offset) { + previous = token; + if (!isTrivia(token)) { + previousSignificant = token; + } + continue; + } + if (token.offset > offset) { + break; + } + } + + return tokenContext({ current, previous, previousSignificant, touching }); +} + +function tokenContext(input: { + readonly current: SyntaxToken | undefined; + readonly previous: SyntaxToken | undefined; + readonly previousSignificant: SyntaxToken | undefined; + readonly touching: SyntaxToken | undefined; +}): TokenContext { + return { + ...(input.current === undefined ? {} : { current: input.current }), + ...(input.previous === undefined ? {} : { previous: input.previous }), + ...(input.previousSignificant === undefined + ? {} + : { previousSignificant: input.previousSignificant }), + ...(input.touching === undefined ? {} : { touching: input.touching }), + }; +} + +function isTrivia(token: SyntaxToken): boolean { + return token.kind === 'Whitespace' || token.kind === 'Newline' || token.kind === 'Comment'; +} + +function containsOffset(node: SyntaxNode, offset: number): boolean { + const start = node.offset; + const end = endOffset(node); + return node.textLength === 0 ? offset === start : offset >= start && offset <= end; +} + +function endOffset(node: SyntaxNode): number { + return node.offset + node.textLength; +} + +function hasOnlyHorizontalWhitespace(source: string, start: number, end: number): boolean { + if (end <= start) { + return false; + } + for (let index = start; index < end; index++) { + const char = source.charAt(index); + if (char !== ' ' && char !== '\t') { + return false; + } + } + return true; +} 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..f7ae9bcb7a --- /dev/null +++ b/packages/1-framework/3-tooling/language-server/src/completion-provider.ts @@ -0,0 +1,253 @@ +import type { NamespaceSymbol, SymbolTable } from '@prisma-next/psl-parser'; +import type { SourceFile } from '@prisma-next/psl-parser/syntax'; +import { type CompletionItem, CompletionItemKind } from 'vscode-languageserver'; +import type { ModelFieldTypeCompletionContext, PslCompletionContext } from './completion-context'; + +export interface PslCompletionCandidateSource { + readonly scalarTypes: readonly string[]; + readonly symbolTable?: SymbolTable; +} + +export interface ProvidePslCompletionItemsInput { + readonly context: PslCompletionContext; + readonly sourceFile: SourceFile; + readonly candidates: PslCompletionCandidateSource; +} + +type ModelTypeCompletionCandidateCategory = + | 'configuredScalar' + | 'topLevelModel' + | 'topLevelCompositeType' + | 'scalar' + | 'typeAlias' + | 'namespaceModel' + | 'namespaceCompositeType'; + +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, + namespaceModel: 5, + namespaceCompositeType: 6, +}; + +export function providePslCompletionItems( + input: ProvidePslCompletionItemsInput, +): readonly CompletionItem[] { + if (input.context.kind === 'unsupported') { + return []; + } + return provideModelFieldTypeCompletionItems(input.context, input.sourceFile, input.candidates); +} + +function provideModelFieldTypeCompletionItems( + context: ModelFieldTypeCompletionContext, + sourceFile: SourceFile, + source: PslCompletionCandidateSource, +): readonly CompletionItem[] { + const replacementRange = { + start: sourceFile.positionAt(context.offset - context.prefix.name.length), + end: sourceFile.positionAt(context.offset), + }; + + return candidatesForContext(context, source) + .filter((candidate) => candidate.filterText.startsWith(context.prefix.name)) + .map((candidate) => ({ + label: candidate.label, + kind: candidate.kind, + detail: candidate.detail, + sortText: sortText(candidate), + filterText: candidate.filterText, + textEdit: { + range: replacementRange, + newText: candidate.insertText, + }, + })); +} + +function candidatesForContext( + context: ModelFieldTypeCompletionContext, + source: PslCompletionCandidateSource, +): readonly ModelTypeCompletionCandidate[] { + const namespace = context.prefix.namespace; + if (namespace !== undefined) { + return namespaceCandidates(source.symbolTable?.topLevel.namespaces[namespace]); + } + + return [ + ...configuredScalarCandidates(source.scalarTypes), + ...topLevelSymbolCandidates(source.symbolTable), + ...allNamespaceCandidates(source.symbolTable), + ]; +} + +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)) + .flatMap(namespaceCandidatesForBareContext); +} + +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 namespaceCandidatesForBareContext( + namespace: NamespaceSymbol, +): readonly ModelTypeCompletionCandidate[] { + return [ + ...qualifiedNamespaceCandidates( + namespace.name, + recordNames(namespace.models), + 'namespaceModel', + `Model in namespace ${namespace.name}`, + CompletionItemKind.Class, + ), + ...qualifiedNamespaceCandidates( + namespace.name, + recordNames(namespace.compositeTypes), + 'namespaceCompositeType', + `Composite type in namespace ${namespace.name}`, + CompletionItemKind.Struct, + ), + ]; +} + +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 qualifiedNamespaceCandidates( + namespace: string, + names: readonly string[], + category: ModelTypeCompletionCandidateCategory, + detail: string, + kind: CompletionItemKind, +): readonly ModelTypeCompletionCandidate[] { + return names.map((name) => { + const qualifiedName = `${namespace}.${name}`; + return { + category, + label: qualifiedName, + insertText: qualifiedName, + filterText: qualifiedName, + 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 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/server.ts b/packages/1-framework/3-tooling/language-server/src/server.ts index 8936237dba..2afed2aca3 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, @@ -12,6 +13,7 @@ import { type InitializeParams, type InitializeResult, type Range, + type Position, RegistrationRequest, type SemanticTokens, TextDocumentSyncKind, @@ -19,6 +21,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'; @@ -288,6 +292,49 @@ 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 = project.artifacts.getDocument(uri); + 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, + symbolTable, + }, + }), + ]; + } catch { + return []; + } + } + connection.onInitialize(async (params): Promise => { rootPath = resolveRootPath(params.rootUri, params.rootPath); watchedConfigGlob = join(rootPath, '**', CONFIG_FILENAME); @@ -303,6 +350,7 @@ export function createServer(connection: Connection): LanguageServer { full: true, range: true, }, + completionProvider: {}, }, }; }); @@ -343,6 +391,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), 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..ffc278093f --- /dev/null +++ b/packages/1-framework/3-tooling/language-server/test/completion-context.test.ts @@ -0,0 +1,165 @@ +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), + }); +} + +describe('classifyPslCompletionContext', () => { + it('classifies a blank model field type position', () => { + const context = classify(['model Post {', ' author |', '}'].join('\n')); + + expect(context).toMatchObject({ + kind: 'modelFieldType', + fieldName: 'author', + prefix: { path: [], name: '' }, + }); + }); + + it('classifies a partial bare model field type prefix', () => { + const context = classify(['model Post {', ' reviewer U|', '}'].join('\n')); + + expect(context).toMatchObject({ + kind: 'modelFieldType', + fieldName: 'reviewer', + prefix: { path: ['U'], name: 'U' }, + }); + }); + + it('classifies namespace-qualified model field type prefixes', () => { + expect(classify(['model Post {', ' owner auth.|', '}'].join('\n'))).toMatchObject({ + kind: 'modelFieldType', + fieldName: 'owner', + prefix: { path: ['auth'], namespace: 'auth', name: '' }, + }); + + expect(classify(['model Post {', ' editor auth.U|', '}'].join('\n'))).toMatchObject({ + kind: 'modelFieldType', + fieldName: 'editor', + prefix: { path: ['auth', 'U'], namespace: 'auth', name: 'U' }, + }); + }); + + it('classifies contract-space-qualified model field type prefixes', () => { + expect(classify(['model Post {', ' external supabase:|', '}'].join('\n'))).toMatchObject({ + kind: 'modelFieldType', + fieldName: 'external', + prefix: { path: ['supabase'], contractSpace: 'supabase', name: '' }, + }); + + expect( + classify(['model Post {', ' externalUser supabase:auth.|', '}'].join('\n')), + ).toMatchObject({ + kind: 'modelFieldType', + fieldName: 'externalUser', + prefix: { + path: ['supabase', 'auth'], + contractSpace: 'supabase', + namespace: 'auth', + name: '', + }, + }); + + expect(classify(['model Post {', ' owner supabase:auth.U|', '}'].join('\n'))).toMatchObject({ + kind: 'modelFieldType', + fieldName: 'owner', + prefix: { + path: ['supabase', 'auth', 'U'], + contractSpace: 'supabase', + namespace: 'auth', + name: 'U', + }, + }); + }); + + it('returns unsupported in comments and trivia outside type positions', () => { + expect(classify(['model Post {', ' // U|', ' id Int', '}'].join('\n'))).toMatchObject({ + kind: 'unsupported', + reason: 'comment', + }); + + expect(classify(['model Post {', ' |', ' id Int', '}'].join('\n'))).toMatchObject({ + kind: 'unsupported', + reason: 'outsideModelField', + }); + }); + + it('returns unsupported for ordinary field and block attributes', () => { + expect(classify(['model Post {', ' id Int @|', '}'].join('\n'))).toMatchObject({ + kind: 'unsupported', + reason: 'attribute', + }); + + expect(classify(['model Post {', ' id Int', ' @@|', '}'].join('\n'))).toMatchObject({ + kind: 'unsupported', + reason: 'attribute', + }); + }); + + it('returns unsupported inside attribute arguments', () => { + expect(classify(['model Post {', ' id Int @default(|)', '}'].join('\n'))).toMatchObject({ + kind: 'unsupported', + reason: 'attributeArgument', + }); + + expect( + classify(['model Post {', ' authorId Int @relation(fields: [|])', '}'].join('\n')), + ).toMatchObject({ + kind: 'unsupported', + reason: 'attributeArgument', + }); + }); + + it('returns unsupported inside generic block contexts', () => { + expect(classify(['datasource db {', ' provider = |', '}'].join('\n'))).toMatchObject({ + kind: 'unsupported', + reason: 'genericBlock', + }); + + expect(classify(['generator client {', ' prov|', '}'].join('\n'))).toMatchObject({ + kind: 'unsupported', + reason: 'genericBlock', + }); + }); + + it('returns unsupported inside type constructor arguments', () => { + expect(classify(['model Embedding {', ' vector Vector(|)', '}'].join('\n'))).toMatchObject({ + kind: 'unsupported', + reason: 'constructorArgument', + }); + }); + + it('returns unsupported outside model field type prefixes', () => { + expect(classify(['model Post {', ' |id Int', '}'].join('\n'))).toMatchObject({ + kind: 'unsupported', + reason: 'fieldName', + }); + + expect(classify(['model Post {', ' id Int |', '}'].join('\n'))).toMatchObject({ + kind: 'unsupported', + reason: 'notTypePrefix', + }); + }); + + it('returns unsupported for invalid over-qualified names', () => { + expect(classify(['model Post {', ' owner auth.domain.U|', '}'].join('\n'))).toMatchObject({ + kind: 'unsupported', + reason: 'invalidQualifiedType', + }); + + expect(classify(['model Post {', ' owner supabase:auth:U|', '}'].join('\n'))).toMatchObject({ + kind: 'unsupported', + reason: 'invalidQualifiedType', + }); + }); +}); 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..a94b44c3c1 --- /dev/null +++ b/packages/1-framework/3-tooling/language-server/test/completion-provider.test.ts @@ -0,0 +1,165 @@ +import { buildSymbolTable } from '@prisma-next/psl-parser'; +import { parse } from '@prisma-next/psl-parser/syntax'; +import { describe, expect, it } from 'vitest'; +import { classifyPslCompletionContext } from '../src/completion-context'; +import { providePslCompletionItems } from '../src/completion-provider'; + +const scalarTypes = ['String', 'Int', 'Boolean', 'DateTime'] as const; + +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) { + 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, symbolTable }, + }), + sourceFile, + cursorOffset, + }; +} + +describe('providePslCompletionItems', () => { + 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.Account', + 'auth.User', + 'auth.Profile', + ]); + 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', + 'Model in namespace auth', + 'Model in namespace auth', + 'Composite type in namespace auth', + ]); + expect(items[0]?.textEdit).toEqual({ + range: { + start: sourceFile.positionAt(cursorOffset), + end: sourceFile.positionAt(cursorOffset), + }, + newText: 'Boolean', + }); + }); + + it('filters bare prefixes against visible candidate labels', () => { + const { items } = complete(['model Post {', ' reviewer U|', '}'].join('\n')); + + expect(items.map((item) => item.label)).toEqual(['User', 'UserId']); + }); + + it('returns namespace-qualified candidates 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(['User']); + expect(items[0]).toMatchObject({ + detail: 'Model in namespace auth', + textEdit: { + range: { + start: sourceFile.positionAt(cursorOffset - 'U'.length), + end: sourceFile.positionAt(cursorOffset), + }, + newText: 'User', + }, + }); + }); + + it('returns contract-space-qualified candidates from visible namespace data', () => { + const { items, sourceFile, cursorOffset } = complete( + ['model Post {', ' owner supabase:auth.P|', '}'].join('\n'), + ); + + expect(items.map((item) => item.label)).toEqual(['Profile']); + expect(items[0]).toMatchObject({ + detail: 'Composite type in namespace auth', + textEdit: { + range: { + start: sourceFile.positionAt(cursorOffset - 'P'.length), + end: sourceFile.positionAt(cursorOffset), + }, + newText: 'Profile', + }, + }); + }); + + 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..92fa0c3200 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 @@ -9,6 +9,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, @@ -23,6 +26,7 @@ import { type InitializeResult, LogMessageNotification, MessageType, + type Position, PublishDiagnosticsNotification, type Range, type RegistrationParams, @@ -388,6 +392,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) => { @@ -409,7 +450,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 +461,79 @@ describe('language server', { timeout: timeouts.databaseOperation }, () => { full: true, range: true, }); + expect(result.capabilities.completionProvider).toBeDefined(); + }); + + 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('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 () => { diff --git a/projects/lsp-autocomplete/plan.md b/projects/lsp-autocomplete/plan.md new file mode 100644 index 0000000000..08ecb293cc --- /dev/null +++ b/projects/lsp-autocomplete/plan.md @@ -0,0 +1,41 @@ +# lsp-autocomplete — Plan + +**Spec:** `projects/lsp-autocomplete/spec.md` +**Linear Project:** [Language Tools Support Prisma Next PSL](https://linear.app/prisma-company/project/language-tools-support-prisma-next-psl-3422a7e44b9c) + +## At a glance + +This project is a two-slice stack. Slice 1 lands the completion request path, shared cursor-context classifier, and first model-type provider; slice 2 reuses that hand-off to add descriptor-backed generic block entry/parameter completions and final scope documentation. + +## Composition + +### Stack (deliver in order) + +1. **Slice `model-type-completions`** — Linear: [TML-2946](https://linear.app/prisma-company/issue/TML-2946/lsp-autocomplete-model-type-completions) + - **Outcome:** Configured PSL inputs answer `textDocument/completion` at model field type positions, including bare, namespace-qualified, and contract-space-qualified prefixes such as `User`, `auth.User`, and `supabase:auth.User`. + - **Builds on:** Existing language-server diagnostics/formatting ownership, cached `DocumentAst` / `SourceFile` / `SymbolTable` project artifacts, and the parser's recovered `TypeAnnotation` / `QualifiedName` CST shape. + - **Hands to:** A tested completion capability, request handler, cursor-context classifier, explicit provider dispatch shape, and model-type provider that unsupported contexts can safely bypass. + - **Focus:** LSP completion wiring for configured PSL documents, pure cursor classification over existing parse artifacts, symbol-table-backed model field type suggestions, namespace-qualified type positions, and guardrails that keep ordinary `@` / `@@` attribute contexts empty. + +2. **Slice `generic-block-completions`** — Linear: [TML-2945](https://linear.app/prisma-company/issue/TML-2945/lsp-autocomplete-generic-block-entry-completions) + - **Outcome:** Configured PSL inputs answer `textDocument/completion` inside descriptor-backed generic PSL blocks for blank entry positions and partial keys/parameters on the current `GenericBlockDeclaration`. + - **Builds on:** Slice `model-type-completions`' completion handler, cursor-context classifier, provider dispatch shape, unsupported-context behavior, and access to the language-server control stack. + - **Hands to:** The scoped first autocomplete surface described by the project spec: model field type completions plus generic block entry/parameter completions, with ordinary PSL attributes still out of scope. + - **Focus:** Using `pslBlockDescriptors`, `GenericBlockDeclarationAst`, `KeyValuePairAst`, and reconstructed block symbols to produce descriptor-backed generic block suggestions; documentation for supported and excluded completion contexts. + +### Parallel groups + +None. Both slices touch the same language-server completion route and classifier, and the generic block provider is simpler and safer once the model-type slice has established the provider dispatch and unsupported-context semantics. + +## Dependencies (external) + +- [x] Linear Project exists — [Language Tools Support Prisma Next PSL](https://linear.app/prisma-company/project/language-tools-support-prisma-next-psl-3422a7e44b9c), Terminal team. +- [x] Existing LSP scaffold exists — diagnostics and formatting already run through `packages/1-framework/3-tooling/language-server/src/server.ts` and cache project artifacts in `project-artifacts.ts`. +- [x] Existing parser/symbol substrate exists — `parseTypeAnnotation()` uses `QualifiedName`, `SourceFile` maps offsets, red-tree nodes expose offsets/parents/ancestors/tokens, and `buildSymbolTable()` exposes model/composite/scalar/type-alias/block symbols. +- [x] Existing generic block descriptor path exists — `pslBlockDescriptors` is resolved into the language-server control stack and `BlockSymbol.block` is reconstructed from `GenericBlockDeclarationAst`. + +## Sequencing rationale + +The only real dependency is the shared completion route and cursor-context classifier. Model type completions are the narrower end-to-end slice because they depend on existing `TypeAnnotation` / `QualifiedName` and symbol-table shapes but do not need descriptor-specific block semantics. Generic block completions then consume the established request path and classifier, adding descriptor-backed context handling without reopening the LSP wiring decision. + +The slices are not parallelized because both would otherwise make independent edits to the same provider routing and unsupported-context behavior. Keeping them stacked avoids duplicate classifier designs and makes the second review about descriptor semantics rather than LSP plumbing. diff --git a/projects/lsp-autocomplete/slices/model-type-completions/dispatches/01-cursor-classifier-substrate.md b/projects/lsp-autocomplete/slices/model-type-completions/dispatches/01-cursor-classifier-substrate.md new file mode 100644 index 0000000000..b981ed9faf --- /dev/null +++ b/projects/lsp-autocomplete/slices/model-type-completions/dispatches/01-cursor-classifier-substrate.md @@ -0,0 +1,72 @@ +# Brief: cursor-classifier-substrate + +## Task + +Add the test-first cursor-classifier substrate for PSL completion in `@prisma-next/language-server`. The classifier must be pure and must identify model field type completion contexts from the existing cached parser artifacts (`DocumentAst`, `SourceFile`, red-tree syntax, AST ancestors) while returning an unsupported context for everything outside slice-1 scope. + +## Scope + +**In:** + +- New language-server classifier module and focused tests. +- Offset conversion with `SourceFile.offsetAt(position)`. +- Touching/current/previous token or node lookup implemented locally in the language-server package using existing red-tree traversal APIs. +- Model field type context detection for blank type positions, partial bare types, namespace-qualified prefixes, and contract-space-qualified prefixes. +- Unsupported classification for ordinary `@` / `@@` attributes, attribute arguments, generic blocks, comments/trivia, constructor argument positions, unconfigured provider contexts, and invalid over-qualified names that are not valid type-prefix contexts. + +**Out:** + +- LSP `completionProvider` advertisement and `connection.onCompletion(...)` handler. +- Completion item generation and symbol-table candidate extraction. +- Generic block entry/parameter completions. +- Ordinary PSL attribute completions or attribute argument completions. +- Relation-aware completions. +- Completion-marker reparsing. +- Parser behavior changes or new public parser exports unless you halt and surface evidence that local language-server traversal cannot work. + +## Completed when + +- [ ] Classifier tests cover blank type, partial bare type, namespace-qualified prefix, contract-space-qualified prefix, comments/trivia, ordinary `@` / `@@` attributes, attribute arguments, generic block contexts, and unsupported contexts. +- [ ] The classifier exposes a typed context union that later dispatches can route to a model-type provider or `[]`. +- [ ] No completion-marker reparsing, parser behavior changes, or public parser exports are introduced. +- [ ] Validation gates pass or any blocker is surfaced with concrete evidence. + +## Standing instruction + +Stay focused on the goal; control scope. Trivial-and-related fixes that obviously serve the goal go in the same dispatch with a one-line note in your wrap-up message. Anything that pulls you off the goal — even if it looks useful — halts and surfaces. + +## References + +**Slice-loop dispatch:** + +- Slice spec: `projects/lsp-autocomplete/slices/model-type-completions/spec.md` +- Slice plan entry: `projects/lsp-autocomplete/slices/model-type-completions/plan.md` § Dispatch 1: cursor-classifier-substrate +- Project spec / plan: `projects/lsp-autocomplete/spec.md`, `projects/lsp-autocomplete/plan.md` +- Code review log: `projects/lsp-autocomplete/reviews/code-review.md` +- Calibration: `drive/calibration/sizing.md` § Dispatch INVEST — specialised for this repo +- Relevant code surfaces from prior grounding: + - `packages/1-framework/3-tooling/language-server/src/server.ts` + - `packages/1-framework/3-tooling/language-server/src/project-artifacts.ts` + - `packages/1-framework/3-tooling/language-server/src/pipeline.ts` + - `packages/1-framework/3-tooling/language-server/test/server.test.ts` + - `packages/1-framework/2-authoring/psl-parser/src/source-file.ts` + - `packages/1-framework/2-authoring/psl-parser/src/syntax/red.ts` + - `packages/1-framework/2-authoring/psl-parser/src/syntax/ast/declarations.ts` + - `packages/1-framework/2-authoring/psl-parser/src/syntax/ast/type-annotation.ts` + - `packages/1-framework/2-authoring/psl-parser/src/syntax/ast/qualified-name.ts` + +## Operational metadata + +- **Model tier:** `implementer/fast` — routine code edits within a slice; persistent across rounds in this slice. +- **Time-box:** 90 minutes wall clock. Overrun means halt and surface current state rather than broadening scope. +- **Halt conditions:** + - A parser behavior change or new public parser export appears necessary. + - Completing the classifier requires touching LSP request handling, completion item generation, generic block provider behavior, ordinary attribute completion, or relation-aware completion. + - Existing parser recovery cannot represent one of the required contexts with current red-tree/AST artifacts. + - Validation gates fail for reasons that look unrelated to this dispatch. + +## Validation gates + +- `pnpm --filter @prisma-next/language-server test` +- `pnpm --filter @prisma-next/language-server typecheck` +- `pnpm --filter @prisma-next/language-server lint` diff --git a/projects/lsp-autocomplete/slices/model-type-completions/dispatches/02-model-type-provider.md b/projects/lsp-autocomplete/slices/model-type-completions/dispatches/02-model-type-provider.md new file mode 100644 index 0000000000..9d77b7ac80 --- /dev/null +++ b/projects/lsp-autocomplete/slices/model-type-completions/dispatches/02-model-type-provider.md @@ -0,0 +1,70 @@ +# Brief: model-type-provider + +## Task + +Add the model field type completion provider and explicit completion dispatch entry point in `@prisma-next/language-server`. The provider consumes Dispatch 1’s typed classifier output and returns stable LSP completion items for configured scalar types plus visible type-like symbols from the current `SymbolTable`. Unsupported contexts return `[]`. + +## Scope + +**In:** + +- Provider/dispatcher module and focused tests in the language-server package. +- Candidate extraction from configured scalar types and the current symbol table. +- Candidate categories: configured scalars, top-level models, composite types, scalars, type aliases, namespace models, and namespace composite types. +- Prefix filtering and replacement metadata for bare, namespace-qualified, and contract-space-qualified model field type contexts already classified by Dispatch 1. +- Stable ordering that tests can pin. +- Explicit empty-list behavior for unsupported contexts. + +**Out:** + +- LSP `completionProvider` advertisement and `connection.onCompletion(...)` handler. +- Server/open-document configured-input gating. +- Generic block entry/parameter completions. +- Ordinary PSL `@` / `@@` attribute completions or attribute argument completions. +- Relation-aware completions. +- Creating a new external contract-space symbol index. +- Parser behavior changes, parser public exports, or completion-marker reparsing. + +## Completed when + +- [ ] Provider tests cover bare model field type completion candidates. +- [ ] Provider tests cover namespace-qualified prefixes and contract-space-qualified syntax positions using visible candidate data. +- [ ] Provider tests prove unsupported classifier contexts return `[]`. +- [ ] Provider tests prove generic block symbols are not returned as model field type candidates. +- [ ] The dispatcher/candidate-source seam is ready for the server route in Dispatch 3 without adding LSP request handling in this dispatch. +- [ ] Validation gates pass or any blocker is surfaced with concrete evidence. + +## Standing instruction + +Stay focused on the goal; control scope. Trivial-and-related fixes that obviously serve the goal go in the same dispatch with a one-line note in your wrap-up message. Anything that pulls you off the goal — even if it looks useful — halts and surfaces. + +## References + +**Slice-loop dispatch:** + +- Slice spec: `projects/lsp-autocomplete/slices/model-type-completions/spec.md` +- Slice plan entry: `projects/lsp-autocomplete/slices/model-type-completions/plan.md` § Dispatch 2: model-type-provider +- Dispatch 1 hand-off: `packages/1-framework/3-tooling/language-server/src/completion-context.ts` and `packages/1-framework/3-tooling/language-server/test/completion-context.test.ts` +- Project spec / plan: `projects/lsp-autocomplete/spec.md`, `projects/lsp-autocomplete/plan.md` +- Code review log: `projects/lsp-autocomplete/reviews/code-review.md` +- Relevant code surfaces: + - `packages/1-framework/3-tooling/language-server/src/project-artifacts.ts` + - `packages/1-framework/3-tooling/language-server/src/pipeline.ts` + - `packages/1-framework/2-authoring/psl-parser/src/symbol-table.ts` + - `packages/1-framework/2-authoring/psl-parser/test/symbol-table.test.ts` + +## Operational metadata + +- **Model tier:** `implementer/fast` — routine code edits within a slice; persistent across rounds in this slice. +- **Time-box:** 90 minutes wall clock. Overrun means halt and surface current state rather than broadening scope. +- **Halt conditions:** + - Candidate extraction requires a new external contract-space symbol index. + - Completing provider tests requires LSP request handling or configured-input server wiring. + - Generic block, ordinary attribute, relation-aware, or parser behavior work appears necessary. + - Validation gates fail for reasons that look unrelated to this dispatch. + +## Validation gates + +- `pnpm --filter @prisma-next/language-server test` +- `pnpm --filter @prisma-next/language-server typecheck` +- `pnpm --filter @prisma-next/language-server lint` diff --git a/projects/lsp-autocomplete/slices/model-type-completions/dispatches/03-lsp-completion-route.md b/projects/lsp-autocomplete/slices/model-type-completions/dispatches/03-lsp-completion-route.md new file mode 100644 index 0000000000..12b21e3b23 --- /dev/null +++ b/projects/lsp-autocomplete/slices/model-type-completions/dispatches/03-lsp-completion-route.md @@ -0,0 +1,75 @@ +# Brief: lsp-completion-route + +## Task + +Wire the language-server completion request path for configured PSL inputs. The server should advertise completion support, route `textDocument/completion` requests through Dispatch 1's classifier and Dispatch 2's provider, and return `[]` for unconfigured, missing, artifact-less, or unsupported contexts. + +## Scope + +**In:** + +- `server.ts` completion capability advertisement in the initialize response. +- `connection.onCompletion(...)` request handler for open, configured PSL documents. +- Reuse of existing open-document lookup, configured-input gating, cached `DocumentAst` / `SourceFile`, and current project `SymbolTable` artifacts. +- Calling the existing model field type context classifier and provider dispatcher. +- Empty completion results for unconfigured documents, missing documents, unavailable artifacts, parse/artifact gaps, and unsupported classifier contexts. +- Server test harness helper for completion requests, analogous to the existing diagnostics/formatting helpers. +- Server tests for completion capability advertisement, configured model field type completions, unconfigured documents returning `[]`, and unsupported contexts returning `[]`. +- Preservation of existing diagnostics and formatting behavior. + +**Out:** + +- Generic block entry/parameter completions. +- Ordinary PSL `@` / `@@` attribute completions or attribute argument completions. +- Relation-aware completions. +- Changes to candidate extraction ordering or filtering beyond integration fixes strictly required by the server route. +- Creating a new external contract-space symbol index. +- Parser behavior changes, parser public exports, or completion-marker reparsing. +- Documentation updates; those are Dispatch 4. + +## Completed when + +- [ ] Server initialize tests prove `completionProvider` is advertised without regressing existing capabilities. +- [ ] Server completion tests prove a configured PSL document returns expected model field type completion labels at a type position. +- [ ] Server completion tests prove an unconfigured document returns `[]`. +- [ ] Server completion tests prove an unsupported context, including at least one ordinary PSL attribute context if easy to add here, returns `[]`. +- [ ] Existing diagnostics and formatting server tests still pass. +- [ ] Validation gates pass or any blocker is surfaced with concrete evidence. + +## Standing instruction + +Stay focused on the goal; control scope. Trivial-and-related fixes that obviously serve the goal go in the same dispatch with a one-line note in your wrap-up message. Anything that pulls you off the goal — even if it looks useful — halts and surfaces. + +## References + +**Slice-loop dispatch:** + +- Slice spec: `projects/lsp-autocomplete/slices/model-type-completions/spec.md` +- Slice plan entry: `projects/lsp-autocomplete/slices/model-type-completions/plan.md` § Dispatch 3: lsp-completion-route +- Dispatch 1 hand-off: `packages/1-framework/3-tooling/language-server/src/completion-context.ts` and `packages/1-framework/3-tooling/language-server/test/completion-context.test.ts` +- Dispatch 2 hand-off: `packages/1-framework/3-tooling/language-server/src/completion-provider.ts` and `packages/1-framework/3-tooling/language-server/test/completion-provider.test.ts` +- Project spec / plan: `projects/lsp-autocomplete/spec.md`, `projects/lsp-autocomplete/plan.md` +- Code review log: `projects/lsp-autocomplete/reviews/code-review.md` +- Relevant code surfaces: + - `packages/1-framework/3-tooling/language-server/src/server.ts` + - `packages/1-framework/3-tooling/language-server/src/project-artifacts.ts` + - `packages/1-framework/3-tooling/language-server/src/pipeline.ts` + - `packages/1-framework/3-tooling/language-server/test/server.test.ts` + +## Operational metadata + +- **Dispatch ID:** `0fb30343-7c83-48d5-af0a-c7e9dbfb771b` +- **Round ID:** `c3095776-c25f-4d4c-b780-f50cab74c1a3` +- **Model tier:** `implementer/fast` — routine language-server integration work within a slice; reuse the existing implementer session. +- **Time-box:** 90 minutes wall clock. Overrun means halt and surface current state rather than broadening scope. +- **Halt conditions:** + - Completion routing requires parser behavior changes, parser public exports, or completion-marker reparsing. + - Server tests require implementing generic block completions, ordinary attribute completions, attribute argument completions, relation-aware completions, or an external contract-space symbol index. + - Candidate extraction needs a scope expansion rather than a small integration fix. + - Validation gates fail for reasons that look unrelated to this dispatch. + +## Validation gates + +- `pnpm --filter @prisma-next/language-server test` +- `pnpm --filter @prisma-next/language-server typecheck` +- `pnpm --filter @prisma-next/language-server lint` diff --git a/projects/lsp-autocomplete/slices/model-type-completions/dispatches/04-scope-docs-and-final-guardrails.md b/projects/lsp-autocomplete/slices/model-type-completions/dispatches/04-scope-docs-and-final-guardrails.md new file mode 100644 index 0000000000..9a9080cdbd --- /dev/null +++ b/projects/lsp-autocomplete/slices/model-type-completions/dispatches/04-scope-docs-and-final-guardrails.md @@ -0,0 +1,70 @@ +# Brief: scope-docs-and-final-guardrails + +## Task + +Finish the `model-type-completions` slice by documenting the newly supported narrow completion scope and ensuring the final guardrails protect against scope creep. The README should no longer say completion is wholly unsupported, but it must be precise: slice 1 supports configured PSL model field type completions only. + +## Scope + +**In:** + +- Update `packages/1-framework/3-tooling/language-server/README.md` to describe the supported completion surface. +- Document that completion is available only for configured PSL inputs and only at model field type positions. +- Document candidate sources accurately: configured scalar types plus visible model/composite/scalar/type-alias candidates from the current project symbol table, including namespace-qualified and contract-space-qualified type-position syntax when candidates are visible in current artifacts. +- Explicitly document exclusions: generic block entry/parameter completions, ordinary PSL `@` / `@@` attribute completions, attribute argument completions, relation-aware completions, and new external contract-space candidate discovery are not part of slice 1. +- Add or verify final server-level guardrail coverage for ordinary attribute contexts returning `[]`; if Dispatch 3 already added sufficient coverage, keep it and only add a narrowly useful missing assertion. +- Preserve existing diagnostics, formatting, and completion route behavior. + +**Out:** + +- Implementing generic block entry/parameter completions. +- Implementing ordinary PSL `@` / `@@` attribute completions or attribute argument completions. +- Implementing relation-aware completions. +- Adding or changing candidate extraction semantics beyond fixing a discovered guardrail gap. +- Creating a new external contract-space symbol index. +- Parser behavior changes, parser public exports, or completion-marker reparsing. +- Broad documentation rewrites unrelated to the language-server completion scope. + +## Completed when + +- [ ] The language-server README accurately describes configured PSL model field type completion support. +- [ ] The README explicitly excludes generic block completions, ordinary attribute completions, attribute argument completions, relation-aware completions, and external contract-space candidate discovery unless future slices add them. +- [ ] Server-level guardrail coverage proves ordinary attribute contexts return `[]` through the LSP route, either by existing Dispatch 3 coverage or a focused D4 test addition. +- [ ] No implementation scope is broadened while editing docs/guardrails. +- [ ] Validation gates pass or any blocker is surfaced with concrete evidence. + +## Standing instruction + +Stay focused on slice close-out. Do not use this dispatch to improve or reshape the provider, classifier, parser, symbol table, or LSP route unless a concrete guardrail failure proves a tiny fix is required. + +## References + +**Slice-loop dispatch:** + +- Slice spec: `projects/lsp-autocomplete/slices/model-type-completions/spec.md` +- Slice plan entry: `projects/lsp-autocomplete/slices/model-type-completions/plan.md` § Dispatch 4: scope-docs-and-final-guardrails +- Dispatch 1 hand-off: `packages/1-framework/3-tooling/language-server/src/completion-context.ts` +- Dispatch 2 hand-off: `packages/1-framework/3-tooling/language-server/src/completion-provider.ts` +- Dispatch 3 hand-off: `packages/1-framework/3-tooling/language-server/src/server.ts` and `packages/1-framework/3-tooling/language-server/test/server.test.ts` +- Project spec / plan: `projects/lsp-autocomplete/spec.md`, `projects/lsp-autocomplete/plan.md` +- Code review log: `projects/lsp-autocomplete/reviews/code-review.md` +- Relevant docs/tests: + - `packages/1-framework/3-tooling/language-server/README.md` + - `packages/1-framework/3-tooling/language-server/test/server.test.ts` + +## Operational metadata + +- **Dispatch ID:** `8c39fcaa-7306-425f-982b-fd5b4f7cdc5c` +- **Round ID:** `dc7ce623-26b7-41fb-9b78-34b6962a7269` +- **Model tier:** `implementer/fast` — focused documentation and final guardrail work; reuse the existing implementer session. +- **Time-box:** 45 minutes wall clock. Overrun means halt and surface current state rather than broadening scope. +- **Halt conditions:** + - Documentation accuracy requires changing product scope or resolving a new design question. + - Guardrail coverage requires implementing generic block completions, ordinary attribute completions, attribute argument completions, relation-aware completions, external contract-space indexes, parser changes, or marker reparsing. + - Validation gates fail for reasons that look unrelated to this dispatch. + +## Validation gates + +- `pnpm --filter @prisma-next/language-server test` +- `pnpm --filter @prisma-next/language-server typecheck` +- `pnpm --filter @prisma-next/language-server lint` diff --git a/projects/lsp-autocomplete/slices/model-type-completions/plan.md b/projects/lsp-autocomplete/slices/model-type-completions/plan.md new file mode 100644 index 0000000000..aada9df9b7 --- /dev/null +++ b/projects/lsp-autocomplete/slices/model-type-completions/plan.md @@ -0,0 +1,82 @@ +# Slice plan: model-type-completions + +**Spec:** `projects/lsp-autocomplete/slices/model-type-completions/spec.md` +**Parent project:** `projects/lsp-autocomplete/spec.md` +**Linear issue:** [TML-2946](https://linear.app/prisma-company/issue/TML-2946/lsp-autocomplete-model-type-completions) + +## Dispatch plan + +### Dispatch 1: cursor-classifier-substrate + +- **Outcome:** A pure classifier identifies model field type completion contexts and unsupported contexts from existing cached `DocumentAst`, `SourceFile`, and syntax tree structure. +- **Builds on:** Existing parser recovery, `SourceFile.offsetAt(position)`, red-tree traversal, `FieldDeclarationAst.typeAnnotation()`, `TypeAnnotationAst`, and `QualifiedNameAst`. +- **Hands to:** A typed completion context union consumed by provider dispatch. +- **Focus:** Cursor offset conversion; touching/current/previous token handling; ancestor-based model field type detection; blank and partial type positions; bare, namespace-qualified, and contract-space-qualified prefixes; explicit rejection of ordinary `@` / `@@` attributes, attribute arguments, generic blocks, comments/trivia, constructor arguments, and invalid over-qualified names. +- **Completed when:** + - Classifier tests cover blank type, partial bare type, namespace-qualified prefixes, contract-space-qualified prefixes, comments/trivia, ordinary attributes, attribute arguments, generic blocks, and unsupported contexts. + - No completion-marker reparsing is introduced. +- **Validation gates:** + - `pnpm --filter @prisma-next/language-server test` + - `pnpm --filter @prisma-next/language-server typecheck` + - `pnpm --filter @prisma-next/language-server lint` + - If parser utilities or exports are changed: `pnpm --filter @prisma-next/psl-parser test`, `pnpm --filter @prisma-next/psl-parser typecheck`, `pnpm --filter @prisma-next/psl-parser lint`, and `pnpm lint:deps`. + +### Dispatch 2: model-type-provider + +- **Outcome:** The model-type provider returns stable LSP completion items for scalar and visible type-like symbol-table candidates when the classifier reports a model field type context. +- **Builds on:** Dispatch 1’s typed completion context union. +- **Hands to:** A completion entry point that can route context to provider output or `[]`. +- **Focus:** Candidate extraction from configured scalar types and the current `SymbolTable`; top-level models, composite types, scalars, type aliases; namespace models and composite types; stable ordering; prefix filtering for bare, namespace-qualified, and contract-space-qualified syntax; explicit exclusion of generic block symbols, ordinary attributes, relation-aware suggestions, and external contract-space indexes that do not exist in current artifacts. +- **Completed when:** + - Provider tests cover bare type completions, namespace-qualified completions, contract-space-qualified syntax positions, and unsupported analysis returning `[]`. + - Tests prove generic block symbols are not returned as model field type candidates. + - The candidate-source seam can be extended later without changing the classifier contract. +- **Validation gates:** + - `pnpm --filter @prisma-next/language-server test` + - `pnpm --filter @prisma-next/language-server typecheck` + - `pnpm --filter @prisma-next/language-server lint` + +### Dispatch 3: lsp-completion-route + +- **Outcome:** The language server advertises completion support and answers `textDocument/completion` for open, configured PSL inputs. +- **Builds on:** Dispatch 1’s classifier and Dispatch 2’s provider dispatch entry point. +- **Hands to:** End-to-end LSP completion behavior for configured documents. +- **Focus:** `server.ts` completion capability advertisement; `connection.onCompletion(...)` request handler; open-document lookup; configured-input gating; cached artifact lookup; empty results for unconfigured, missing, unsupported, or artifact-less documents; preservation of existing diagnostics and formatting behavior. +- **Completed when:** + - Server tests cover initialize capability advertisement. + - Server tests cover configured PSL completion returning expected labels at a model field type position. + - Server tests cover unconfigured documents and unsupported contexts returning `[]`. + - Existing diagnostics and formatting server tests still pass. +- **Validation gates:** + - `pnpm --filter @prisma-next/language-server test` + - `pnpm --filter @prisma-next/language-server typecheck` + - `pnpm --filter @prisma-next/language-server lint` + +### Dispatch 4: scope-docs-and-final-guardrails + +- **Outcome:** Documentation and final guardrail tests accurately describe and protect the slice-1 completion scope. +- **Builds on:** Dispatch 3’s end-to-end completion route. +- **Hands to:** Slice `generic-block-completions` can add descriptor-backed contexts/providers without reworking the LSP request path. +- **Focus:** Update `packages/1-framework/3-tooling/language-server/README.md` so completion is no longer documented as wholly out of scope; document configured PSL inputs, model field type positions, visible symbol-table candidates, and qualified syntax support; explicitly exclude generic block completions, ordinary `@` / `@@` attributes, attribute arguments, relation-aware completions, and external contract-space candidate discovery unless a future slice adds it. +- **Completed when:** + - README states the supported model-type completion scope without overclaiming slice-2 or future surfaces. + - Final guardrail coverage includes at least one ordinary attribute context returning `[]` through the server path. + - The slice-specific done conditions in `spec.md` are satisfied. +- **Validation gates:** + - `pnpm --filter @prisma-next/language-server test` + - `pnpm --filter @prisma-next/language-server typecheck` + - `pnpm --filter @prisma-next/language-server lint` + - If imports, exports, or package boundaries changed: `pnpm lint:deps`. + +## Dispatch-INVEST check + +- **Independent:** Each dispatch leaves a named hand-off usable by the next dispatch without concurrent sibling work. +- **Negotiable:** Dispatches name outcomes and leave implementation details to executor discovery inside the scoped surfaces. +- **Valuable:** Every dispatch materially advances the slice from classifier substrate to provider, LSP route, and final scope guardrails. +- **Estimable:** Each dispatch has binary completed-when checks and concrete validation commands. +- **Small:** Each dispatch is one coherent review lens: classifier, provider, route, or docs/guardrails. +- **Testable:** The language-server package test/typecheck/lint gates verify each dispatch, with parser gates added only if parser exports change. + +## Open items + +None. diff --git a/projects/lsp-autocomplete/slices/model-type-completions/spec.md b/projects/lsp-autocomplete/slices/model-type-completions/spec.md new file mode 100644 index 0000000000..8735348645 --- /dev/null +++ b/projects/lsp-autocomplete/slices/model-type-completions/spec.md @@ -0,0 +1,106 @@ +# Slice: model-type-completions + +Parent project: `projects/lsp-autocomplete/`. This slice contributes the first end-to-end PSL completion surface: model field type completions for configured language-server inputs. + +## At a glance + +Configured PSL documents answer `textDocument/completion` at model field type positions. The slice establishes the shared completion capability, request handler, cursor-context classifier, provider dispatch seam, and model-type provider that the later generic-block slice can extend without reshaping the LSP route. + +## Chosen design + +The language server adds completion as another configured-input-only surface next to diagnostics and formatting. The request path is: + +```text +textDocument/completion + → open document lookup + → configured PSL input check + → cached DocumentAst / SourceFile / SymbolTable lookup + → SourceFile.offsetAt(position) + → completion context analysis over the existing red tree + AST ancestors + → explicit provider dispatch + → CompletionItem[] +``` + +Unsupported, unconfigured, missing-document, and missing-artifact cases return an empty completion list. Completion does not parse a second copy of the document with a marker inserted. + +The classifier is a pure layer in the language-server package. It uses `SourceFile.offsetAt(position)`, red-tree offsets/tokens/ancestors, `FieldDeclarationAst.typeAnnotation()`, `TypeAnnotationAst.name()`, and `QualifiedNameAst` to identify model field type contexts. It recognizes blank and partial type positions such as: + +```prisma +model Post { + author | + reviewer U| + owner auth.| + editor auth.U| + external supabase:| + externalUser supabase:auth.| +} +``` + +The classifier explicitly rejects ordinary PSL `@` / `@@` attributes, attribute arguments, generic blocks, comments/trivia, constructor argument positions, and malformed over-qualified names that are not valid type-prefix contexts. This rejection is required because parser recovery can keep attribute syntax under nearby field structure; a field ancestor alone is not enough to classify a cursor as a type position. + +Provider dispatch is explicit. Slice 1 has one productive provider: + +| Context | Provider behavior | +| --- | --- | +| model field type position | Complete configured scalar types plus visible model, composite type, scalar, and type-alias symbols from the current project symbol table. | +| unsupported context | Return `[]`. | + +The model-type provider reads candidates from the configured scalar type set and the current `SymbolTable`: top-level models, composite types, scalars, type aliases, and namespace models/composite types. It ignores generic block symbols and does not provide relation-aware suggestions. Qualified prefixes preserve the user’s syntactic shape: bare `User`, namespace-qualified `auth.User`, and contract-space-qualified `supabase:auth.User` positions are classified and filtered as type positions. This slice does not invent a new external contract-space symbol index; contract-space-qualified completions use the visible candidate data already available to the language server. + +The server test harness grows a completion request helper analogous to the existing diagnostics/formatting helpers. Server tests cover capability advertisement and configured/unconfigured document behavior. + +## Coherence rationale + +This is one reviewable PR because the capability, classifier, provider dispatch seam, and first provider are one end-to-end feature path. Splitting the LSP route from the model-type provider would ship a completion capability with no useful completion behavior, while bundling generic block descriptor semantics would add a second syntactic domain and exceed the slice’s review coherence. + +## Scope + +**In:** + +- `packages/1-framework/3-tooling/language-server` completion capability and request handler. +- A pure language-server cursor-context classifier over cached `DocumentAst`, `SourceFile`, red-tree syntax, and the current `SymbolTable`. +- Model field type completion provider backed by configured scalar types and visible type-like symbol-table entries. +- Bare, namespace-qualified, and contract-space-qualified type-position classification and prefix filtering. +- Empty results for unsupported contexts, unconfigured documents, missing documents, and unavailable artifacts. +- Focused classifier/provider/server tests for model type positions and unsupported contexts. +- Language-server README update for the newly supported narrow completion scope. + +**Out:** + +- Generic block entry/parameter completions; those belong to slice `generic-block-completions`. +- Ordinary PSL `@` / `@@` attribute name completions. +- Attribute argument completions. +- Relation-aware completions such as `fields`, `references`, inverse relation suggestions, or field-specific relation ranking. +- A new external contract-space symbol index beyond the language server’s current project artifacts. +- Parser-wide public cursor helper exports unless implementation proves a local language-server helper is insufficient. +- Completion-marker reparsing. + +## Pre-investigated edge cases + +**None pre-investigated from outside the codebase.** The known code-grounded constraints are captured in the chosen design; new edge cases that surface at dispatch time amend the spec via `drive-discussion` per invariant I12. + +## Slice-specific done conditions + +- [ ] The completed slice leaves a typed classifier/provider dispatch seam that `generic-block-completions` can extend without changing the LSP request handler. +- [ ] Language-server documentation no longer says completion is wholly out of scope and does not overclaim generic block, ordinary attribute, relation-aware, or external contract-space candidate support. + +## Open Questions + +None. + +## References + +- Parent project: `projects/lsp-autocomplete/spec.md` +- Project plan: `projects/lsp-autocomplete/plan.md` +- Linear issue: [TML-2946](https://linear.app/prisma-company/issue/TML-2946/lsp-autocomplete-model-type-completions) +- Relevant code surfaces: + - `packages/1-framework/3-tooling/language-server/src/server.ts` + - `packages/1-framework/3-tooling/language-server/src/project-artifacts.ts` + - `packages/1-framework/3-tooling/language-server/src/pipeline.ts` + - `packages/1-framework/3-tooling/language-server/test/server.test.ts` + - `packages/1-framework/2-authoring/psl-parser/src/source-file.ts` + - `packages/1-framework/2-authoring/psl-parser/src/syntax/red.ts` + - `packages/1-framework/2-authoring/psl-parser/src/syntax/ast/declarations.ts` + - `packages/1-framework/2-authoring/psl-parser/src/syntax/ast/type-annotation.ts` + - `packages/1-framework/2-authoring/psl-parser/src/syntax/ast/qualified-name.ts` + - `packages/1-framework/2-authoring/psl-parser/src/symbol-table.ts` diff --git a/projects/lsp-autocomplete/spec.md b/projects/lsp-autocomplete/spec.md new file mode 100644 index 0000000000..55a788ab68 --- /dev/null +++ b/projects/lsp-autocomplete/spec.md @@ -0,0 +1,110 @@ +# lsp-autocomplete + +## Purpose + +Give PSL authors the first useful LSP autocomplete loop for schema authoring while validating the repo's cursor-context design against the existing recovered CST/AST. The project exists to prove completion can be built on the current parser, source-file, red-tree, and symbol-table artifacts without introducing speculative completion-marker reparsing. + +## At a glance + +The first autocomplete surface is intentionally narrow: when editing a configured PSL input, the language server classifies the cursor against the cached `DocumentAst`, `SourceFile`, and project `SymbolTable`, then routes to focused completion providers. + +```prisma +model Post { + author | +} +``` + +At a model field type position, completion suggests valid type names from the configured scalar set and known schema symbols such as models, composite types, scalar aliases, and type aliases. Qualified type positions are in scope: completion must handle bare `User`, namespace-qualified `auth.User`, and contract-space-qualified `supabase:auth.User` prefixes in the existing type-annotation grammar. + +```prisma +datasource db { + | +} +``` + +Inside a generic block, completion suggests descriptor-backed block entries/parameters for that block kind. In this spec, “generic block attributes” means the key/value-style generic block members represented by `GenericBlockDeclaration` / `KeyValuePairAst` and extension-block descriptors. Ordinary PSL `@` / `@@` field/model attributes are deliberately out of scope for this part. + +The intended shape is: + +```text +LSP completion position + → configured PSL document check + → SourceFile.offsetAt(position) + → red-tree touching token / previous token / ancestor context + → CompletionAnalysis + → explicit provider dispatch + → LSP CompletionItem[] +``` + +## Non-goals + +- Ordinary PSL `@` / `@@` field or model attribute name completions. +- Attribute argument completions, including relation-specific argument completions. +- Relation-aware completions such as `fields`, `references`, or inverse relation suggestions. +- Completion for unopened configured inputs or cross-file project tables beyond the current cached project artifact shape. +- Speculative completion-marker reparsing. +- Hover, go-to-definition, semantic tokens, diagnostics redesign, or formatting changes. +- A complete ranking/scoring engine for completion entries beyond stable, useful ordering for the scoped providers. + +## Place in the larger world + +This project layers on the existing language-server and parser artifacts rather than replacing them. + +- `packages/1-framework/3-tooling/language-server/src/server.ts` currently supports diagnostics and formatting. It also exposes `getDocumentAst()` and `getProjectSymbolTable()` as future-feature hooks. +- `packages/1-framework/3-tooling/language-server/src/project-artifacts.ts` preserves each open document's `DocumentAst` and `SourceFile`, plus one project `SymbolTable`. +- `packages/1-framework/3-tooling/language-server/src/pipeline.ts` runs `parse()` and `buildSymbolTable()` and documents that malformed input should not throw. +- `packages/1-framework/2-authoring/psl-parser/src/parse.ts` already has fault-tolerant recovery for block declarations, fields, type annotations, attributes, and generic block key/value entries. Its `parseTypeAnnotation()` consumes a `QualifiedName`, and `parseQualifiedName()` accepts `[space ':']? Ident ('.' Ident)*`, matching type references such as `auth.User` and `supabase:auth.User`. +- `packages/1-framework/2-authoring/psl-parser/src/syntax/red.ts` provides offsets, parents, ancestors, child iteration, descendants, and token iteration. +- `packages/1-framework/2-authoring/psl-parser/src/symbol-table.ts` provides model, composite type, scalar, type-alias, block, and field symbols that completion can reuse. The resolved field shape already splits type references into `typeName`, `typeNamespaceId`, and `typeContractSpaceId`, and reports `PSL_INVALID_QUALIFIED_TYPE` for over-qualified field types. +- Generic block entry completions depend on the `pslBlockDescriptors` already passed through the language-server control stack. + +No contract or adapter impact is expected. This project changes editor tooling behavior only; it should not change emitted contracts, runtime behavior, migration output, or target adapters. + +## Cross-cutting requirements + +- Completion uses the existing cached parse artifacts for the open configured PSL input. It must not create a parallel parse pipeline for normal operation. +- Cursor classification is implemented as a pure, testable layer that maps `(document, sourceFile, offset, symbolTable, controlStack)` to a typed completion context. +- The classifier uses tsserver-style previous/current token handling where prefix typing matters, for example partial identifiers after a field name or inside a generic block. +- Provider routing is explicit. Completion providers should not independently rediscover syntactic context. +- Completion is available only for configured PSL inputs, matching the current diagnostics/formatting ownership rules. +- Diagnostics and formatting behavior remain unchanged while completion is added. +- The first provider set is limited to model field type completions, including namespace-qualified type positions, and generic block entry/parameter completions. +- Ordinary PSL `@` / `@@` attribute contexts must not accidentally receive the new generic block or model type suggestions. +- Tests are written before or alongside implementation changes. + +## Transitional-shape constraints + +- Do not introduce speculative completion-marker reparsing unless a concrete parser/AST gap is proven by a failing test that cannot be solved with red-tree cursor utilities. +- Parser or syntax export changes must be minimal and justified by completion’s cursor-context needs. +- Each intermediate slice keeps diagnostics and formatting tests green. +- The language server may advertise completion only once unsupported contexts safely return empty results rather than misleading suggestions. +- Generic block completions may initially be descriptor-name focused; richer value-specific completions can come later without changing the classifier shape. + +## Project Definition of Done + +- [ ] Team-DoD floor items inherited from `drive/calibration/dod.md`. +- [ ] The language server advertises completion support for configured PSL inputs without changing diagnostics or formatting behavior. +- [ ] Model field type positions complete configured scalar types plus visible model, composite type, scalar, and type-alias symbols from the current project symbol table, including namespace-qualified and contract-space-qualified type prefixes. +- [ ] Generic block entry/parameter positions complete descriptor-backed keys or values for the current `GenericBlockDeclaration` where descriptor data is available. +- [ ] Ordinary PSL `@` / `@@` field/model attribute contexts return no scoped suggestions from this project’s providers. +- [ ] Cursor-context classification has focused tests for blank model bodies, field-name prefixes, bare and namespace-qualified field-type positions, generic block blank lines, generic block partial keys, comments/trivia, and unsupported contexts. +- [ ] LSP/server tests cover completion requests against an open configured PSL document. +- [ ] Package documentation or README notes the supported completion scope and explicitly names the out-of-scope attribute surfaces. +- [ ] Validation gates for touched packages pass, including typecheck, lint, and relevant package tests. + +## Open Questions + +None. + +## References + +- Linear Project: not linked in this chat. +- Sibling / dependent projects: current language-server diagnostics and formatting surfaces. +- ADRs: N/A — no durable architectural shift expected beyond the local completion-context pattern. +- Code references: + - `packages/1-framework/3-tooling/language-server/src/server.ts` + - `packages/1-framework/3-tooling/language-server/src/project-artifacts.ts` + - `packages/1-framework/3-tooling/language-server/src/pipeline.ts` + - `packages/1-framework/2-authoring/psl-parser/src/parse.ts` + - `packages/1-framework/2-authoring/psl-parser/src/syntax/red.ts` + - `packages/1-framework/2-authoring/psl-parser/src/symbol-table.ts` diff --git a/projects/lsp-autocomplete/trace.jsonl b/projects/lsp-autocomplete/trace.jsonl new file mode 100644 index 0000000000..4ee7915046 --- /dev/null +++ b/projects/lsp-autocomplete/trace.jsonl @@ -0,0 +1,27 @@ +{"event_id":"4ed57784-402f-4dde-9a2e-4845293db750","event_type":"spec-authored","schema_version":"1","ts":"2026-06-24T15:00:58.405Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"spec_path":"projects/lsp-autocomplete/spec.md","spec_kind":"project","byte_length":7527,"edge_cases_count":null,"open_questions_count":0,"dod_items_count":9} +{"event_id":"0e623027-20e6-4b45-94d8-8785e391dbd9","event_type":"spec-amended","schema_version":"1","ts":"2026-06-24T15:06:37.042Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"spec_path":"projects/lsp-autocomplete/spec.md","spec_kind":"project","byte_length":8274,"bytes_delta":747,"edge_cases_count":null,"open_questions_count":0,"dod_items_count":9,"reason":"scope-shift","sections_changed":["At a glance","Place in the larger world","Cross-cutting requirements","Project Definition of Done"]} +{"event_id":"0f4cf6ce-9e0a-43b4-b1a9-bedb1d9176c8","event_type":"plan-authored","schema_version":"1","ts":"2026-06-24T16:21:14.109Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"plan_path":"projects/lsp-autocomplete/plan.md","plan_kind":"project","byte_length":4599,"dispatch_count":null,"slice_count":2,"dispatch_size_distribution":null,"open_items_count":0} +{"event_id":"aa153178-fea9-45ac-b5e4-ff673ddcea84","event_type":"health-check-fired","schema_version":"1","ts":"2026-06-25T11:03:43.828Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"cadence":"opening-rollup","drift_signal_count":0,"max_drift_severity":"none","recommended_next":"Start slice model-type-completions (TML-2946)."} +{"event_id":"4751a039-5eb0-4b7f-bb7a-8462fc4053ed","event_type":"spec-authored","schema_version":"1","ts":"2026-06-25T11:21:45.497Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"spec_path":"projects/lsp-autocomplete/slices/model-type-completions/spec.md","spec_kind":"slice","byte_length":6762,"edge_cases_count":0,"open_questions_count":0,"dod_items_count":2} +{"event_id":"88acb6b4-94da-4676-9291-5cdc9036976a","event_type":"plan-authored","schema_version":"1","ts":"2026-06-25T11:21:45.498Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"plan_path":"projects/lsp-autocomplete/slices/model-type-completions/plan.md","plan_kind":"slice","byte_length":6687,"dispatch_count":4,"slice_count":null,"dispatch_size_distribution":{"S":0,"M":4,"L":0,"XL":0},"open_items_count":0} +{"event_id":"e3793112-e9d2-4392-bfec-80f379b37cd6","event_type":"slice-started","schema_version":"1","ts":"2026-06-25T11:22:23.553Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"slice_slug":"model-type-completions","slice_index":1,"linear_ref":"TML-2946"} +{"event_id":"ff05c1d9-417e-48ed-994c-46fc247b5126","event_type":"dispatch-start","schema_version":"1","ts":"2026-06-25T11:25:08.893Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"5947dc2d-4f4b-49d4-a1fb-1f4afbfdca23","dispatch_name":"cursor-classifier-substrate","subagent_type":"implementer/fast","model":"GPT-5.5","parent_dispatch_id":null} +{"event_id":"9835a693-0e26-46c2-bf93-2138560abe24","event_type":"round-start","schema_version":"1","ts":"2026-06-25T11:25:08.893Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"5947dc2d-4f4b-49d4-a1fb-1f4afbfdca23","round_id":"d645299c-0031-4a43-9b59-ed920dcff41d","round_number":1} +{"event_id":"29283204-e15d-4429-b877-2c3281daaf74","event_type":"brief-issued","schema_version":"1","ts":"2026-06-25T11:25:08.893Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"5947dc2d-4f4b-49d4-a1fb-1f4afbfdca23","round_id":"d645299c-0031-4a43-9b59-ed920dcff41d","brief_byte_length":4573,"brief_content_hash":"fb41d44eb8f28dd310937df4d66494ed14a24313ac737100aecbbde20a99c4ee","brief_disposition":"initial"} +{"event_id":"0b88efbe-8ab0-44c5-b51c-58ed28343cb8","event_type":"round-end","schema_version":"1","ts":"2026-06-25T11:57:39.436Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"5947dc2d-4f4b-49d4-a1fb-1f4afbfdca23","round_id":"d645299c-0031-4a43-9b59-ed920dcff41d","verdict":"satisfied","findings_filed":0,"wall_clock_ms":1950543} +{"event_id":"29d1e947-700e-43fa-a832-0296691673cd","event_type":"dispatch-end","schema_version":"1","ts":"2026-06-25T11:57:39.436Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"5947dc2d-4f4b-49d4-a1fb-1f4afbfdca23","result":"completed","wall_clock_ms":1950543} +{"event_id":"1b8b899e-6b04-4f86-94c8-0b97690b7471","event_type":"dispatch-start","schema_version":"1","ts":"2026-06-25T11:58:59.102Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"e5d21f42-c48e-46bc-a8f4-1839303f2958","dispatch_name":"model-type-provider","subagent_type":"implementer/fast","model":"GPT-5.5","parent_dispatch_id":"5947dc2d-4f4b-49d4-a1fb-1f4afbfdca23"} +{"event_id":"f00b324a-bede-4e3e-b8f0-322e5cc37cd4","event_type":"round-start","schema_version":"1","ts":"2026-06-25T11:58:59.102Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"e5d21f42-c48e-46bc-a8f4-1839303f2958","round_id":"b7306d31-7eb6-41e6-a7cf-f77c5ee71892","round_number":1} +{"event_id":"4440685f-19ea-4fcc-af06-b7dbe77721d1","event_type":"brief-issued","schema_version":"1","ts":"2026-06-25T11:58:59.102Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"e5d21f42-c48e-46bc-a8f4-1839303f2958","round_id":"b7306d31-7eb6-41e6-a7cf-f77c5ee71892","brief_byte_length":4036,"brief_content_hash":"f7024f5b519b4e39d690e12b236573d4d00384ad44a02bfd5f80da45a14ab17a","brief_disposition":"initial"} +{"event_id":"b87ad897-2907-4587-9a36-8716035ba9ea","event_type":"round-end","schema_version":"1","ts":"2026-06-25T12:18:52.716Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"e5d21f42-c48e-46bc-a8f4-1839303f2958","round_id":"b7306d31-7eb6-41e6-a7cf-f77c5ee71892","verdict":"satisfied","findings_filed":0,"wall_clock_ms":1193614} +{"event_id":"b181df1e-7e70-4d3b-b6d5-22850216340a","event_type":"dispatch-end","schema_version":"1","ts":"2026-06-25T12:18:52.716Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"e5d21f42-c48e-46bc-a8f4-1839303f2958","result":"completed","wall_clock_ms":1193614} +{"event_id":"d88d2aa7-b26b-4088-9845-461666d0d951","event_type":"dispatch-start","schema_version":"1","ts":"2026-06-25T12:20:48.885Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"0fb30343-7c83-48d5-af0a-c7e9dbfb771b","dispatch_name":"lsp-completion-route","subagent_type":"implementer/fast","model":"GPT-5.5","parent_dispatch_id":"e5d21f42-c48e-46bc-a8f4-1839303f2958"} +{"event_id":"420f4094-41fd-40d4-839b-f028971625df","event_type":"round-start","schema_version":"1","ts":"2026-06-25T12:20:48.885Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"0fb30343-7c83-48d5-af0a-c7e9dbfb771b","round_id":"c3095776-c25f-4d4c-b780-f50cab74c1a3","round_number":1} +{"event_id":"68736cb8-5b56-485b-9b76-734fee524bc9","event_type":"brief-issued","schema_version":"1","ts":"2026-06-25T12:20:48.885Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"0fb30343-7c83-48d5-af0a-c7e9dbfb771b","round_id":"c3095776-c25f-4d4c-b780-f50cab74c1a3","brief_byte_length":4826,"brief_content_hash":"d8c80db10794af37454b9085bc369eb9e98b1cdba1cad715289f5e17a94da2da","brief_disposition":"initial"} +{"event_id":"5391fb9b-f18a-4c8b-9c1a-056674e47147","event_type":"round-end","schema_version":"1","ts":"2026-06-25T13:11:28.273Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"0fb30343-7c83-48d5-af0a-c7e9dbfb771b","round_id":"c3095776-c25f-4d4c-b780-f50cab74c1a3","verdict":"satisfied","findings_filed":0,"wall_clock_ms":3039388} +{"event_id":"0fa28427-b23c-49cc-9a6a-f76a2eda10c3","event_type":"dispatch-end","schema_version":"1","ts":"2026-06-25T13:11:28.273Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"0fb30343-7c83-48d5-af0a-c7e9dbfb771b","result":"completed","wall_clock_ms":3039388} +{"event_id":"45e6d883-5c44-4a6c-b112-758e82a8fb2a","event_type":"dispatch-start","schema_version":"1","ts":"2026-06-25T13:12:48.946Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"8c39fcaa-7306-425f-982b-fd5b4f7cdc5c","dispatch_name":"scope-docs-and-final-guardrails","subagent_type":"implementer/fast","model":"GPT-5.5","parent_dispatch_id":"0fb30343-7c83-48d5-af0a-c7e9dbfb771b"} +{"event_id":"ba489f0c-a63f-4984-b69c-747fb579bdb6","event_type":"round-start","schema_version":"1","ts":"2026-06-25T13:12:48.946Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"8c39fcaa-7306-425f-982b-fd5b4f7cdc5c","round_id":"dc7ce623-26b7-41fb-9b78-34b6962a7269","round_number":1} +{"event_id":"3c8d8e21-eb71-43e4-9381-db9806243c25","event_type":"brief-issued","schema_version":"1","ts":"2026-06-25T13:12:48.946Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"8c39fcaa-7306-425f-982b-fd5b4f7cdc5c","round_id":"dc7ce623-26b7-41fb-9b78-34b6962a7269","brief_byte_length":4831,"brief_content_hash":"b5629c09b472447ef5e3be56f6efab6b3bf701d409279d65b33dd9939ed5090d","brief_disposition":"initial"} +{"event_id":"11258695-c9fa-4a3f-9150-8b19c9f4528b","event_type":"round-end","schema_version":"1","ts":"2026-06-25T13:21:21.652Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"8c39fcaa-7306-425f-982b-fd5b4f7cdc5c","round_id":"dc7ce623-26b7-41fb-9b78-34b6962a7269","verdict":"satisfied","findings_filed":0,"wall_clock_ms":512706} +{"event_id":"ff35f2ce-eb15-4d7a-92ed-0f9f7f17aaf6","event_type":"dispatch-end","schema_version":"1","ts":"2026-06-25T13:21:21.652Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"8c39fcaa-7306-425f-982b-fd5b4f7cdc5c","result":"completed","wall_clock_ms":512706} From a8cbee329e0389a1914c6ccef0cdd41d9a54c05f Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Thu, 25 Jun 2026 13:56:03 +0000 Subject: [PATCH 02/54] TML-2946: add generic block completions Signed-off-by: Serhii Tatarintsev --- .../3-tooling/language-server/README.md | 8 +- .../language-server/src/completion-context.ts | 156 +++++++++++++++++- .../src/completion-provider.ts | 57 ++++++- .../3-tooling/language-server/src/server.ts | 1 + .../test/completion-context.test.ts | 25 ++- .../test/completion-provider.test.ts | 60 ++++++- .../language-server/test/server.test.ts | 31 +++- 7 files changed, 318 insertions(+), 20 deletions(-) diff --git a/packages/1-framework/3-tooling/language-server/README.md b/packages/1-framework/3-tooling/language-server/README.md index b2525db23b..eb5671a344 100644 --- a/packages/1-framework/3-tooling/language-server/README.md +++ b/packages/1-framework/3-tooling/language-server/README.md @@ -6,7 +6,7 @@ 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, full/range semantic tokens, and model field type 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 and only at model field type positions. It suggests configured scalar types plus visible model, composite type, scalar, and type-alias candidates from the current project symbol table, including namespace-qualified and contract-space-qualified type-position syntax when those candidates are visible in the current cached artifacts. Generic block entry or parameter completions, ordinary PSL `@` / `@@` attribute completions, attribute argument 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. +Supported capabilities are intentionally narrow: parse diagnostics, whole-document formatting, folding ranges, full/range semantic tokens, model field type completion, and descriptor-backed generic block parameter 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, and type-alias candidates from the current project symbol table, including namespace-qualified and contract-space-qualified type-position syntax when those candidates are 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. Ordinary PSL `@` / `@@` attribute completions, attribute argument completions, generic block parameter 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 @@ -31,7 +31,7 @@ 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. **Completion** — on `textDocument/completion`, the server serves configured PSL model field type positions from cached parse artifacts. It classifies the cursor using the cached AST/source file, reads the current project symbol table, and returns `[]` for missing or closed documents, unconfigured documents, unavailable artifacts, unsupported contexts, ordinary attributes or attribute arguments, generic block contexts, relation-aware scenarios, and external contract-space discovery gaps. +7. **Completion** — on `textDocument/completion`, the server serves configured PSL model field type positions and descriptor-backed generic block parameter-key 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, and returns `[]` for missing or closed documents, unconfigured documents, unavailable artifacts, unsupported contexts, ordinary attributes or attribute arguments, generic block parameter values, 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 @@ -43,8 +43,8 @@ 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. -- `completion-context.ts` — pure cursor classifier for PSL completion contexts, currently routing model field type positions and marking everything outside slice scope unsupported. -- `completion-provider.ts` — pure completion item provider for supported model field type contexts. +- `completion-context.ts` — pure cursor classifier for PSL completion contexts, currently routing model field type positions and descriptor-backed generic block parameter-key positions while marking everything outside slice scope unsupported. +- `completion-provider.ts` — pure completion item provider for supported model field type and generic block parameter 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. 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 index 0ea195930b..66297b55f7 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-context.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-context.ts @@ -46,6 +46,15 @@ export interface ModelFieldTypeCompletionContext { readonly prefix: TypeNamePrefix; } +export interface GenericBlockParameterCompletionContext { + readonly kind: 'genericBlockParameter'; + readonly offset: number; + readonly blockKeyword: string; + readonly prefix: string; + readonly replacementStartOffset: number; + readonly existingParameterNames: readonly string[]; +} + export interface UnsupportedPslCompletionContext { readonly kind: 'unsupported'; readonly offset: number; @@ -53,6 +62,7 @@ export interface UnsupportedPslCompletionContext { } export type PslCompletionContext = + | GenericBlockParameterCompletionContext | ModelFieldTypeCompletionContext | UnsupportedPslCompletionContext; @@ -83,6 +93,16 @@ export function classifyPslCompletionContext( return unsupported(offset, ancestorReason); } + const genericBlockContext = classifyGenericBlockParameter({ + node: contextNode, + offset, + sourceFile: input.sourceFile, + tokenContext, + }); + if (genericBlockContext !== undefined) { + return genericBlockContext; + } + const field = closestAst(contextNode, FieldDeclarationAst.cast); if (field === undefined) { return unsupported(offset, 'outsideModelField'); @@ -169,6 +189,136 @@ function modelFieldType( return { kind: 'modelFieldType', offset, fieldName, prefix }; } +function classifyGenericBlockParameter(input: { + readonly node: SyntaxNode | undefined; + readonly offset: number; + readonly sourceFile: SourceFile; + readonly tokenContext: TokenContext; +}): PslCompletionContext | undefined { + const block = closestAst(input.node, GenericBlockDeclarationAst.cast); + if (block === undefined) { + return undefined; + } + + const lbrace = firstTokenOfKind(block.syntax, 'LBrace'); + if (lbrace === undefined || input.offset < lbrace.offset + lbrace.text.length) { + return unsupported(input.offset, 'genericBlock'); + } + + const rbrace = firstTokenOfKind(block.syntax, 'RBrace'); + if (rbrace !== undefined && input.offset > rbrace.offset) { + return unsupported(input.offset, 'genericBlock'); + } + + const field = closestAst(input.node, FieldDeclarationAst.cast); + if (field !== undefined && containsOffset(field.syntax, input.offset)) { + return unsupported(input.offset, 'genericBlock'); + } + + if (input.tokenContext.previousSignificant?.kind === 'Equals') { + return unsupported(input.offset, 'genericBlock'); + } + + const keyword = block.keyword()?.text; + if (keyword === undefined || keyword.length === 0) { + return unsupported(input.offset, 'genericBlock'); + } + + const activePair = activeKeyValuePair(input.node, input.offset); + if (activePair !== undefined && isAfterEquals(activePair, input.offset)) { + return unsupported(input.offset, 'genericBlock'); + } + + const prefix = genericBlockParameterPrefix(activePair, input.offset, input.sourceFile.text); + if (prefix === undefined) { + return unsupported(input.offset, 'genericBlock'); + } + + return { + kind: 'genericBlockParameter', + offset: input.offset, + blockKeyword: keyword, + prefix: prefix.text, + replacementStartOffset: prefix.replacementStartOffset, + existingParameterNames: existingParameterNames(block, activePair), + }; +} + +function activeKeyValuePair( + node: SyntaxNode | undefined, + offset: number, +): KeyValuePairAst | undefined { + const pair = closestAst(node, KeyValuePairAst.cast); + if (pair === undefined || !containsOffset(pair.syntax, offset)) { + return undefined; + } + return pair; +} + +function isAfterEquals(pair: KeyValuePairAst, offset: number): boolean { + const equals = firstTokenOfKind(pair.syntax, 'Equals'); + return equals !== undefined && offset > equals.offset; +} + +function genericBlockParameterPrefix( + pair: KeyValuePairAst | undefined, + offset: number, + source: string, +): { readonly text: string; readonly replacementStartOffset: number } | undefined { + if (pair === undefined) { + return { text: '', replacementStartOffset: offset }; + } + + const key = pair.key(); + if (key === undefined) { + return { text: '', replacementStartOffset: offset }; + } + + const keyStart = key.syntax.offset; + const keyEnd = endOffset(key.syntax); + if (offset < keyStart) { + return { text: '', replacementStartOffset: offset }; + } + if (offset <= keyEnd) { + return { text: source.slice(keyStart, offset), replacementStartOffset: keyStart }; + } + const equals = firstTokenOfKind(pair.syntax, 'Equals'); + if (equals === undefined || offset <= equals.offset) { + return { text: source.slice(keyStart, keyEnd), replacementStartOffset: keyStart }; + } + return undefined; +} + +function existingParameterNames( + block: GenericBlockDeclarationAst, + activePair: KeyValuePairAst | undefined, +): readonly string[] { + const names: string[] = []; + for (const entry of block.entries()) { + if (activePair !== undefined && sameSpan(entry.syntax, activePair.syntax)) { + continue; + } + const name = entry.key()?.name(); + if (name !== undefined) { + names.push(name); + } + } + return names; +} + +function sameSpan(left: SyntaxNode, right: SyntaxNode): boolean { + return left.offset === right.offset && left.textLength === right.textLength; +} + +function firstTokenOfKind(node: SyntaxNode, kind: SyntaxToken['kind']): SyntaxToken | undefined { + for (const token of node.tokens()) { + if (token.kind === kind) { + return token; + } + } + return undefined; +} + function unsupported( offset: number, reason: UnsupportedPslCompletionReason, @@ -191,12 +341,6 @@ function unsupportedAncestorReason( ) { return 'attribute'; } - if ( - closestAst(node, GenericBlockDeclarationAst.cast) !== undefined || - closestAst(node, KeyValuePairAst.cast) !== undefined - ) { - return 'genericBlock'; - } 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 index f7ae9bcb7a..c9f56eb42f 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-provider.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-provider.ts @@ -1,10 +1,20 @@ -import type { NamespaceSymbol, SymbolTable } from '@prisma-next/psl-parser'; +import type { AuthoringPslBlockDescriptorNamespace } from '@prisma-next/framework-components/authoring'; +import { + findBlockDescriptor, + type NamespaceSymbol, + type SymbolTable, +} from '@prisma-next/psl-parser'; import type { SourceFile } from '@prisma-next/psl-parser/syntax'; import { type CompletionItem, CompletionItemKind } from 'vscode-languageserver'; -import type { ModelFieldTypeCompletionContext, PslCompletionContext } from './completion-context'; +import type { + GenericBlockParameterCompletionContext, + ModelFieldTypeCompletionContext, + PslCompletionContext, +} from './completion-context'; export interface PslCompletionCandidateSource { readonly scalarTypes: readonly string[]; + readonly pslBlockDescriptors: AuthoringPslBlockDescriptorNamespace; readonly symbolTable?: SymbolTable; } @@ -48,9 +58,48 @@ export function providePslCompletionItems( if (input.context.kind === 'unsupported') { return []; } + if (input.context.kind === 'genericBlockParameter') { + return provideGenericBlockParameterCompletionItems( + input.context, + input.sourceFile, + input.candidates, + ); + } return provideModelFieldTypeCompletionItems(input.context, input.sourceFile, input.candidates); } +function provideGenericBlockParameterCompletionItems( + context: GenericBlockParameterCompletionContext, + sourceFile: SourceFile, + source: PslCompletionCandidateSource, +): readonly CompletionItem[] { + const descriptor = findBlockDescriptor(source.pslBlockDescriptors, context.blockKeyword); + if (descriptor === undefined) { + return []; + } + + const existing = new Set(context.existingParameterNames); + const replacementRange = { + start: sourceFile.positionAt(context.replacementStartOffset), + end: sourceFile.positionAt(context.offset), + }; + + return Object.keys(descriptor.parameters) + .filter((parameterName) => !existing.has(parameterName)) + .filter((parameterName) => parameterName.startsWith(context.prefix)) + .map((parameterName, index) => ({ + label: parameterName, + kind: CompletionItemKind.Property, + detail: 'Generic block parameter', + sortText: genericBlockParameterSortText(index, parameterName), + filterText: parameterName, + textEdit: { + range: replacementRange, + newText: parameterName, + }, + })); +} + function provideModelFieldTypeCompletionItems( context: ModelFieldTypeCompletionContext, sourceFile: SourceFile, @@ -246,6 +295,10 @@ 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; 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 2afed2aca3..156c40d217 100644 --- a/packages/1-framework/3-tooling/language-server/src/server.ts +++ b/packages/1-framework/3-tooling/language-server/src/server.ts @@ -326,6 +326,7 @@ export function createServer(connection: Connection): LanguageServer { sourceFile: cached.sourceFile, candidates: { scalarTypes: project.controlStack.scalarTypes, + pslBlockDescriptors: project.controlStack.pslBlockDescriptors, symbolTable, }, }), 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 index ffc278093f..d50ff813b2 100644 --- 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 @@ -120,13 +120,28 @@ describe('classifyPslCompletionContext', () => { }); }); - it('returns unsupported inside generic block contexts', () => { - expect(classify(['datasource db {', ' provider = |', '}'].join('\n'))).toMatchObject({ - kind: 'unsupported', - reason: 'genericBlock', + it('classifies blank generic block parameter positions', () => { + expect(classify(['policy UserAccess {', ' |', '}'].join('\n'))).toMatchObject({ + kind: 'genericBlockParameter', + blockKeyword: 'policy', + prefix: '', + existingParameterNames: [], }); + }); - expect(classify(['generator client {', ' prov|', '}'].join('\n'))).toMatchObject({ + it('classifies generic block parameter prefixes and records sibling keys', () => { + expect(classify(['policy UserAccess {', ' on = User', ' wh|', '}'].join('\n'))).toMatchObject( + { + kind: 'genericBlockParameter', + blockKeyword: 'policy', + prefix: 'wh', + existingParameterNames: ['on'], + }, + ); + }); + + it('returns unsupported inside generic block parameter values', () => { + expect(classify(['datasource db {', ' provider = |', '}'].join('\n'))).toMatchObject({ kind: 'unsupported', reason: 'genericBlock', }); 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 index a94b44c3c1..0b5d5aa963 100644 --- 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 @@ -1,3 +1,4 @@ +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'; @@ -6,6 +7,21 @@ import { providePslCompletionItems } from '../src/completion-provider'; const scalarTypes = ['String', 'Int', 'Boolean', 'DateTime'] as const; +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' }, + }, + }, +}; + const candidateSource = [ 'types {', ' Email = String', @@ -46,7 +62,7 @@ function complete(markedFieldSource: string) { document, sourceFile, scalarTypes, - pslBlockDescriptors: {}, + pslBlockDescriptors, }); const context = classifyPslCompletionContext({ document, @@ -58,7 +74,7 @@ function complete(markedFieldSource: string) { items: providePslCompletionItems({ context, sourceFile, - candidates: { scalarTypes, symbolTable }, + candidates: { scalarTypes, pslBlockDescriptors, symbolTable }, }), sourceFile, cursorOffset, @@ -150,6 +166,46 @@ describe('providePslCompletionItems', () => { }); }); + 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('filters descriptor-backed generic block parameters and excludes sibling keys', () => { + const { items, sourceFile, cursorOffset } = complete( + ['policy Rule {', ' on = User', ' wh|', '}'].join('\n'), + ); + + expect(items.map((item) => item.label)).toEqual(['where']); + expect(items[0]?.textEdit).toEqual({ + range: { + start: sourceFile.positionAt(cursorOffset - 'wh'.length), + end: sourceFile.positionAt(cursorOffset), + }, + newText: 'where', + }); + }); + + 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')); 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 92fa0c3200..2aebe2b44b 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'; @@ -79,15 +80,30 @@ const formattedPsl = 'model User {\n id Int\n}\n'; const scalarTypes = ['String', 'Int', 'Boolean', 'DateTime'] as const; +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 }; } @@ -100,6 +116,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); @@ -498,6 +516,17 @@ describe('language server', { timeout: timeouts.databaseOperation }, () => { ]); }); + it('returns generic block parameter completions for configured PSL descriptors', async () => { + harness = startHarness(resolveToSchemaWithPslBlockDescriptors); + await harness.initialize(); + const { source, position } = sourceWithCursor(['policy UserAccess {', ' wh|', '}'].join('\n')); + openDocument(harness, schemaUri, source); + await harness.waitForDiagnostics(schemaUri); + + const items = completionItems(await requestCompletion(harness, schemaUri, position)); + expect(items.map((item) => item.label)).toEqual(['where']); + }); + it('returns no completion items for unconfigured PSL documents', async () => { harness = startHarness(resolveToSchema); await harness.initialize(); From 86926a9175d18ee865d4cf1fae041e37bb989bdf Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Thu, 25 Jun 2026 14:18:26 +0000 Subject: [PATCH 03/54] TML-2946: complete namespaces before members Signed-off-by: Serhii Tatarintsev --- .../3-tooling/language-server/README.md | 2 +- .../src/completion-provider.ts | 57 +++++-------------- .../test/completion-provider.test.ts | 44 ++++++++++++-- 3 files changed, 54 insertions(+), 49 deletions(-) diff --git a/packages/1-framework/3-tooling/language-server/README.md b/packages/1-framework/3-tooling/language-server/README.md index eb5671a344..07c14ff5a3 100644 --- a/packages/1-framework/3-tooling/language-server/README.md +++ b/packages/1-framework/3-tooling/language-server/README.md @@ -6,7 +6,7 @@ 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, full/range semantic tokens, model field type completion, and descriptor-backed generic block parameter 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, and type-alias candidates from the current project symbol table, including namespace-qualified and contract-space-qualified type-position syntax when those candidates are 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. Ordinary PSL `@` / `@@` attribute completions, attribute argument completions, generic block parameter 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. +Supported capabilities are intentionally narrow: parse diagnostics, whole-document formatting, folding ranges, full/range semantic tokens, model field type completion, and descriptor-backed generic block parameter 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. Ordinary PSL `@` / `@@` attribute completions, attribute argument completions, generic block parameter 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 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 index c9f56eb42f..6c27d7a4af 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-provider.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-provider.ts @@ -30,6 +30,7 @@ type ModelTypeCompletionCandidateCategory = | 'topLevelCompositeType' | 'scalar' | 'typeAlias' + | 'namespace' | 'namespaceModel' | 'namespaceCompositeType'; @@ -48,8 +49,9 @@ const categoryOrder: Record = { topLevelCompositeType: 2, scalar: 3, typeAlias: 4, - namespaceModel: 5, - namespaceCompositeType: 6, + namespace: 5, + namespaceModel: 6, + namespaceCompositeType: 7, }; export function providePslCompletionItems( @@ -197,7 +199,7 @@ function allNamespaceCandidates( } return Object.values(symbolTable.topLevel.namespaces) .sort((left, right) => compareNames(left.name, right.name)) - .flatMap(namespaceCandidatesForBareContext); + .map(namespaceQualifierCandidate); } function namespaceCandidates( @@ -222,25 +224,16 @@ function namespaceCandidates( ]; } -function namespaceCandidatesForBareContext( - namespace: NamespaceSymbol, -): readonly ModelTypeCompletionCandidate[] { - return [ - ...qualifiedNamespaceCandidates( - namespace.name, - recordNames(namespace.models), - 'namespaceModel', - `Model in namespace ${namespace.name}`, - CompletionItemKind.Class, - ), - ...qualifiedNamespaceCandidates( - namespace.name, - recordNames(namespace.compositeTypes), - 'namespaceCompositeType', - `Composite type in namespace ${namespace.name}`, - CompletionItemKind.Struct, - ), - ]; +function namespaceQualifierCandidate(namespace: NamespaceSymbol): ModelTypeCompletionCandidate { + const qualifier = `${namespace.name}.`; + return { + category: 'namespace', + label: qualifier, + insertText: qualifier, + filterText: qualifier, + detail: 'Namespace', + kind: CompletionItemKind.Module, + }; } function symbolCandidates( @@ -259,26 +252,6 @@ function symbolCandidates( })); } -function qualifiedNamespaceCandidates( - namespace: string, - names: readonly string[], - category: ModelTypeCompletionCandidateCategory, - detail: string, - kind: CompletionItemKind, -): readonly ModelTypeCompletionCandidate[] { - return names.map((name) => { - const qualifiedName = `${namespace}.${name}`; - return { - category, - label: qualifiedName, - insertText: qualifiedName, - filterText: qualifiedName, - detail, - kind, - }; - }); -} - function recordNames( record: Record, ): readonly string[] { 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 index 0b5d5aa963..dc339e4bb1 100644 --- 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 @@ -2,6 +2,7 @@ import type { AuthoringPslBlockDescriptorNamespace } from '@prisma-next/framewor import { buildSymbolTable } from '@prisma-next/psl-parser'; import { parse } from '@prisma-next/psl-parser/syntax'; import { describe, expect, it } from 'vitest'; +import { CompletionItemKind } from 'vscode-languageserver'; import { classifyPslCompletionContext } from '../src/completion-context'; import { providePslCompletionItems } from '../src/completion-provider'; @@ -97,9 +98,7 @@ describe('providePslCompletionItems', () => { 'Address', 'Email', 'UserId', - 'auth.Account', - 'auth.User', - 'auth.Profile', + 'auth.', ]); expect(items.map((item) => item.detail)).toEqual([ 'Configured scalar type', @@ -111,9 +110,7 @@ describe('providePslCompletionItems', () => { 'Composite type', 'Scalar type', 'Type alias', - 'Model in namespace auth', - 'Model in namespace auth', - 'Composite type in namespace auth', + 'Namespace', ]); expect(items[0]?.textEdit).toEqual({ range: { @@ -130,6 +127,41 @@ describe('providePslCompletionItems', () => { expect(items.map((item) => item.label)).toEqual(['User', 'UserId']); }); + it('filters bare namespace prefixes and replaces the typed segment with the namespace qualifier', () => { + const { items, sourceFile, cursorOffset } = complete( + ['model Post {', ' reviewer a|', '}'].join('\n'), + ); + + expect(items.map((item) => item.label)).toEqual(['auth.']); + expect(items[0]).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 namespace-qualified candidates with replacement metadata for the typed segment', () => { const { items, sourceFile, cursorOffset } = complete( ['model Post {', ' owner auth.U|', '}'].join('\n'), From f9071ddde127373235680c13877995d5a7a19cca Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Fri, 26 Jun 2026 09:36:07 +0000 Subject: [PATCH 04/54] feat(language-server): complete declaration keywords Signed-off-by: Serhii Tatarintsev --- .../3-tooling/language-server/README.md | 8 +- .../language-server/src/completion-context.ts | 163 +++++++++++++++++- .../src/completion-provider.ts | 147 +++++++++++++++- .../3-tooling/language-server/src/server.ts | 9 +- .../test/completion-context.test.ts | 44 +++++ .../test/completion-provider.test.ts | 99 ++++++++++- .../language-server/test/server.test.ts | 60 ++++++- 7 files changed, 519 insertions(+), 11 deletions(-) diff --git a/packages/1-framework/3-tooling/language-server/README.md b/packages/1-framework/3-tooling/language-server/README.md index 07c14ff5a3..d905acf901 100644 --- a/packages/1-framework/3-tooling/language-server/README.md +++ b/packages/1-framework/3-tooling/language-server/README.md @@ -6,7 +6,7 @@ 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, full/range semantic tokens, model field type completion, and descriptor-backed generic block parameter 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. Ordinary PSL `@` / `@@` attribute completions, attribute argument completions, generic block parameter 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. +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 @@ -31,7 +31,7 @@ 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. **Completion** — on `textDocument/completion`, the server serves configured PSL model field type positions and descriptor-backed generic block parameter-key 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, and returns `[]` for missing or closed documents, unconfigured documents, unavailable artifacts, unsupported contexts, ordinary attributes or attribute arguments, generic block parameter values, relation-aware scenarios, and external contract-space discovery gaps. +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 @@ -43,8 +43,8 @@ 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. -- `completion-context.ts` — pure cursor classifier for PSL completion contexts, currently routing model field type positions and descriptor-backed generic block parameter-key positions while marking everything outside slice scope unsupported. -- `completion-provider.ts` — pure completion item provider for supported model field type and generic block parameter contexts. +- `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. 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 index 66297b55f7..bab64849c1 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-context.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-context.ts @@ -1,5 +1,6 @@ import { AttributeArgListAst, + CompositeTypeDeclarationAst, type DocumentAst, FieldAttributeAst, FieldDeclarationAst, @@ -7,12 +8,14 @@ import { KeyValuePairAst, ModelAttributeAst, ModelDeclarationAst, + NamespaceDeclarationAst, type Position, type QualifiedNameAst, type SourceFile, type SyntaxNode, type SyntaxToken, TypeAnnotationAst, + TypesBlockAst, } from '@prisma-next/psl-parser/syntax'; export interface ClassifyPslCompletionContextInput { @@ -55,6 +58,16 @@ export interface GenericBlockParameterCompletionContext { readonly existingParameterNames: readonly string[]; } +export type DeclarationKeywordCompletionScope = 'document' | 'namespace'; + +export interface DeclarationKeywordCompletionContext { + readonly kind: 'declarationKeyword'; + readonly offset: number; + readonly scope: DeclarationKeywordCompletionScope; + readonly prefix: string; + readonly replacementStartOffset: number; +} + export interface UnsupportedPslCompletionContext { readonly kind: 'unsupported'; readonly offset: number; @@ -62,6 +75,7 @@ export interface UnsupportedPslCompletionContext { } export type PslCompletionContext = + | DeclarationKeywordCompletionContext | GenericBlockParameterCompletionContext | ModelFieldTypeCompletionContext | UnsupportedPslCompletionContext; @@ -93,6 +107,16 @@ export function classifyPslCompletionContext( return unsupported(offset, ancestorReason); } + const declarationKeywordContext = classifyDeclarationKeyword({ + node: contextNode, + offset, + sourceFile: input.sourceFile, + tokenContext, + }); + if (declarationKeywordContext !== undefined) { + return declarationKeywordContext; + } + const genericBlockContext = classifyGenericBlockParameter({ node: contextNode, offset, @@ -189,6 +213,143 @@ function modelFieldType( return { kind: 'modelFieldType', offset, fieldName, prefix }; } +function classifyDeclarationKeyword(input: { + readonly node: SyntaxNode | undefined; + readonly offset: number; + readonly sourceFile: SourceFile; + readonly tokenContext: TokenContext; +}): DeclarationKeywordCompletionContext | undefined { + if (isInsideNonDeclarationKeywordBody(input.node, input.offset)) { + return undefined; + } + + const namespace = closestAst(input.node, NamespaceDeclarationAst.cast); + const scope = namespaceBodyContainsOffset(namespace, input.offset) ? 'namespace' : 'document'; + const anchorOffset = scope === 'namespace' ? namespace?.lbrace()?.offset : undefined; + const prefix = declarationKeywordPrefix({ + offset: input.offset, + source: input.sourceFile.text, + tokenContext: input.tokenContext, + ...(anchorOffset === undefined ? {} : { anchorOffset }), + }); + if (prefix === undefined) { + return undefined; + } + + return { + kind: 'declarationKeyword', + offset: input.offset, + scope, + prefix: prefix.text, + replacementStartOffset: prefix.replacementStartOffset, + }; +} + +function isInsideNonDeclarationKeywordBody(node: SyntaxNode | undefined, offset: number): boolean { + return ( + declarationBodyContainsOffset(closestAst(node, ModelDeclarationAst.cast), offset) || + declarationBodyContainsOffset(closestAst(node, CompositeTypeDeclarationAst.cast), offset) || + declarationBodyContainsOffset(closestAst(node, TypesBlockAst.cast), offset) || + declarationBodyContainsOffset(closestAst(node, GenericBlockDeclarationAst.cast), offset) + ); +} + +function declarationBodyContainsOffset( + declaration: + | CompositeTypeDeclarationAst + | GenericBlockDeclarationAst + | ModelDeclarationAst + | TypesBlockAst + | undefined, + offset: number, +): boolean { + if (declaration === undefined) { + return false; + } + const lbrace = declaration.lbrace(); + if (lbrace === undefined) { + return false; + } + const bodyStart = lbrace.offset + lbrace.text.length; + const rbrace = declaration.rbrace(); + const bodyEnd = rbrace?.offset ?? endOffset(declaration.syntax); + return offset >= bodyStart && offset <= bodyEnd; +} + +function namespaceBodyContainsOffset( + namespace: NamespaceDeclarationAst | undefined, + offset: number, +): boolean { + if (namespace === undefined) { + return false; + } + const lbrace = namespace.lbrace(); + if (lbrace === undefined) { + return false; + } + const bodyStart = lbrace.offset + lbrace.text.length; + const rbrace = namespace.rbrace(); + const bodyEnd = rbrace?.offset ?? endOffset(namespace.syntax); + return offset >= bodyStart && offset <= bodyEnd; +} + +function declarationKeywordPrefix(input: { + readonly offset: number; + readonly source: string; + readonly tokenContext: TokenContext; + readonly anchorOffset?: number; +}): { readonly text: string; readonly replacementStartOffset: number } | undefined { + const token = declarationPrefixToken(input.tokenContext, input.offset); + const start = token?.offset ?? input.offset; + if (!hasOnlyHorizontalWhitespace(input.source, declarationPrefixAllowedStart(input), start)) { + return undefined; + } + if (token === undefined) { + return { text: '', replacementStartOffset: input.offset }; + } + return { + text: input.source.slice(token.offset, input.offset), + replacementStartOffset: token.offset, + }; +} + +function declarationPrefixToken( + tokenContext: TokenContext, + offset: number, +): SyntaxToken | undefined { + if (tokenContext.current?.kind === 'Ident') { + return tokenContext.current; + } + if (tokenContext.touching?.kind === 'Ident') { + return tokenContext.touching; + } + if ( + tokenContext.previousSignificant?.kind === 'Ident' && + tokenContext.previousSignificant.offset + tokenContext.previousSignificant.text.length === + offset + ) { + return tokenContext.previousSignificant; + } + return undefined; +} + +function declarationPrefixAllowedStart(input: { + readonly offset: number; + readonly source: string; + readonly anchorOffset?: number; +}): number { + const lineStart = lineStartOffset(input.source, input.offset); + if (input.anchorOffset === undefined || input.anchorOffset < lineStart) { + return lineStart; + } + return input.anchorOffset + 1; +} + +function lineStartOffset(source: string, offset: number): number { + const previousNewline = source.lastIndexOf('\n', Math.max(0, offset - 1)); + return previousNewline < 0 ? 0 : previousNewline + 1; +} + function classifyGenericBlockParameter(input: { readonly node: SyntaxNode | undefined; readonly offset: number; @@ -528,7 +689,7 @@ function endOffset(node: SyntaxNode): number { } function hasOnlyHorizontalWhitespace(source: string, start: number, end: number): boolean { - if (end <= start) { + if (end < start) { return false; } for (let index = start; index < end; index++) { 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 index 6c27d7a4af..d58bf3b1a4 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-provider.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-provider.ts @@ -1,12 +1,16 @@ -import type { AuthoringPslBlockDescriptorNamespace } from '@prisma-next/framework-components/authoring'; +import { + type AuthoringPslBlockDescriptorNamespace, + isAuthoringPslBlockDescriptor, +} from '@prisma-next/framework-components/authoring'; import { findBlockDescriptor, type NamespaceSymbol, type SymbolTable, } from '@prisma-next/psl-parser'; import type { SourceFile } from '@prisma-next/psl-parser/syntax'; -import { type CompletionItem, CompletionItemKind } from 'vscode-languageserver'; +import { type CompletionItem, CompletionItemKind, InsertTextFormat } from 'vscode-languageserver'; import type { + DeclarationKeywordCompletionContext, GenericBlockParameterCompletionContext, ModelFieldTypeCompletionContext, PslCompletionContext, @@ -22,8 +26,11 @@ export interface ProvidePslCompletionItemsInput { readonly context: PslCompletionContext; readonly sourceFile: SourceFile; readonly candidates: PslCompletionCandidateSource; + readonly clientSupportsSnippets: boolean; } +type DeclarationKeywordCompletionCandidateCategory = 'native' | 'genericBlock'; + type ModelTypeCompletionCandidateCategory = | 'configuredScalar' | 'topLevelModel' @@ -34,6 +41,15 @@ type ModelTypeCompletionCandidateCategory = | '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; @@ -54,12 +70,47 @@ const categoryOrder: Record = { 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[] { if (input.context.kind === 'unsupported') { return []; } + if (input.context.kind === 'declarationKeyword') { + return provideDeclarationKeywordCompletionItems( + input.context, + input.sourceFile, + input.candidates, + input.clientSupportsSnippets, + ); + } if (input.context.kind === 'genericBlockParameter') { return provideGenericBlockParameterCompletionItems( input.context, @@ -70,6 +121,98 @@ export function providePslCompletionItems( return provideModelFieldTypeCompletionItems(input.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) + .filter((candidate) => candidate.label.startsWith(context.prefix)) + .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 provideGenericBlockParameterCompletionItems( context: GenericBlockParameterCompletionContext, sourceFile: SourceFile, 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 156c40d217..1717dc074b 100644 --- a/packages/1-framework/3-tooling/language-server/src/server.ts +++ b/packages/1-framework/3-tooling/language-server/src/server.ts @@ -67,6 +67,7 @@ export function createServer(connection: Connection): LanguageServer { let rootPath = process.cwd(); let watchedConfigGlob = join(rootPath, '**', CONFIG_FILENAME); let supportsWatchedFilesRegistration = false; + let clientSupportsSnippets = false; async function publish(uri: string): Promise { const project = await resolveProjectForDocument(uri); @@ -329,6 +330,7 @@ export function createServer(connection: Connection): LanguageServer { pslBlockDescriptors: project.controlStack.pslBlockDescriptors, symbolTable, }, + clientSupportsSnippets, }), ]; } catch { @@ -340,6 +342,7 @@ export function createServer(connection: Connection): LanguageServer { rootPath = resolveRootPath(params.rootUri, params.rootPath); watchedConfigGlob = join(rootPath, '**', CONFIG_FILENAME); supportsWatchedFilesRegistration = clientSupportsWatchedFilesRegistration(params); + clientSupportsSnippets = clientSupportsCompletionSnippets(params); return { capabilities: { @@ -351,7 +354,7 @@ export function createServer(connection: Connection): LanguageServer { full: true, range: true, }, - completionProvider: {}, + completionProvider: { triggerCharacters: ['.'] }, }, }; }); @@ -472,6 +475,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 index d50ff813b2..1279d18a79 100644 --- 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 @@ -16,6 +16,50 @@ function classify(markedSource: string): ReturnType { + it('classifies blank document-level declaration keyword positions', () => { + const context = classify('|'); + + expect(context).toMatchObject({ + kind: 'declarationKeyword', + scope: 'document', + prefix: '', + replacementStartOffset: 0, + offset: 0, + }); + }); + + it('classifies partial document-level declaration keyword prefixes', () => { + const context = classify('mo|'); + + expect(context).toMatchObject({ + kind: 'declarationKeyword', + scope: 'document', + prefix: 'mo', + replacementStartOffset: 0, + offset: 2, + }); + }); + + it('classifies blank namespace-body declaration keyword positions', () => { + const context = classify(['namespace auth {', ' |', '}'].join('\n')); + + expect(context).toMatchObject({ + kind: 'declarationKeyword', + scope: 'namespace', + prefix: '', + }); + }); + + it('classifies partial namespace-body declaration keyword prefixes', () => { + const context = classify(['namespace auth {', ' ty|', '}'].join('\n')); + + expect(context).toMatchObject({ + kind: 'declarationKeyword', + scope: 'namespace', + prefix: 'ty', + }); + }); + it('classifies a blank model field type position', () => { const context = classify(['model Post {', ' author |', '}'].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 index dc339e4bb1..dcd3b0adb3 100644 --- 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 @@ -2,11 +2,12 @@ import type { AuthoringPslBlockDescriptorNamespace } from '@prisma-next/framewor import { buildSymbolTable } from '@prisma-next/psl-parser'; import { parse } from '@prisma-next/psl-parser/syntax'; import { describe, expect, it } from 'vitest'; -import { CompletionItemKind } from 'vscode-languageserver'; +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: { @@ -21,6 +22,17 @@ const pslBlockDescriptors: AuthoringPslBlockDescriptorNamespace = { 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 = [ @@ -53,7 +65,10 @@ const candidateSource = [ '}', ].join('\n'); -function complete(markedFieldSource: string) { +function complete( + markedFieldSource: string, + options: { readonly clientSupportsSnippets?: boolean } = {}, +) { const markedSource = `${candidateSource}\n${markedFieldSource}`; const cursorOffset = markedSource.indexOf('|'); expect(cursorOffset).toBeGreaterThanOrEqual(0); @@ -76,6 +91,7 @@ function complete(markedFieldSource: string) { context, sourceFile, candidates: { scalarTypes, pslBlockDescriptors, symbolTable }, + clientSupportsSnippets: options.clientSupportsSnippets === true, }), sourceFile, cursorOffset, @@ -83,6 +99,85 @@ function complete(markedFieldSource: string) { } 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('filters document-level declaration keyword prefixes and replaces the typed segment', () => { + const { items, sourceFile, cursorOffset } = complete('mo|'); + + expect(items.map((item) => item.label)).toEqual(['model']); + expect(items[0]?.textEdit).toEqual({ + 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('filters namespace-body declaration keyword prefixes', () => { + const { items } = complete(['namespace feature {', ' po|', '}'].join('\n')); + + expect(items.map((item) => item.label)).toEqual(['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'), 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 2aebe2b44b..f3ab974ece 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 @@ -25,6 +25,7 @@ import { InitializedNotification, InitializeRequest, type InitializeResult, + InsertTextFormat, LogMessageNotification, MessageType, type Position, @@ -79,6 +80,7 @@ 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: { @@ -153,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; @@ -479,7 +485,7 @@ describe('language server', { timeout: timeouts.databaseOperation }, () => { full: true, range: true, }); - expect(result.capabilities.completionProvider).toBeDefined(); + expect(result.capabilities.completionProvider).toEqual({ triggerCharacters: ['.'] }); }); it('returns model field type completions for configured PSL inputs', async () => { @@ -527,6 +533,58 @@ describe('language server', { timeout: timeouts.databaseOperation }, () => { expect(items.map((item) => item.label)).toEqual(['where']); }); + 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(); From 81b472c4ca8b12deb35556d7b7f85cdbac141be6 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Fri, 26 Jun 2026 09:45:22 +0000 Subject: [PATCH 05/54] chore(drive): record top-level keyword completion slice Signed-off-by: Serhii Tatarintsev --- projects/lsp-autocomplete/plan.md | 17 +++- .../01-declaration-keyword-completions.md | 73 ++++++++++++++ .../top-level-keyword-completions/plan.md | 36 +++++++ .../top-level-keyword-completions/spec.md | 99 +++++++++++++++++++ projects/lsp-autocomplete/spec.md | 9 +- projects/lsp-autocomplete/trace.jsonl | 10 ++ 6 files changed, 236 insertions(+), 8 deletions(-) create mode 100644 projects/lsp-autocomplete/slices/top-level-keyword-completions/dispatches/01-declaration-keyword-completions.md create mode 100644 projects/lsp-autocomplete/slices/top-level-keyword-completions/plan.md create mode 100644 projects/lsp-autocomplete/slices/top-level-keyword-completions/spec.md diff --git a/projects/lsp-autocomplete/plan.md b/projects/lsp-autocomplete/plan.md index 08ecb293cc..ed269d8732 100644 --- a/projects/lsp-autocomplete/plan.md +++ b/projects/lsp-autocomplete/plan.md @@ -5,7 +5,7 @@ ## At a glance -This project is a two-slice stack. Slice 1 lands the completion request path, shared cursor-context classifier, and first model-type provider; slice 2 reuses that hand-off to add descriptor-backed generic block entry/parameter completions and final scope documentation. +This project is a three-slice stack. Slice 1 lands the completion request path, shared cursor-context classifier, and first model-type provider; slice 2 reuses that hand-off to add descriptor-backed generic block entry/parameter completions; slice 3 extends the same classifier/provider seam with declaration keyword completions, including namespace-body filtering and snippet/plain insertion behavior. ## Composition @@ -20,12 +20,18 @@ This project is a two-slice stack. Slice 1 lands the completion request path, sh 2. **Slice `generic-block-completions`** — Linear: [TML-2945](https://linear.app/prisma-company/issue/TML-2945/lsp-autocomplete-generic-block-entry-completions) - **Outcome:** Configured PSL inputs answer `textDocument/completion` inside descriptor-backed generic PSL blocks for blank entry positions and partial keys/parameters on the current `GenericBlockDeclaration`. - **Builds on:** Slice `model-type-completions`' completion handler, cursor-context classifier, provider dispatch shape, unsupported-context behavior, and access to the language-server control stack. - - **Hands to:** The scoped first autocomplete surface described by the project spec: model field type completions plus generic block entry/parameter completions, with ordinary PSL attributes still out of scope. + - **Hands to:** The scoped first autocomplete surface described by the original project spec: model field type completions plus generic block entry/parameter completions, with ordinary PSL attributes still out of scope. - **Focus:** Using `pslBlockDescriptors`, `GenericBlockDeclarationAst`, `KeyValuePairAst`, and reconstructed block symbols to produce descriptor-backed generic block suggestions; documentation for supported and excluded completion contexts. +3. **Slice `top-level-keyword-completions`** — Linear: [TML-2947](https://linear.app/prisma-company/issue/TML-2947/lsp-autocomplete-top-level-keyword-completions) + - **Outcome:** Configured PSL inputs answer `textDocument/completion` at document-level and namespace-body declaration keyword positions, with native PSL block keywords, descriptor-backed generic block keywords, and snippet/plain insertion variants gated by client capability. + - **Builds on:** The established completion handler, cursor-context classifier, provider dispatch shape, generic block descriptor candidate source, and namespace-aware completion behavior already present on the branch. + - **Hands to:** The expanded first autocomplete surface now includes model field type completions, generic block entry/parameter completions, and declaration keyword completions without adding ordinary PSL attribute completions. + - **Focus:** Declaration-position classification; document-vs-namespace native keyword sets; descriptor-backed generic block keyword suggestions; snippet support derived from LSP initialize capabilities; plain-text fallbacks for non-snippet clients; README and guardrail tests. + ### Parallel groups -None. Both slices touch the same language-server completion route and classifier, and the generic block provider is simpler and safer once the model-type slice has established the provider dispatch and unsupported-context semantics. +None. All slices touch the same language-server completion route and classifier. The generic block and declaration keyword providers are simpler and safer once the model-type slice has established provider dispatch, unsupported-context semantics, and server request wiring. ## Dependencies (external) @@ -33,9 +39,10 @@ None. Both slices touch the same language-server completion route and classifier - [x] Existing LSP scaffold exists — diagnostics and formatting already run through `packages/1-framework/3-tooling/language-server/src/server.ts` and cache project artifacts in `project-artifacts.ts`. - [x] Existing parser/symbol substrate exists — `parseTypeAnnotation()` uses `QualifiedName`, `SourceFile` maps offsets, red-tree nodes expose offsets/parents/ancestors/tokens, and `buildSymbolTable()` exposes model/composite/scalar/type-alias/block symbols. - [x] Existing generic block descriptor path exists — `pslBlockDescriptors` is resolved into the language-server control stack and `BlockSymbol.block` is reconstructed from `GenericBlockDeclarationAst`. +- [x] Linear slice issue exists for declaration keyword completion — [TML-2947](https://linear.app/prisma-company/issue/TML-2947/lsp-autocomplete-top-level-keyword-completions), Terminal team. ## Sequencing rationale -The only real dependency is the shared completion route and cursor-context classifier. Model type completions are the narrower end-to-end slice because they depend on existing `TypeAnnotation` / `QualifiedName` and symbol-table shapes but do not need descriptor-specific block semantics. Generic block completions then consume the established request path and classifier, adding descriptor-backed context handling without reopening the LSP wiring decision. +The only real dependency is the shared completion route and cursor-context classifier. Model type completions are the narrower end-to-end slice because they depend on existing `TypeAnnotation` / `QualifiedName` and symbol-table shapes but do not need descriptor-specific block semantics. Generic block completions then consume the established request path and classifier, adding descriptor-backed context handling without reopening the LSP wiring decision. Declaration keyword completions build last because they reuse both the classifier/provider dispatch seam and the generic block descriptor candidate source while adding LSP snippet capability threading. -The slices are not parallelized because both would otherwise make independent edits to the same provider routing and unsupported-context behavior. Keeping them stacked avoids duplicate classifier designs and makes the second review about descriptor semantics rather than LSP plumbing. +The slices are not parallelized because they would otherwise make independent edits to the same provider routing and unsupported-context behavior. Keeping them stacked avoids duplicate classifier designs and makes each later review about one syntactic domain rather than LSP plumbing. diff --git a/projects/lsp-autocomplete/slices/top-level-keyword-completions/dispatches/01-declaration-keyword-completions.md b/projects/lsp-autocomplete/slices/top-level-keyword-completions/dispatches/01-declaration-keyword-completions.md new file mode 100644 index 0000000000..9e8f9f8c0c --- /dev/null +++ b/projects/lsp-autocomplete/slices/top-level-keyword-completions/dispatches/01-declaration-keyword-completions.md @@ -0,0 +1,73 @@ +# Brief: declaration-keyword-completions + +## Task + +Implement top-level declaration keyword completions for configured PSL inputs in the language server. Extend the existing completion classifier/provider route so document-level declaration positions complete native PSL block keywords plus descriptor-backed generic block keywords, namespace-body declaration positions complete only namespace-valid native keywords plus descriptor-backed generic block keywords, and completion item insertion is snippet-capability gated with a plain-text fallback. + +## Scope + +**In:** + +- Language-server tests first for classifier/provider/server behavior covering document-level and namespace-body declaration keyword completions. +- `packages/1-framework/3-tooling/language-server/src/completion-context.ts` declaration-position context, including blank and partial keyword prefixes and replacement range metadata. +- `packages/1-framework/3-tooling/language-server/src/completion-provider.ts` native keyword and descriptor-backed generic block keyword candidates, stable filtering/ordering, snippet/plain insertion variants, and unsupported-context routing. +- `packages/1-framework/3-tooling/language-server/src/server.ts` snippet-support detection from `InitializeParams.capabilities.textDocument?.completion?.completionItem?.snippetSupport === true` and threading into provider input. +- Server capability preservation, including `completionProvider.triggerCharacters: ['.']` if that is not already advertised for namespace-member completion. +- `packages/1-framework/3-tooling/language-server/README.md` documentation of top-level and namespace-body keyword completion scope, snippet capability gating, and explicit exclusions. + +**Out:** + +- Ordinary PSL `@` / `@@` field or model attribute name completions. +- Attribute argument completions. +- Generic block value completions. +- Relation-aware completions. +- Nested `namespace` or `types` keyword suggestions inside namespace bodies. +- Completion-marker reparsing or a second parser pass. +- Parser public API changes unless implementation proves the existing red-tree/AST surface is insufficient. If parser exports or generated declarations must change, halt and explain why before doing so. +- Reapplying or modifying unrelated local dirt, especially `apps/lsp-playground/src/client/runtime.ts`. + +## Completed when + +- [ ] Tests cover blank and partial document-level declaration keyword positions; blank and partial namespace-body declaration keyword positions; namespace-body exclusion of `namespace` and `types`; descriptor-backed generic block keyword candidates; snippet-supported output; plain-text fallback output; and ordinary `@` / `@@` attribute contexts returning no scoped keyword suggestions. +- [ ] The implementation derives and threads `clientSupportsSnippets` from initialize capabilities, emitting snippet items only when true and plain-text items otherwise. +- [ ] Existing model type completions, namespace member completions, generic block key completions, diagnostics, formatting, and folding-range behavior remain covered and green. +- [ ] README accurately documents the expanded completion scope and exclusions. + +## Standing instruction + +Stay focused on the goal; control scope. Trivial-and-related fixes that obviously serve the goal go in the same dispatch with a one-line note in your wrap-up message. Anything that pulls you off the goal — even if it looks useful — halts and surfaces. + +## References + +**Slice-loop dispatch:** + +- Slice spec: `projects/lsp-autocomplete/slices/top-level-keyword-completions/spec.md` +- Slice plan entry: `projects/lsp-autocomplete/slices/top-level-keyword-completions/plan.md` § Dispatch 1: declaration-keyword-completions +- Parent project spec: `projects/lsp-autocomplete/spec.md` +- Parent project plan: `projects/lsp-autocomplete/plan.md` +- Code review log: `projects/lsp-autocomplete/reviews/code-review.md` +- Existing completion slice artifacts: `projects/lsp-autocomplete/slices/model-type-completions/` +- Linear issue: `TML-2947` — https://linear.app/prisma-company/issue/TML-2947/lsp-autocomplete-top-level-keyword-completions + +**Code surfaces to inspect during pre-flight:** + +- `packages/1-framework/3-tooling/language-server/src/completion-context.ts` +- `packages/1-framework/3-tooling/language-server/src/completion-provider.ts` +- `packages/1-framework/3-tooling/language-server/src/server.ts` +- `packages/1-framework/3-tooling/language-server/test/completion-context.test.ts` +- `packages/1-framework/3-tooling/language-server/test/completion-provider.test.ts` +- `packages/1-framework/3-tooling/language-server/test/server.test.ts` +- `packages/1-framework/3-tooling/language-server/README.md` + +## Operational metadata + +- **Model tier:** mid — one coherent language-server feature touching classifier/provider/server tests and docs, with snippet capability gating and namespace-specific behavior. +- **Time-box:** 90 minutes wall-clock. If this runs materially longer, pause and report the current state rather than expanding scope. +- **Halt conditions:** Halt if declaration-position detection cannot be implemented from existing cached parse artifacts; if parser public exports or generated parser declarations appear necessary; if ordinary PSL attributes or attribute arguments must be touched to complete this; if completion-marker reparsing seems necessary; if unrelated local changes block validation; or if validation failures appear unrelated to this dispatch and cannot be isolated. + +## Validation gates + +- `pnpm --filter @prisma-next/language-server test` +- `pnpm --filter @prisma-next/language-server typecheck` +- `pnpm --filter @prisma-next/language-server lint` +- If parser exports or generated parser declarations are changed after an explicit halt/approval: `pnpm --filter @prisma-next/psl-parser build`, then rerun the language-server gates. diff --git a/projects/lsp-autocomplete/slices/top-level-keyword-completions/plan.md b/projects/lsp-autocomplete/slices/top-level-keyword-completions/plan.md new file mode 100644 index 0000000000..7c59f66fcd --- /dev/null +++ b/projects/lsp-autocomplete/slices/top-level-keyword-completions/plan.md @@ -0,0 +1,36 @@ +# Slice plan: top-level-keyword-completions + +**Spec:** `projects/lsp-autocomplete/slices/top-level-keyword-completions/spec.md` +**Parent project:** `projects/lsp-autocomplete/spec.md` +**Linear issue:** [TML-2947](https://linear.app/prisma-company/issue/TML-2947/lsp-autocomplete-top-level-keyword-completions) + +## Dispatch plan + +### Dispatch 1: declaration-keyword-completions + +- **Outcome:** Configured PSL inputs complete declaration keywords at document top level and inside namespace bodies, with descriptor-backed generic block keywords and client-capability-gated snippet/plain insertion. +- **Builds on:** The existing LSP completion request path, completion context classifier, provider dispatch seam, generic block descriptor candidate source, namespace-aware type-position completion work, and generic block key completion work already present on the branch. +- **Hands to:** A completed top-level keyword completion surface for PR review: scoped declaration-position classification, scope-appropriate keyword candidates, snippet/plain insertion behavior, server capability threading, tests, and README coverage. +- **Focus:** Write tests before implementation; extend the language-server completion context with document-level and namespace-body declaration keyword positions; add provider output for native PSL block keywords and descriptor-backed generic block keywords; thread `clientSupportsSnippets` from initialize capabilities to provider input; emit snippet items only for snippet-capable clients and plain-text items otherwise; keep ordinary PSL attributes, attribute arguments, generic block values, relation-aware completions, and completion-marker reparsing out of scope; preserve existing model type, namespace member, generic block key, diagnostics, formatting, and folding-range behavior. +- **Completed when:** + - Classifier/provider/server tests cover blank and partial document-level declaration keyword positions; blank and partial namespace-body declaration keyword positions; namespace-body exclusion of `namespace` and `types`; descriptor-backed generic block keyword candidates; snippet-supported output; plain-text fallback output; and unsupported ordinary `@` / `@@` attribute contexts returning no scoped keyword suggestions. + - The server derives snippet support from `InitializeParams.capabilities.textDocument?.completion?.completionItem?.snippetSupport === true` and threads the boolean into completion provider input without assuming snippets for CodeMirror-style clients. + - The README documents top-level and namespace-body keyword completion scope, snippet capability gating, and the continued exclusion of ordinary attributes and attribute arguments. +- **Validation gates:** + - `pnpm --filter @prisma-next/language-server test` + - `pnpm --filter @prisma-next/language-server typecheck` + - `pnpm --filter @prisma-next/language-server lint` + - If parser exports or generated parser declarations are changed: `pnpm --filter @prisma-next/psl-parser build`, then rerun the language-server gates. + +## Dispatch-INVEST check + +- **Independent:** The dispatch produces a complete reviewable surface on top of the already-landed completion route and does not require a sibling slice to merge concurrently. +- **Negotiable:** The dispatch pins the outcome and capability behavior while leaving implementation discovery over the existing classifier/provider/server files to the executor. +- **Valuable:** It directly closes the user's requested declaration-keyword completion gap, including namespace-body behavior and snippet/plain insertion variants. +- **Estimable:** The completed-when checklist is binary and covered by tests plus package gates. +- **Small:** The work is one coherent language-server completion surface in one package, with one syntactic context family and one provider output family. +- **Testable:** The language-server package tests/typecheck/lint gates verify the slice; parser build is only required if implementation changes parser declarations. + +## Open items + +None. diff --git a/projects/lsp-autocomplete/slices/top-level-keyword-completions/spec.md b/projects/lsp-autocomplete/slices/top-level-keyword-completions/spec.md new file mode 100644 index 0000000000..73def4815d --- /dev/null +++ b/projects/lsp-autocomplete/slices/top-level-keyword-completions/spec.md @@ -0,0 +1,99 @@ +# Slice: top-level-keyword-completions + +Parent project: `projects/lsp-autocomplete/`. This slice contributes declaration-keyword completion for configured PSL inputs at document top level and inside namespace bodies. + +## At a glance + +Configured PSL documents answer `textDocument/completion` where the cursor is positioned to start a block declaration. The slice adds native PSL declaration keywords, descriptor-backed generic block keywords, namespace-body filtering, and snippet/plain-text insertion variants gated by client completion capabilities. + +## Chosen design + +The existing completion route remains the only LSP entry point. This slice extends the classifier/provider seam with a declaration-keyword context instead of adding a second completion path: + +```text +textDocument/completion + → configured PSL input check + → cached DocumentAst / SourceFile / SymbolTable lookup + → SourceFile.offsetAt(position) + → completion context analysis over the existing red tree + AST ancestors + → declaration-keyword provider + → CompletionItem[] +``` + +The classifier recognizes declaration positions in two scopes: + +| Scope | Completion position | Native keyword candidates | +| --- | --- | --- | +| `document` | top-level document body | `model`, `type`, `types`, `namespace` | +| `namespace` | direct body of a `namespace` declaration | `model`, `type` | + +Both scopes also include descriptor-backed generic PSL block keywords from `project.controlStack.pslBlockDescriptors`. Namespace-body completion deliberately excludes nested `namespace` and `types` because the parser diagnoses those inside namespaces even though recovery may still produce nodes for invalid syntax. + +Prefix handling follows the existing tsserver-style previous/current token shape already used by model type and generic block completions: blank declaration positions have an empty prefix, partial identifiers like `mo|` filter to matching declaration keywords, and replacement starts at the beginning of the typed keyword prefix. Comments, model fields, ordinary PSL `@` / `@@` attributes, attribute arguments, type positions, and generic block member positions stay unsupported for this provider. + +The provider emits the same labels in both insertion modes, but insertion detail is capability-gated: + +- When `clientSupportsSnippets` is `true`, block keywords use `InsertTextFormat.Snippet` and snippets place the final cursor inside the new block body, for example `model ${1:Name} {\n $0\n}`. +- When `clientSupportsSnippets` is `false`, the server returns plain-text completion items with no snippet syntax. + +The language server derives `clientSupportsSnippets` from `InitializeParams.capabilities.textDocument?.completion?.completionItem?.snippetSupport === true` and threads that boolean into the completion provider input. This keeps CodeMirror-style clients that do not advertise snippet support on plain text while allowing snippet-capable editors to place the cursor inside completed blocks. + +The existing namespace-member completion flow should remain intact. If the server capability does not already advertise `.` as a completion trigger character, this slice may add `triggerCharacters: ['.']` while preserving completion behavior for clients that invoke completion manually. + +## Coherence rationale + +This is one reviewable slice because declaration-position classification, keyword candidate selection, and snippet/plain insertion are one user-visible completion surface. Splitting snippets into a separate slice would ship the new keyword provider in the wrong insertion shape for snippet-capable clients, while folding ordinary attribute completions into this slice would add a separate syntactic domain and break the project's non-goals. + +## Scope + +**In:** + +- Declaration-keyword completion contexts at document top level and direct namespace-body declaration positions. +- Native PSL declaration keyword candidates for each scope. +- Descriptor-backed generic block keyword candidates from `pslBlockDescriptors` in both document and namespace scopes. +- Prefix filtering and replacement ranges for blank and partial declaration keywords. +- Snippet completion items when the client advertises snippet support. +- Plain-text fallback completion items when snippets are not supported. +- Threading client snippet capability from LSP initialize state to completion provider input. +- Tests for classifier/provider/server behavior and README updates for the expanded completion scope. + +**Out:** + +- Ordinary PSL `@` / `@@` field or model attribute name completions. +- Attribute argument completions. +- Generic block value completions beyond descriptor-backed block/key surfaces already in scope for the project. +- Relation-aware completions. +- Nested namespace or `types` suggestions inside namespace bodies. +- Completion-marker reparsing. + +## Pre-investigated edge cases + +| Edge case | Disposition | Notes | +| --------- | ----------- | ----- | +| Namespace-body native keyword set differs from document top-level keyword set. | In scope. | `model`, `type`, and descriptor-backed generic blocks are namespace-valid; `namespace` and `types` are document-only suggestions for this slice. | +| Snippet support varies by LSP client. | In scope. | Snippet syntax must be gated by the initialize capability and have a plain-text fallback. | + +## Slice-specific done conditions + +- [ ] Configured PSL completion suggests document-level and namespace-body declaration keywords with scope-appropriate filtering. +- [ ] Snippet-capable clients receive snippet insertion items, and non-snippet clients receive plain-text insertion items for the same keyword labels. +- [ ] The slice does not add ordinary PSL `@` / `@@` attribute completions or completion-marker reparsing. + +## Open Questions + +None. + +## References + +- Parent project: `projects/lsp-autocomplete/spec.md` +- Project plan: `projects/lsp-autocomplete/plan.md` +- Linear issue: [TML-2947](https://linear.app/prisma-company/issue/TML-2947/lsp-autocomplete-top-level-keyword-completions) +- Existing completion slice: `projects/lsp-autocomplete/slices/model-type-completions/` +- Relevant code surfaces: + - `packages/1-framework/3-tooling/language-server/src/completion-context.ts` + - `packages/1-framework/3-tooling/language-server/src/completion-provider.ts` + - `packages/1-framework/3-tooling/language-server/src/server.ts` + - `packages/1-framework/3-tooling/language-server/test/completion-context.test.ts` + - `packages/1-framework/3-tooling/language-server/test/completion-provider.test.ts` + - `packages/1-framework/3-tooling/language-server/test/server.test.ts` + - `packages/1-framework/3-tooling/language-server/README.md` diff --git a/projects/lsp-autocomplete/spec.md b/projects/lsp-autocomplete/spec.md index 55a788ab68..e3bbc617b1 100644 --- a/projects/lsp-autocomplete/spec.md +++ b/projects/lsp-autocomplete/spec.md @@ -24,6 +24,8 @@ datasource db { Inside a generic block, completion suggests descriptor-backed block entries/parameters for that block kind. In this spec, “generic block attributes” means the key/value-style generic block members represented by `GenericBlockDeclaration` / `KeyValuePairAst` and extension-block descriptors. Ordinary PSL `@` / `@@` field/model attributes are deliberately out of scope for this part. +At declaration positions, completion suggests top-level PSL block keywords and descriptor-backed generic block keywords. Document top level includes `model`, `type`, `types`, and `namespace`; namespace bodies include only namespace-valid declaration keywords such as `model`, `type`, and descriptor-backed generic block keywords. Completion items support snippet-capable clients while retaining plain-text fallbacks for clients that do not advertise snippet support. + The intended shape is: ```text @@ -68,8 +70,8 @@ No contract or adapter impact is expected. This project changes editor tooling b - Provider routing is explicit. Completion providers should not independently rediscover syntactic context. - Completion is available only for configured PSL inputs, matching the current diagnostics/formatting ownership rules. - Diagnostics and formatting behavior remain unchanged while completion is added. -- The first provider set is limited to model field type completions, including namespace-qualified type positions, and generic block entry/parameter completions. -- Ordinary PSL `@` / `@@` attribute contexts must not accidentally receive the new generic block or model type suggestions. +- The first provider set is limited to model field type completions, including namespace-qualified type positions, generic block entry/parameter completions, and declaration keyword completions at document top level and inside namespace bodies. +- Ordinary PSL `@` / `@@` attribute contexts must not accidentally receive the new generic block, model type, or declaration keyword suggestions. - Tests are written before or alongside implementation changes. ## Transitional-shape constraints @@ -86,8 +88,9 @@ No contract or adapter impact is expected. This project changes editor tooling b - [ ] The language server advertises completion support for configured PSL inputs without changing diagnostics or formatting behavior. - [ ] Model field type positions complete configured scalar types plus visible model, composite type, scalar, and type-alias symbols from the current project symbol table, including namespace-qualified and contract-space-qualified type prefixes. - [ ] Generic block entry/parameter positions complete descriptor-backed keys or values for the current `GenericBlockDeclaration` where descriptor data is available. +- [ ] Document top-level and namespace-body declaration positions complete scope-appropriate native PSL block keywords plus descriptor-backed generic block keywords, with snippet-capable and plain-text client behavior covered. - [ ] Ordinary PSL `@` / `@@` field/model attribute contexts return no scoped suggestions from this project’s providers. -- [ ] Cursor-context classification has focused tests for blank model bodies, field-name prefixes, bare and namespace-qualified field-type positions, generic block blank lines, generic block partial keys, comments/trivia, and unsupported contexts. +- [ ] Cursor-context classification has focused tests for blank model bodies, field-name prefixes, bare and namespace-qualified field-type positions, generic block blank lines, generic block partial keys, declaration keyword positions at document top level and inside namespace bodies, comments/trivia, and unsupported contexts. - [ ] LSP/server tests cover completion requests against an open configured PSL document. - [ ] Package documentation or README notes the supported completion scope and explicitly names the out-of-scope attribute surfaces. - [ ] Validation gates for touched packages pass, including typecheck, lint, and relevant package tests. diff --git a/projects/lsp-autocomplete/trace.jsonl b/projects/lsp-autocomplete/trace.jsonl index 4ee7915046..20e411b7ed 100644 --- a/projects/lsp-autocomplete/trace.jsonl +++ b/projects/lsp-autocomplete/trace.jsonl @@ -25,3 +25,13 @@ {"event_id":"3c8d8e21-eb71-43e4-9381-db9806243c25","event_type":"brief-issued","schema_version":"1","ts":"2026-06-25T13:12:48.946Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"8c39fcaa-7306-425f-982b-fd5b4f7cdc5c","round_id":"dc7ce623-26b7-41fb-9b78-34b6962a7269","brief_byte_length":4831,"brief_content_hash":"b5629c09b472447ef5e3be56f6efab6b3bf701d409279d65b33dd9939ed5090d","brief_disposition":"initial"} {"event_id":"11258695-c9fa-4a3f-9150-8b19c9f4528b","event_type":"round-end","schema_version":"1","ts":"2026-06-25T13:21:21.652Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"8c39fcaa-7306-425f-982b-fd5b4f7cdc5c","round_id":"dc7ce623-26b7-41fb-9b78-34b6962a7269","verdict":"satisfied","findings_filed":0,"wall_clock_ms":512706} {"event_id":"ff35f2ce-eb15-4d7a-92ed-0f9f7f17aaf6","event_type":"dispatch-end","schema_version":"1","ts":"2026-06-25T13:21:21.652Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"8c39fcaa-7306-425f-982b-fd5b4f7cdc5c","result":"completed","wall_clock_ms":512706} +{"event_id":"9077f136-092e-4264-a069-49526e026e1c","schema_version":"1","ts":"2026-06-25T16:10:07.920Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"event_type":"spec-amended","spec_path":"projects/lsp-autocomplete/spec.md","spec_kind":"project","byte_length":9155,"bytes_delta":881,"edge_cases_count":null,"open_questions_count":0,"dod_items_count":10,"reason":"scope-shift","sections_changed":["At a glance","Cross-cutting requirements","Project Definition of Done"]} +{"event_id":"a51509d6-a65c-46b8-8299-f14d78cfa043","schema_version":"1","ts":"2026-06-25T16:10:08.389Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"event_type":"plan-amended","plan_path":"projects/lsp-autocomplete/plan.md","plan_kind":"project","byte_length":6360,"bytes_delta":1761,"dispatch_count":null,"slice_count":3,"dispatch_size_distribution":null,"open_items_count":0,"reason":"scope-shift","dispatches_added":null,"dispatches_removed":null,"dispatches_resized":null} +{"event_id":"e665fc15-4825-4946-a36e-bb2cd8004166","schema_version":"1","ts":"2026-06-25T16:10:08.866Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"event_type":"spec-authored","spec_path":"projects/lsp-autocomplete/slices/top-level-keyword-completions/spec.md","spec_kind":"slice","byte_length":6665,"edge_cases_count":2,"open_questions_count":0,"dod_items_count":3} +{"event_id":"b2d09aa2-f68a-4774-b87a-7759439c28ee","schema_version":"1","ts":"2026-06-25T16:10:09.338Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"event_type":"plan-authored","plan_path":"projects/lsp-autocomplete/slices/top-level-keyword-completions/plan.md","plan_kind":"slice","byte_length":4027,"dispatch_count":1,"slice_count":null,"dispatch_size_distribution":{"S":0,"M":1,"L":0,"XL":0},"open_items_count":0} +{"event_id":"d5faa1b8-1354-4f35-a338-e1a3441c81ae","schema_version":"1","ts":"2026-06-25T16:10:21.998Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"event_type":"slice-started","slice_slug":"top-level-keyword-completions","slice_index":3,"linear_ref":"TML-2947"} +{"event_id":"1e748a14-288a-4e31-ac0b-6957e8ceb919","schema_version":"1","ts":"2026-06-25T16:12:45.212Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"event_type":"dispatch-start","dispatch_id":"a8e0dc43-b04f-4edf-ab22-d79f80cc5957","dispatch_name":"declaration-keyword-completions","subagent_type":"implementer/mid","model":"GPT-5.5","parent_dispatch_id":"8c39fcaa-7306-425f-982b-fd5b4f7cdc5c"} +{"event_id":"4bdd6834-21bc-4317-894b-4244f5e6d3e5","schema_version":"1","ts":"2026-06-25T16:12:45.697Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"event_type":"round-start","dispatch_id":"a8e0dc43-b04f-4edf-ab22-d79f80cc5957","round_id":"f42bee87-498e-41bf-8cbd-f1a497764e2f","round_number":1} +{"event_id":"08e80c4d-fa6d-48e1-99b9-b9b4f8c9f28d","schema_version":"1","ts":"2026-06-25T16:12:46.157Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"event_type":"brief-issued","dispatch_id":"a8e0dc43-b04f-4edf-ab22-d79f80cc5957","round_id":"f42bee87-498e-41bf-8cbd-f1a497764e2f","brief_byte_length":5865,"brief_content_hash":"8b7927766a32521446ef7153fd7d50c498bd5d45b741b65dc9e535664a5ab981","brief_disposition":"initial"} +{"event_id":"e9d73a33-5b37-48d2-ac4f-d55c91facfd7","schema_version":"1","ts":"2026-06-26T09:45:00.921Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"event_type":"round-end","dispatch_id":"a8e0dc43-b04f-4edf-ab22-d79f80cc5957","round_id":"f42bee87-498e-41bf-8cbd-f1a497764e2f","verdict":"satisfied","findings_filed":0,"wall_clock_ms":63121996} +{"event_id":"fd4fdc3b-fc24-41c8-ac83-1e7ae273e902","schema_version":"1","ts":"2026-06-26T09:45:01.388Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"event_type":"dispatch-end","dispatch_id":"a8e0dc43-b04f-4edf-ab22-d79f80cc5957","result":"completed","wall_clock_ms":63122481} From 4ffa748c2dff7f34dc74df502d28ccc9ad1b0c74 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Fri, 26 Jun 2026 10:18:43 +0000 Subject: [PATCH 06/54] fix(lsp-playground): serve runtime config over HTTP Signed-off-by: Serhii Tatarintsev --- apps/lsp-playground/README.md | 4 +- apps/lsp-playground/src/cli.ts | 68 +++++++++++++++++------ apps/lsp-playground/src/client/main.ts | 59 ++++++++++++++++---- apps/lsp-playground/src/client/runtime.ts | 8 --- 4 files changed, 102 insertions(+), 37 deletions(-) delete mode 100644 apps/lsp-playground/src/client/runtime.ts 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..3db2900889 100644 --- a/apps/lsp-playground/src/cli.ts +++ b/apps/lsp-playground/src/cli.ts @@ -11,6 +11,15 @@ 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'; + +interface RuntimeConfig { + readonly wsPath: string; + readonly documentUri: string; + readonly rootUri: string; + readonly schemaPath: string; + readonly schemaText: string; +} async function fileExists(path: string): Promise { try { @@ -132,22 +141,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 +192,41 @@ export const schemaText = ${JSON.stringify(schemaText)}; })(), }, }); - httpServer.on('request', viteServer.middlewares); + httpServer.on( + 'request', + (request: nodeHttp.IncomingMessage, response: nodeHttp.ServerResponse) => { + const baseUrl = `http://${request.headers.host ?? 'localhost'}/`; + const requestPath = + request.url !== undefined ? new URL(request.url, baseUrl).pathname : undefined; + 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 = ''; From 0071da5cc786c2ebbcc51996c5629eec442995a7 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Fri, 26 Jun 2026 17:44:31 +0000 Subject: [PATCH 07/54] feat(psl-parser): add rust-analyzer-style red-tree cursor navigation Make red tokens navigable (parent, prev/next token) and add SyntaxNode.tokenAtOffset (none/single/between with left/right bias), coveringElement, first/lastToken, plus trivia-skip helpers (skipTriviaToken, nonTriviaSibling, previousNonTriviaToken). Mirrors rust-analyzer syntax idioms; no fake-identifier marker. Signed-off-by: Serhii Tatarintsev --- .../psl-parser/src/exports/syntax.ts | 13 +- .../psl-parser/src/syntax/navigation.ts | 71 ++++ .../2-authoring/psl-parser/src/syntax/red.ts | 314 ++++++++++++++++-- .../psl-parser/test/syntax/navigation.test.ts | 172 ++++++++++ .../psl-parser/test/syntax/red.test.ts | 175 +++++++++- 5 files changed, 708 insertions(+), 37 deletions(-) create mode 100644 packages/1-framework/2-authoring/psl-parser/src/syntax/navigation.ts create mode 100644 packages/1-framework/2-authoring/psl-parser/test/syntax/navigation.test.ts 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..98595fce3f 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 @@ -46,7 +46,16 @@ export { filterChildren, findChildToken, findFirstChild, printSyntax } from '../ 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, + previousNonTriviaToken, + 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/syntax/navigation.ts b/packages/1-framework/2-authoring/psl-parser/src/syntax/navigation.ts new file mode 100644 index 0000000000..19e55a36f9 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/src/syntax/navigation.ts @@ -0,0 +1,71 @@ +import type { TokenKind } from '../tokenizer'; +import { type SyntaxElement, SyntaxToken } from './red'; + +/** Direction of a sibling/token walk. Mirrors rust-analyzer's `Direction`. */ +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. Mirrors rust-analyzer's + * `algo::skip_trivia_token`. + */ +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. Mirrors rust-analyzer's + * `algo::non_trivia_sibling`. + */ +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; +} + +/** + * The nearest significant token strictly before `element` in document order, + * crossing node boundaries. Mirrors rust-analyzer's + * `algo::previous_non_trivia_token`. + */ +export function previousNonTriviaToken(element: SyntaxElement): SyntaxToken | undefined { + const start = element instanceof SyntaxToken ? element : element.firstToken; + if (start === undefined) return undefined; + for (let prev = start.prevToken; prev !== undefined; prev = prev.prevToken) { + if (!isTrivia(prev)) return prev; + } + 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..9c218284df 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,18 +1,110 @@ -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. Mirrors rust-analyzer's `SyntaxToken`. */ -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; + + constructor(green: GreenToken, offset: number, parent: SyntaxNode) { + this.green = green; + this.kind = green.kind; + this.text = green.text; + this.offset = offset; + this.parent = parent; + } + + get textLength(): number { + return this.text.length; + } + + /** The sibling element immediately after this token within its parent. */ + get nextSiblingOrToken(): SyntaxElement | undefined { + return siblingAfter(this.parent, this.green, this.offset); + } + + /** The sibling element immediately before this token within its parent. */ + get prevSiblingOrToken(): SyntaxElement | undefined { + return siblingBefore(this.parent, this.green, this.offset); + } + + /** 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}. Mirrors rust-analyzer's + * `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`. + */ +export class TokenAtOffset { + readonly #left: SyntaxToken | undefined; + readonly #right: SyntaxToken | undefined; + + private constructor(left: SyntaxToken | undefined, right: SyntaxToken | undefined) { + this.#left = left; + this.#right = right; + } + + static none(): TokenAtOffset { + return new TokenAtOffset(undefined, undefined); + } + + static single(token: SyntaxToken): TokenAtOffset { + return new TokenAtOffset(token, token); + } + + static between(left: SyntaxToken, right: SyntaxToken): TokenAtOffset { + return new TokenAtOffset(left, right); + } + + get isEmpty(): boolean { + return this.#left === undefined && this.#right === undefined; + } + + get isBetween(): boolean { + return this.#left !== undefined && this.#right !== undefined && this.#left !== this.#right; + } + + leftBiased(): SyntaxToken | undefined { + return this.#left; + } + + rightBiased(): SyntaxToken | undefined { + return this.#right; + } +} + export class SyntaxNode { readonly green: GreenNode; readonly offset: number; @@ -44,36 +136,32 @@ 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 siblingAfter(this.parent, this.green, this.offset); } 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 siblingBefore(this.parent, this.green, this.offset); + } + + /** 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 { @@ -116,21 +204,179 @@ 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. + * Consistent with the zero-width containment rule used by {@link containsOffset}: + * empty children are never the token at an offset. + */ + tokenAtOffset(offset: number): TokenAtOffset { + return tokenAtOffsetOf(this, offset); + } + + /** + * The smallest element fully containing the range `[start, end]`. Mirrors + * rust-analyzer's `covering_element`. At a seam (and for empty ranges) the + * left-hand element is preferred, matching {@link containsOffset}'s zero-width + * rule (`textLength === 0` ⇒ contains only the empty range at `start`). + */ + 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 elementLength(el: SyntaxElement): number { + return el instanceof SyntaxToken ? el.text.length : el.textLength; +} + +/** + * Whether `el` contains `offset`. A zero-width element contains only the empty + * position at its start (`textLength === 0` ⇒ `offset === start`); otherwise 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 len === 0 ? offset === start : offset >= start && offset <= start + len; +} + +function containsRange(el: SyntaxElement, start: number, end: number): boolean { + const elStart = el.offset; + const len = elementLength(el); + if (len === 0) return start === elStart && end === elStart; + 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 (elementLength(child) === 0) continue; + 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 siblingAfter( + parent: SyntaxNode, + green: GreenElement, + offset: number, +): SyntaxElement | undefined { + let cursor = parent.offset; + let found = false; + for (const child of parent.green.children) { + if (found) return wrapElement(child, cursor, parent); + if (child === green && cursor === offset) found = true; + cursor += elementTextLength(child); + } + return undefined; +} + +function siblingBefore( + parent: SyntaxNode, + green: GreenElement, + offset: number, +): SyntaxElement | undefined { + let cursor = parent.offset; + let prev: { green: GreenElement; offset: number } | undefined; + for (const child of parent.green.children) { + if (child === green && cursor === offset) { + if (prev === undefined) return undefined; + return wrapElement(prev.green, prev.offset, parent); + } + prev = { green: child, offset: cursor }; + cursor += elementTextLength(child); + } + return undefined; +} + +function climbingNext(el: SyntaxElement): SyntaxElement | undefined { + let current: SyntaxElement = el; + for (;;) { + const parent = current.parent; + if (parent === undefined) return undefined; + const sibling = siblingAfter(parent, current.green, current.offset); + 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 = siblingBefore(parent, current.green, current.offset); + if (sibling !== undefined) return sibling; + current = parent; + } +} + function wrapElement(green: GreenElement, offset: number, parent: SyntaxNode): SyntaxElement { if (green.type === 'token') { - const token: SyntaxToken = { kind: green.kind, text: green.text, offset }; - return token; + return new SyntaxToken(green, offset, parent); } return new SyntaxNode(green, offset, parent); } 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..ff23a6b5a1 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/test/syntax/navigation.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from 'vitest'; +import { GreenNodeBuilder } from '../../src/syntax/green-builder'; +import { + isTrivia, + isTriviaKind, + nonTriviaSibling, + previousNonTriviaToken, + 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(); + } + }); +}); + +describe('previousNonTriviaToken', () => { + it('finds the significant token before a token, crossing nodes', () => { + const intToken = sampleTokens(createSyntaxTree(buildSampleTree()))[9]; + expect(intToken.text).toBe('Int'); + expect(previousNonTriviaToken(intToken)?.text).toBe('id'); // the field name + }); + + it('accepts a node and starts from its first token', () => { + const root = createSyntaxTree(buildSampleTree()); + const attr = firstNodeOfKind(root, 'FieldAttribute'); + // FieldAttribute's first token is `@`; the previous significant token is `Int`. + expect(previousNonTriviaToken(attr)?.text).toBe('Int'); + }); + + it('returns undefined at the start of the document', () => { + const root = createSyntaxTree(buildSampleTree()); + const first = root.firstToken; + expect(first?.text).toBe('model'); + if (first !== undefined) { + expect(previousNonTriviaToken(first)).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..657f78fd0f 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,17 @@ import { describe, expect, it } from 'vitest'; import { GreenNodeBuilder } from '../../src/syntax/green-builder'; -import { createSyntaxTree, SyntaxNode } from '../../src/syntax/red'; +import { createSyntaxTree, SyntaxNode, SyntaxToken } 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() { @@ -210,6 +221,168 @@ describe('SyntaxNode.ancestors', () => { }); }); +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('skips a zero-width node sitting on the seam', () => { + 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 at = root.tokenAtOffset(1); + expect(at.isBetween).toBe(true); + expect(at.leftBiased()?.text).toBe('A'); + expect(at.rightBiased()?.text).toBe('B'); + }); + + 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('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(); From 2c9369fea6f6c379c450446d8a4136243488da8d Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Fri, 26 Jun 2026 17:44:47 +0000 Subject: [PATCH 08/54] refactor(language-server): rewrite completion classifier on red-tree navigation Anchor classification on tokenAtOffset(offset).leftBiased() and navigate from token.parent, with one source_range()-style replacement helper. Removes findCursorContext, findTokenContext, findDeepestNodeAtOffset, tokensBetween, lineStartOffsetFromTokens, containsOnlyWhitespaceTokens, the duplicated prefix builders, and the unused UnsupportedPslCompletionReason. Behavior unchanged; matches rust-analyzer classifier shape. Signed-off-by: Serhii Tatarintsev --- .../language-server/src/completion-context.ts | 413 +++++++----------- .../src/completion-provider.ts | 2 +- .../test/completion-context.test.ts | 96 ++-- 3 files changed, 200 insertions(+), 311 deletions(-) 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 index bab64849c1..659db7195f 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-context.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-context.ts @@ -5,16 +5,18 @@ import { FieldAttributeAst, FieldDeclarationAst, GenericBlockDeclarationAst, + isTrivia, KeyValuePairAst, ModelAttributeAst, ModelDeclarationAst, NamespaceDeclarationAst, type Position, + previousNonTriviaToken, type QualifiedNameAst, type SourceFile, type SyntaxNode, type SyntaxToken, - TypeAnnotationAst, + type TokenAtOffset, TypesBlockAst, } from '@prisma-next/psl-parser/syntax'; @@ -24,17 +26,6 @@ export interface ClassifyPslCompletionContextInput { readonly position: Position; } -export type UnsupportedPslCompletionReason = - | 'attribute' - | 'attributeArgument' - | 'comment' - | 'constructorArgument' - | 'fieldName' - | 'genericBlock' - | 'invalidQualifiedType' - | 'notTypePrefix' - | 'outsideModelField'; - export interface TypeNamePrefix { readonly path: readonly string[]; readonly contractSpace?: string; @@ -47,6 +38,7 @@ export interface ModelFieldTypeCompletionContext { readonly offset: number; readonly fieldName: string; readonly prefix: TypeNamePrefix; + readonly replacementStartOffset: number; } export interface GenericBlockParameterCompletionContext { @@ -71,7 +63,6 @@ export interface DeclarationKeywordCompletionContext { export interface UnsupportedPslCompletionContext { readonly kind: 'unsupported'; readonly offset: number; - readonly reason: UnsupportedPslCompletionReason; } export type PslCompletionContext = @@ -81,37 +72,41 @@ export type PslCompletionContext = | UnsupportedPslCompletionContext; interface TokenContext { - readonly current?: SyntaxToken; - readonly previous?: SyntaxToken; - readonly previousSignificant?: SyntaxToken; - readonly touching?: SyntaxToken; + readonly current: SyntaxToken | undefined; + readonly previousSignificant: SyntaxToken | undefined; + readonly touching: SyntaxToken | undefined; } export function classifyPslCompletionContext( input: ClassifyPslCompletionContextInput, ): PslCompletionContext { + const root = input.document.syntax; const offset = input.sourceFile.offsetAt(input.position); - const tokenContext = findTokenContext(input.document.syntax, offset); + const at = root.tokenAtOffset(offset); + const tokenContext = tokenContextAt(at, offset); if (tokenContext.current?.kind === 'Comment' || tokenContext.touching?.kind === 'Comment') { - return unsupported(offset, 'comment'); + return unsupported(offset); } - const node = findDeepestNodeAtOffset(input.document.syntax, offset); - const previousNode = - tokenContext.previousSignificant === undefined - ? undefined - : findDeepestNodeAtOffset(input.document.syntax, tokenContext.previousSignificant.offset); - const contextNode = nodeForContext(node, previousNode); - const ancestorReason = unsupportedAncestorReason(contextNode); - if (ancestorReason !== undefined) { - return unsupported(offset, ancestorReason); + // Anchor on the token left of the cursor (rust-analyzer's `original_token`) and + // navigate outward via `token.parent` rather than scanning the whole tree. + const anchorNode = at.leftBiased()?.parent; + const previousSignificantNode = tokenContext.previousSignificant?.parent; + const contextNode = nodeForContext(anchorNode, previousSignificantNode); + if (hasUnsupportedAncestor(contextNode)) { + return unsupported(offset); } + // rust-analyzer's `source_range()`: the edit replaces the identifier under the + // cursor, or is empty when the cursor sits in trivia. + const replacementStartOffset = sourceRangeStart(tokenContext, offset); + const declarationKeywordContext = classifyDeclarationKeyword({ node: contextNode, offset, - sourceFile: input.sourceFile, + source: input.sourceFile.text, tokenContext, + replacementStartOffset, }); if (declarationKeywordContext !== undefined) { return declarationKeywordContext; @@ -120,8 +115,9 @@ export function classifyPslCompletionContext( const genericBlockContext = classifyGenericBlockParameter({ node: contextNode, offset, - sourceFile: input.sourceFile, + source: input.sourceFile.text, tokenContext, + replacementStartOffset, }); if (genericBlockContext !== undefined) { return genericBlockContext; @@ -129,95 +125,101 @@ export function classifyPslCompletionContext( const field = closestAst(contextNode, FieldDeclarationAst.cast); if (field === undefined) { - return unsupported(offset, 'outsideModelField'); + return unsupported(offset); } if (closestAst(field.syntax, ModelDeclarationAst.cast) === undefined) { - return unsupported(offset, 'outsideModelField'); + return unsupported(offset); } return classifyModelFieldType({ field, offset, - sourceFile: input.sourceFile, + source: input.sourceFile.text, + replacementStartOffset, }); } function classifyModelFieldType(input: { readonly field: FieldDeclarationAst; readonly offset: number; - readonly sourceFile: SourceFile; + readonly source: string; + readonly replacementStartOffset: number; }): PslCompletionContext { const fieldName = input.field.name(); const fieldNameText = fieldName?.name(); if (fieldName === undefined || fieldNameText === undefined) { - return unsupported(input.offset, 'outsideModelField'); + return unsupported(input.offset); } const fieldNameStart = fieldName.syntax.offset; const fieldNameEnd = endOffset(fieldName.syntax); if (input.offset >= fieldNameStart && input.offset <= fieldNameEnd) { - return unsupported(input.offset, 'fieldName'); + return unsupported(input.offset); } const typeAnnotation = input.field.typeAnnotation(); if (typeAnnotation === undefined) { - return unsupported(input.offset, 'outsideModelField'); + return unsupported(input.offset); } const typeStart = typeAnnotation.syntax.offset; const typeEnd = endOffset(typeAnnotation.syntax); if (typeAnnotation.syntax.textLength === 0) { + const fieldNameToken = fieldName.syntax.lastToken; if ( + fieldNameToken !== undefined && input.offset > fieldNameEnd && input.offset <= typeStart && - hasOnlyHorizontalWhitespace(input.sourceFile.text, fieldNameEnd, input.offset) + onlyWhitespaceBetween(fieldNameToken, input.offset) ) { - return modelFieldType(input.offset, fieldNameText, { path: [], name: '' }); + return modelFieldType(input.offset, fieldNameText, { path: [], name: '' }, input.offset); } - return unsupported(input.offset, 'notTypePrefix'); + return unsupported(input.offset); } if (input.offset < typeStart || input.offset > typeEnd) { - return unsupported(input.offset, 'notTypePrefix'); + return unsupported(input.offset); } const constructorArgList = typeAnnotation.argList(); if (constructorArgList !== undefined && containsOffset(constructorArgList.syntax, input.offset)) { - return unsupported(input.offset, 'constructorArgument'); + return unsupported(input.offset); } const name = typeAnnotation.name(); if (name === undefined) { - return unsupported(input.offset, 'notTypePrefix'); + return unsupported(input.offset); } if (!containsOffset(name.syntax, input.offset)) { - return unsupported(input.offset, 'notTypePrefix'); + return unsupported(input.offset); } if (name.isOverQualified()) { - return unsupported(input.offset, 'invalidQualifiedType'); + return unsupported(input.offset); } - const prefix = typeNamePrefix(name, input.offset, input.sourceFile.text); + const prefix = typeNamePrefix(name, input.offset, input.source); if (prefix === undefined) { - return unsupported(input.offset, 'invalidQualifiedType'); + return unsupported(input.offset); } - return modelFieldType(input.offset, fieldNameText, prefix); + return modelFieldType(input.offset, fieldNameText, prefix, input.replacementStartOffset); } function modelFieldType( offset: number, fieldName: string, prefix: TypeNamePrefix, + replacementStartOffset: number, ): ModelFieldTypeCompletionContext { - return { kind: 'modelFieldType', offset, fieldName, prefix }; + return { kind: 'modelFieldType', offset, fieldName, prefix, replacementStartOffset }; } function classifyDeclarationKeyword(input: { readonly node: SyntaxNode | undefined; readonly offset: number; - readonly sourceFile: SourceFile; + readonly source: string; readonly tokenContext: TokenContext; + readonly replacementStartOffset: number; }): DeclarationKeywordCompletionContext | undefined { if (isInsideNonDeclarationKeywordBody(input.node, input.offset)) { return undefined; @@ -225,14 +227,9 @@ function classifyDeclarationKeyword(input: { const namespace = closestAst(input.node, NamespaceDeclarationAst.cast); const scope = namespaceBodyContainsOffset(namespace, input.offset) ? 'namespace' : 'document'; - const anchorOffset = scope === 'namespace' ? namespace?.lbrace()?.offset : undefined; - const prefix = declarationKeywordPrefix({ - offset: input.offset, - source: input.sourceFile.text, - tokenContext: input.tokenContext, - ...(anchorOffset === undefined ? {} : { anchorOffset }), - }); - if (prefix === undefined) { + + const prefixToken = cursorIdentifier(input.tokenContext, input.offset); + if (!declarationKeywordAllowed(prefixToken, namespace, input)) { return undefined; } @@ -240,11 +237,38 @@ function classifyDeclarationKeyword(input: { kind: 'declarationKeyword', offset: input.offset, scope, - prefix: prefix.text, - replacementStartOffset: prefix.replacementStartOffset, + prefix: input.source.slice(input.replacementStartOffset, input.offset), + replacementStartOffset: input.replacementStartOffset, }; } +/** + * A declaration keyword may be completed only when nothing but whitespace + * precedes the cursor on its line — i.e. the previous significant token is on an + * earlier line, is absent, or is the enclosing namespace's opening brace. + */ +function declarationKeywordAllowed( + prefixToken: SyntaxToken | undefined, + namespace: NamespaceDeclarationAst | undefined, + input: { readonly offset: number; readonly tokenContext: TokenContext }, +): boolean { + const previous = + prefixToken !== undefined + ? previousNonTriviaToken(prefixToken) + : input.tokenContext.previousSignificant; + if (previous === undefined) { + return true; + } + + const start = prefixToken?.offset ?? input.offset; + if (newlineBetween(previous, start)) { + return true; + } + + const lbrace = namespace?.lbrace(); + return lbrace !== undefined && lbrace.offset === previous.offset; +} + function isInsideNonDeclarationKeywordBody(node: SyntaxNode | undefined, offset: number): boolean { return ( declarationBodyContainsOffset(closestAst(node, ModelDeclarationAst.cast), offset) || @@ -293,114 +317,53 @@ function namespaceBodyContainsOffset( return offset >= bodyStart && offset <= bodyEnd; } -function declarationKeywordPrefix(input: { - readonly offset: number; - readonly source: string; - readonly tokenContext: TokenContext; - readonly anchorOffset?: number; -}): { readonly text: string; readonly replacementStartOffset: number } | undefined { - const token = declarationPrefixToken(input.tokenContext, input.offset); - const start = token?.offset ?? input.offset; - if (!hasOnlyHorizontalWhitespace(input.source, declarationPrefixAllowedStart(input), start)) { - return undefined; - } - if (token === undefined) { - return { text: '', replacementStartOffset: input.offset }; - } - return { - text: input.source.slice(token.offset, input.offset), - replacementStartOffset: token.offset, - }; -} - -function declarationPrefixToken( - tokenContext: TokenContext, - offset: number, -): SyntaxToken | undefined { - if (tokenContext.current?.kind === 'Ident') { - return tokenContext.current; - } - if (tokenContext.touching?.kind === 'Ident') { - return tokenContext.touching; - } - if ( - tokenContext.previousSignificant?.kind === 'Ident' && - tokenContext.previousSignificant.offset + tokenContext.previousSignificant.text.length === - offset - ) { - return tokenContext.previousSignificant; - } - return undefined; -} - -function declarationPrefixAllowedStart(input: { - readonly offset: number; - readonly source: string; - readonly anchorOffset?: number; -}): number { - const lineStart = lineStartOffset(input.source, input.offset); - if (input.anchorOffset === undefined || input.anchorOffset < lineStart) { - return lineStart; - } - return input.anchorOffset + 1; -} - -function lineStartOffset(source: string, offset: number): number { - const previousNewline = source.lastIndexOf('\n', Math.max(0, offset - 1)); - return previousNewline < 0 ? 0 : previousNewline + 1; -} - function classifyGenericBlockParameter(input: { readonly node: SyntaxNode | undefined; readonly offset: number; - readonly sourceFile: SourceFile; + readonly source: string; readonly tokenContext: TokenContext; + readonly replacementStartOffset: number; }): PslCompletionContext | undefined { const block = closestAst(input.node, GenericBlockDeclarationAst.cast); if (block === undefined) { return undefined; } - const lbrace = firstTokenOfKind(block.syntax, 'LBrace'); + const lbrace = block.lbrace(); if (lbrace === undefined || input.offset < lbrace.offset + lbrace.text.length) { - return unsupported(input.offset, 'genericBlock'); + return unsupported(input.offset); } - const rbrace = firstTokenOfKind(block.syntax, 'RBrace'); + const rbrace = block.rbrace(); if (rbrace !== undefined && input.offset > rbrace.offset) { - return unsupported(input.offset, 'genericBlock'); + return unsupported(input.offset); } const field = closestAst(input.node, FieldDeclarationAst.cast); if (field !== undefined && containsOffset(field.syntax, input.offset)) { - return unsupported(input.offset, 'genericBlock'); + return unsupported(input.offset); } if (input.tokenContext.previousSignificant?.kind === 'Equals') { - return unsupported(input.offset, 'genericBlock'); + return unsupported(input.offset); } const keyword = block.keyword()?.text; if (keyword === undefined || keyword.length === 0) { - return unsupported(input.offset, 'genericBlock'); + return unsupported(input.offset); } const activePair = activeKeyValuePair(input.node, input.offset); if (activePair !== undefined && isAfterEquals(activePair, input.offset)) { - return unsupported(input.offset, 'genericBlock'); - } - - const prefix = genericBlockParameterPrefix(activePair, input.offset, input.sourceFile.text); - if (prefix === undefined) { - return unsupported(input.offset, 'genericBlock'); + return unsupported(input.offset); } return { kind: 'genericBlockParameter', offset: input.offset, blockKeyword: keyword, - prefix: prefix.text, - replacementStartOffset: prefix.replacementStartOffset, + prefix: input.source.slice(input.replacementStartOffset, input.offset), + replacementStartOffset: input.replacementStartOffset, existingParameterNames: existingParameterNames(block, activePair), }; } @@ -417,39 +380,10 @@ function activeKeyValuePair( } function isAfterEquals(pair: KeyValuePairAst, offset: number): boolean { - const equals = firstTokenOfKind(pair.syntax, 'Equals'); + const equals = pair.equals(); return equals !== undefined && offset > equals.offset; } -function genericBlockParameterPrefix( - pair: KeyValuePairAst | undefined, - offset: number, - source: string, -): { readonly text: string; readonly replacementStartOffset: number } | undefined { - if (pair === undefined) { - return { text: '', replacementStartOffset: offset }; - } - - const key = pair.key(); - if (key === undefined) { - return { text: '', replacementStartOffset: offset }; - } - - const keyStart = key.syntax.offset; - const keyEnd = endOffset(key.syntax); - if (offset < keyStart) { - return { text: '', replacementStartOffset: offset }; - } - if (offset <= keyEnd) { - return { text: source.slice(keyStart, offset), replacementStartOffset: keyStart }; - } - const equals = firstTokenOfKind(pair.syntax, 'Equals'); - if (equals === undefined || offset <= equals.offset) { - return { text: source.slice(keyStart, keyEnd), replacementStartOffset: keyStart }; - } - return undefined; -} - function existingParameterNames( block: GenericBlockDeclarationAst, activePair: KeyValuePairAst | undefined, @@ -471,38 +405,16 @@ function sameSpan(left: SyntaxNode, right: SyntaxNode): boolean { return left.offset === right.offset && left.textLength === right.textLength; } -function firstTokenOfKind(node: SyntaxNode, kind: SyntaxToken['kind']): SyntaxToken | undefined { - for (const token of node.tokens()) { - if (token.kind === kind) { - return token; - } - } - return undefined; -} - -function unsupported( - offset: number, - reason: UnsupportedPslCompletionReason, -): UnsupportedPslCompletionContext { - return { kind: 'unsupported', offset, reason }; +function unsupported(offset: number): UnsupportedPslCompletionContext { + return { kind: 'unsupported', offset }; } -function unsupportedAncestorReason( - node: SyntaxNode | undefined, -): UnsupportedPslCompletionReason | undefined { - const argList = closestAst(node, AttributeArgListAst.cast); - if (argList !== undefined) { - return closestAst(argList.syntax, TypeAnnotationAst.cast) === undefined - ? 'attributeArgument' - : 'constructorArgument'; - } - if ( +function hasUnsupportedAncestor(node: SyntaxNode | undefined): boolean { + return ( + closestAst(node, AttributeArgListAst.cast) !== undefined || closestAst(node, FieldAttributeAst.cast) !== undefined || closestAst(node, ModelAttributeAst.cast) !== undefined - ) { - return 'attribute'; - } - return undefined; + ); } function typeNamePrefix( @@ -608,74 +520,72 @@ function closestAst( node: SyntaxNode | undefined, cast: (node: SyntaxNode) => T | undefined, ): T | undefined { - for (let current = node; current !== undefined; current = current.parent) { - const result = cast(current); + if (node === undefined) { + return undefined; + } + const current = cast(node); + if (current !== undefined) { + return current; + } + for (const ancestor of node.ancestors()) { + const result = cast(ancestor); if (result !== undefined) return result; } return undefined; } -function findDeepestNodeAtOffset(node: SyntaxNode, offset: number): SyntaxNode | undefined { - if (!containsOffset(node, offset)) { - return undefined; - } - let deepest = node; - for (const child of node.childNodes()) { - const childMatch = findDeepestNodeAtOffset(child, offset); - if (childMatch !== undefined) { - deepest = childMatch; - } +function tokenContextAt(at: TokenAtOffset, offset: number): TokenContext { + const left = at.leftBiased(); + const right = at.rightBiased(); + const current = right !== undefined && offset < tokenEndOffset(right) ? right : undefined; + const touching = left !== undefined && tokenEndOffset(left) === offset ? left : undefined; + let previousSignificant: SyntaxToken | undefined; + if (left !== undefined) { + previousSignificant = + tokenEndOffset(left) <= offset && !isTrivia(left) ? left : previousNonTriviaToken(left); } - return deepest; + return { current, previousSignificant, touching }; } -function findTokenContext(root: SyntaxNode, offset: number): TokenContext { - let current: SyntaxToken | undefined; - let previous: SyntaxToken | undefined; - let previousSignificant: SyntaxToken | undefined; - let touching: SyntaxToken | undefined; - - for (const token of root.tokens()) { - const tokenEnd = token.offset + token.text.length; - if (offset >= token.offset && offset < tokenEnd) { - current = token; - } - if (offset > token.offset && offset <= tokenEnd) { - touching = token; - } - if (tokenEnd <= offset) { - previous = token; - if (!isTrivia(token)) { - previousSignificant = token; - } - continue; - } - if (token.offset > offset) { - break; - } +/** The identifier token the cursor is editing, if any (rust-analyzer's name ref). */ +function cursorIdentifier(tokenContext: TokenContext, offset: number): SyntaxToken | undefined { + if (tokenContext.current?.kind === 'Ident') { + return tokenContext.current; + } + if (tokenContext.touching?.kind === 'Ident') { + return tokenContext.touching; } + const previous = tokenContext.previousSignificant; + if (previous?.kind === 'Ident' && tokenEndOffset(previous) === offset) { + return previous; + } + return undefined; +} - return tokenContext({ current, previous, previousSignificant, touching }); +function sourceRangeStart(tokenContext: TokenContext, offset: number): number { + return cursorIdentifier(tokenContext, offset)?.offset ?? offset; } -function tokenContext(input: { - readonly current: SyntaxToken | undefined; - readonly previous: SyntaxToken | undefined; - readonly previousSignificant: SyntaxToken | undefined; - readonly touching: SyntaxToken | undefined; -}): TokenContext { - return { - ...(input.current === undefined ? {} : { current: input.current }), - ...(input.previous === undefined ? {} : { previous: input.previous }), - ...(input.previousSignificant === undefined - ? {} - : { previousSignificant: input.previousSignificant }), - ...(input.touching === undefined ? {} : { touching: input.touching }), - }; +/** Whether a newline trivia token separates `from` from `toOffset`. */ +function newlineBetween(from: SyntaxToken, toOffset: number): boolean { + for (let token = from.nextToken; token !== undefined && token.offset < toOffset; ) { + if (token.kind === 'Newline') { + return true; + } + token = token.nextToken; + } + return false; } -function isTrivia(token: SyntaxToken): boolean { - return token.kind === 'Whitespace' || token.kind === 'Newline' || token.kind === 'Comment'; +/** Whether only whitespace trivia tokens lie between `from` and `toOffset`. */ +function onlyWhitespaceBetween(from: SyntaxToken, toOffset: number): boolean { + for (let token = from.nextToken; token !== undefined && token.offset < toOffset; ) { + if (token.kind !== 'Whitespace') { + return false; + } + token = token.nextToken; + } + return true; } function containsOffset(node: SyntaxNode, offset: number): boolean { @@ -688,15 +598,6 @@ function endOffset(node: SyntaxNode): number { return node.offset + node.textLength; } -function hasOnlyHorizontalWhitespace(source: string, start: number, end: number): boolean { - if (end < start) { - return false; - } - for (let index = start; index < end; index++) { - const char = source.charAt(index); - if (char !== ' ' && char !== '\t') { - return false; - } - } - return true; +function tokenEndOffset(token: SyntaxToken): number { + return token.offset + token.text.length; } 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 index d58bf3b1a4..be27fa688b 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-provider.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-provider.ts @@ -251,7 +251,7 @@ function provideModelFieldTypeCompletionItems( source: PslCompletionCandidateSource, ): readonly CompletionItem[] { const replacementRange = { - start: sourceFile.positionAt(context.offset - context.prefix.name.length), + start: sourceFile.positionAt(context.replacementStartOffset), end: sourceFile.positionAt(context.offset), }; 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 index 1279d18a79..2c368cc468 100644 --- 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 @@ -15,6 +15,10 @@ function classify(markedSource: string): ReturnType { it('classifies blank document-level declaration keyword positions', () => { const context = classify('|'); @@ -40,6 +44,10 @@ describe('classifyPslCompletionContext', () => { }); }); + it('does not classify declaration keyword prefixes after other tokens on the same line', () => { + expectUnsupported('model User {} mo|'); + }); + it('classifies blank namespace-body declaration keyword positions', () => { const context = classify(['namespace auth {', ' |', '}'].join('\n')); @@ -70,6 +78,14 @@ describe('classifyPslCompletionContext', () => { }); }); + it('does not treat the next indented line as a blank model field type position', () => { + expectUnsupported(['model Post {', ' author', ' |', '}'].join('\n')); + }); + + it('does not treat a comment after the field name as a blank model field type position', () => { + expectUnsupported(['model Post {', ' author // |', '}'].join('\n')); + }); + it('classifies a partial bare model field type prefix', () => { const context = classify(['model Post {', ' reviewer U|', '}'].join('\n')); @@ -127,41 +143,18 @@ describe('classifyPslCompletionContext', () => { }); it('returns unsupported in comments and trivia outside type positions', () => { - expect(classify(['model Post {', ' // U|', ' id Int', '}'].join('\n'))).toMatchObject({ - kind: 'unsupported', - reason: 'comment', - }); - - expect(classify(['model Post {', ' |', ' id Int', '}'].join('\n'))).toMatchObject({ - kind: 'unsupported', - reason: 'outsideModelField', - }); + expectUnsupported(['model Post {', ' // U|', ' id Int', '}'].join('\n')); + expectUnsupported(['model Post {', ' |', ' id Int', '}'].join('\n')); }); it('returns unsupported for ordinary field and block attributes', () => { - expect(classify(['model Post {', ' id Int @|', '}'].join('\n'))).toMatchObject({ - kind: 'unsupported', - reason: 'attribute', - }); - - expect(classify(['model Post {', ' id Int', ' @@|', '}'].join('\n'))).toMatchObject({ - kind: 'unsupported', - reason: 'attribute', - }); + expectUnsupported(['model Post {', ' id Int @|', '}'].join('\n')); + expectUnsupported(['model Post {', ' id Int', ' @@|', '}'].join('\n')); }); it('returns unsupported inside attribute arguments', () => { - expect(classify(['model Post {', ' id Int @default(|)', '}'].join('\n'))).toMatchObject({ - kind: 'unsupported', - reason: 'attributeArgument', - }); - - expect( - classify(['model Post {', ' authorId Int @relation(fields: [|])', '}'].join('\n')), - ).toMatchObject({ - kind: 'unsupported', - reason: 'attributeArgument', - }); + expectUnsupported(['model Post {', ' id Int @default(|)', '}'].join('\n')); + expectUnsupported(['model Post {', ' authorId Int @relation(fields: [|])', '}'].join('\n')); }); it('classifies blank generic block parameter positions', () => { @@ -185,40 +178,35 @@ describe('classifyPslCompletionContext', () => { }); it('returns unsupported inside generic block parameter values', () => { - expect(classify(['datasource db {', ' provider = |', '}'].join('\n'))).toMatchObject({ - kind: 'unsupported', - reason: 'genericBlock', + expectUnsupported(['datasource db {', ' provider = |', '}'].join('\n')); + }); + + it('classifies the gap before = as a generic block parameter with an empty source range', () => { + const context = classify(['datasource db {', ' url |= "x"', '}'].join('\n')); + + expect(context).toMatchObject({ + kind: 'genericBlockParameter', + blockKeyword: 'datasource', + prefix: '', }); + // 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 === 'genericBlockParameter') { + expect(context.replacementStartOffset).toBe(context.offset); + } }); it('returns unsupported inside type constructor arguments', () => { - expect(classify(['model Embedding {', ' vector Vector(|)', '}'].join('\n'))).toMatchObject({ - kind: 'unsupported', - reason: 'constructorArgument', - }); + expectUnsupported(['model Embedding {', ' vector Vector(|)', '}'].join('\n')); }); it('returns unsupported outside model field type prefixes', () => { - expect(classify(['model Post {', ' |id Int', '}'].join('\n'))).toMatchObject({ - kind: 'unsupported', - reason: 'fieldName', - }); - - expect(classify(['model Post {', ' id Int |', '}'].join('\n'))).toMatchObject({ - kind: 'unsupported', - reason: 'notTypePrefix', - }); + expectUnsupported(['model Post {', ' |id Int', '}'].join('\n')); + expectUnsupported(['model Post {', ' id Int |', '}'].join('\n')); }); it('returns unsupported for invalid over-qualified names', () => { - expect(classify(['model Post {', ' owner auth.domain.U|', '}'].join('\n'))).toMatchObject({ - kind: 'unsupported', - reason: 'invalidQualifiedType', - }); - - expect(classify(['model Post {', ' owner supabase:auth:U|', '}'].join('\n'))).toMatchObject({ - kind: 'unsupported', - reason: 'invalidQualifiedType', - }); + expectUnsupported(['model Post {', ' owner auth.domain.U|', '}'].join('\n')); + expectUnsupported(['model Post {', ' owner supabase:auth:U|', '}'].join('\n')); }); }); From 17583bd6ec40cff7d4500cd99a458b6c1350fd31 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Fri, 26 Jun 2026 17:44:59 +0000 Subject: [PATCH 09/54] fix(language-server): refresh stale completion artifacts and harden playground URL parsing Address PR review: rebuild completion artifacts from the current buffer before classifying so completions after an edit are not stale; parse playground request URLs against a fixed local base and return 400 on malformed/absent URLs instead of trusting the incoming Host header. Signed-off-by: Serhii Tatarintsev --- apps/lsp-playground/src/cli.ts | 21 +++++++++++++++--- .../3-tooling/language-server/src/server.ts | 18 ++++++++++++++- .../language-server/test/server.test.ts | 22 +++++++++++++++++++ 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/apps/lsp-playground/src/cli.ts b/apps/lsp-playground/src/cli.ts index 3db2900889..efbc3e85e9 100644 --- a/apps/lsp-playground/src/cli.ts +++ b/apps/lsp-playground/src/cli.ts @@ -12,6 +12,7 @@ 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; @@ -21,6 +22,17 @@ interface RuntimeConfig { 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 { await access(path); @@ -195,9 +207,12 @@ async function main(): Promise { httpServer.on( 'request', (request: nodeHttp.IncomingMessage, response: nodeHttp.ServerResponse) => { - const baseUrl = `http://${request.headers.host ?? 'localhost'}/`; - const requestPath = - request.url !== undefined ? new URL(request.url, baseUrl).pathname : undefined; + 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; 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 1717dc074b..dbc04a784b 100644 --- a/packages/1-framework/3-tooling/language-server/src/server.ts +++ b/packages/1-framework/3-tooling/language-server/src/server.ts @@ -309,7 +309,7 @@ export function createServer(connection: Connection): LanguageServer { return []; } - const cached = project.artifacts.getDocument(uri); + const cached = currentDocumentArtifact(project, uri, document.getText()); const symbolTable = project.artifacts.getSymbolTable(); if (cached === undefined || symbolTable === undefined) { return []; @@ -338,6 +338,22 @@ export function createServer(connection: Connection): LanguageServer { } } + function currentDocumentArtifact( + project: ProjectState, + uri: string, + text: string, + ): CachedDocument | undefined { + const cached = project.artifacts.getDocument(uri); + if (cached?.sourceFile.text === text) { + return cached; + } + + if (project.artifacts.update(uri, text, project.inputs, project.controlStack) === null) { + return undefined; + } + return project.artifacts.getDocument(uri); + } + connection.onInitialize(async (params): Promise => { rootPath = resolveRootPath(params.rootUri, params.rootPath); watchedConfigGlob = join(rootPath, '**', CONFIG_FILENAME); 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 f3ab974ece..a01a10674d 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 @@ -522,6 +522,28 @@ describe('language server', { timeout: timeouts.databaseOperation }, () => { ]); }); + it('refreshes completion artifacts from the current buffer before classifying', async () => { + harness = startHarness(resolveToSchema); + await harness.initialize(); + const initial = ['model Post {', ' author |', '}'].join('\n'); + const 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(['User']); + await republished; + }); + it('returns generic block parameter completions for configured PSL descriptors', async () => { harness = startHarness(resolveToSchemaWithPslBlockDescriptors); await harness.initialize(); From 3f1344fef9c816463a484ab4b778e5b86961bc10 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Fri, 26 Jun 2026 17:45:10 +0000 Subject: [PATCH 10/54] chore(drive): record navigation + classifier-simplification slices and review dispatch Signed-off-by: Serhii Tatarintsev --- projects/lsp-autocomplete/plan.md | 14 ++++ .../completion-context-simplification/plan.md | 35 ++++++++++ .../completion-context-simplification/spec.md | 59 +++++++++++++++++ .../slices/parser-red-tree-navigation/plan.md | 37 +++++++++++ .../slices/parser-red-tree-navigation/spec.md | 66 +++++++++++++++++++ ...view-feedback-and-classifier-navigation.md | 50 ++++++++++++++ .../top-level-keyword-completions/plan.md | 17 +++++ 7 files changed, 278 insertions(+) create mode 100644 projects/lsp-autocomplete/slices/completion-context-simplification/plan.md create mode 100644 projects/lsp-autocomplete/slices/completion-context-simplification/spec.md create mode 100644 projects/lsp-autocomplete/slices/parser-red-tree-navigation/plan.md create mode 100644 projects/lsp-autocomplete/slices/parser-red-tree-navigation/spec.md create mode 100644 projects/lsp-autocomplete/slices/top-level-keyword-completions/dispatches/02-review-feedback-and-classifier-navigation.md diff --git a/projects/lsp-autocomplete/plan.md b/projects/lsp-autocomplete/plan.md index ed269d8732..71759e2972 100644 --- a/projects/lsp-autocomplete/plan.md +++ b/projects/lsp-autocomplete/plan.md @@ -29,10 +29,24 @@ This project is a three-slice stack. Slice 1 lands the completion request path, - **Hands to:** The expanded first autocomplete surface now includes model field type completions, generic block entry/parameter completions, and declaration keyword completions without adding ordinary PSL attribute completions. - **Focus:** Declaration-position classification; document-vs-namespace native keyword sets; descriptor-backed generic block keyword suggestions; snippet support derived from LSP initialize capabilities; plain-text fallbacks for non-snippet clients; README and guardrail tests. +4. **Slice `parser-red-tree-navigation`** — Linear: TML-2948 (pending) + - **Outcome:** The PSL parser's red tree exposes rust-analyzer-style cursor navigation: navigable tokens (parent + previous/next), `SyntaxNode.tokenAtOffset`, covering-element lookup, and trivia-skipping helpers, exported from `@prisma-next/psl-parser/syntax`. + - **Builds on:** The existing red/green tree (`syntax/red.ts`), AST wrappers, and `tokens()`/`children()`/`ancestors()` iteration already present. + - **Hands to:** A navigation API the completion classifier (and future hover/go-to-definition) can drive instead of re-scanning the whole document, removing the need for source-text whitespace/line scans. + - **Focus:** Make red tokens carry their parent and support previous/next traversal; add offset-query entry points (`tokenAtOffset`, covering element) and non-trivia skip helpers; mirror rust-analyzer idioms (except the fake-identifier completion marker, which we deliberately do not adopt); unit tests in `psl-parser`. + +5. **Slice `completion-context-simplification`** — Linear: TML-2949 (pending) + - **Outcome:** `completion-context.ts` classifies from a single anchor token via parent/ancestor dispatch with one unified replacement-range helper, deleting the bespoke whole-tree token plumbing (`findCursorContext`, located-element bookkeeping, `tokensBetween`, `lineStartOffsetFromTokens`, `containsOnlyWhitespaceTokens`). + - **Builds on:** Slice `parser-red-tree-navigation`'s token-navigation API; the existing completion classifier tests as the behavior guardrail. + - **Hands to:** A smaller, rust-analyzer-shaped completion classifier with identical behavior, validated by the existing language-server test suite. + - **Focus:** Anchor on `tokenAtOffset(offset).leftBiased()`; classify by the parent/ancestor AST type in one dispatch; derive the edit range once from the anchor token; preserve completion semantics; no fake-identifier reparsing. + ### Parallel groups None. All slices touch the same language-server completion route and classifier. The generic block and declaration keyword providers are simpler and safer once the model-type slice has established provider dispatch, unsupported-context semantics, and server request wiring. +Slices 4 and 5 are a stack: `completion-context-simplification` builds directly on `parser-red-tree-navigation`'s hand-off and must not start until the navigation API is landed. + ## Dependencies (external) - [x] Linear Project exists — [Language Tools Support Prisma Next PSL](https://linear.app/prisma-company/project/language-tools-support-prisma-next-psl-3422a7e44b9c), Terminal team. diff --git a/projects/lsp-autocomplete/slices/completion-context-simplification/plan.md b/projects/lsp-autocomplete/slices/completion-context-simplification/plan.md new file mode 100644 index 0000000000..c5010b8746 --- /dev/null +++ b/projects/lsp-autocomplete/slices/completion-context-simplification/plan.md @@ -0,0 +1,35 @@ +# Slice plan: completion-context-simplification + +**Spec:** `projects/lsp-autocomplete/slices/completion-context-simplification/spec.md` +**Parent project:** `projects/lsp-autocomplete/spec.md` +**Depends on:** `projects/lsp-autocomplete/slices/parser-red-tree-navigation` + +## Dispatch plan + +### Dispatch 1: rewrite-classifier-on-navigation-api + +- **Outcome:** `completion-context.ts` classifies from a single anchor token via parent/ancestor dispatch with one unified replacement-range helper, with the bespoke whole-tree token plumbing deleted and all existing language-server tests passing with unchanged behavior. +- **Builds on:** Slice `parser-red-tree-navigation`'s token-navigation API (`tokenAtOffset` + left/right bias, navigable tokens, non-trivia helpers). +- **Hands to:** A smaller, rust-analyzer-shaped completion classifier ready for PR review on the existing branch. +- **Focus:** Anchor on `tokenAtOffset(offset).leftBiased()` and navigate from `token.parent`; collapse the three `classifyX` re-derivations toward one parent/ancestor dispatch (using `closestAst` as the `match_ast!` analog); derive the edit range once from the anchor token like rust-analyzer's `source_range()`; delete `findCursorContext`, located-element/token bookkeeping, `tokensBetween`, `lineStartOffsetFromTokens`, `containsOnlyWhitespaceTokens`, and the duplicated prefix logic; re-express whitespace/line checks via the slice-A non-trivia helpers. Preserve completion semantics. Use rust-analyzer idioms when in doubt. Do **not** change the parser, add fake-identifier reparsing, or alter the already-landed server/playground changes. +- **Completed when:** + - `completion-context.ts` no longer contains `findCursorContext`, `tokensBetween`, `lineStartOffsetFromTokens`, or `containsOnlyWhitespaceTokens`, and computes the edit range in one place. + - Classification anchors on the navigation API rather than scanning `root.tokens()` from the document root. + - The full language-server test suite passes with behavior unchanged; tests change only where they asserted removed internals. +- **Validation gates:** + - `pnpm --filter @prisma-next/language-server test` + - `pnpm --filter @prisma-next/language-server typecheck` + - `pnpm --filter @prisma-next/language-server lint` + +## Dispatch-INVEST check + +- **Independent:** Single-file rewrite once slice A has landed; no concurrent work. +- **Negotiable:** Outcome (anchor-token + parent dispatch + unified range, bespoke plumbing gone) is pinned; exact dispatch structure is the implementer's discovery against rust-analyzer idioms. +- **Valuable:** Delivers the simplification the operator approved; removes the workaround code root-caused in slice A. +- **Estimable:** Completed-when is binary and test-backed. +- **Small:** One file, one package, behavior fixed by existing tests. +- **Testable:** The language-server suite plus package gates verify it. + +## Open items + +None. diff --git a/projects/lsp-autocomplete/slices/completion-context-simplification/spec.md b/projects/lsp-autocomplete/slices/completion-context-simplification/spec.md new file mode 100644 index 0000000000..ba8d817696 --- /dev/null +++ b/projects/lsp-autocomplete/slices/completion-context-simplification/spec.md @@ -0,0 +1,59 @@ +# Slice spec: completion-context-simplification + +**Parent project:** `projects/lsp-autocomplete/spec.md` +**Depends on:** `projects/lsp-autocomplete/slices/parser-red-tree-navigation` (its token-navigation API) + +## At a glance + +`packages/1-framework/3-tooling/language-server/src/completion-context.ts` currently reconstructs cursor context the hard way: `findCursorContext` scans `root.tokens()` from the document root, and helpers (`tokensBetween`, `lineStartOffsetFromTokens`, `containsOnlyWhitespaceTokens`) re-scan tokens or reason over offsets to answer "what is left of the cursor" and "is there only indentation here". rust-analyzer answers the same questions by anchoring on one token and walking the tree: `token_at_offset(offset).left_biased()`, then `token.parent()`, then a single `match_ast!` dispatch over the parent/ancestor type, with the edit range derived once from the anchor token (`source_range()`). + +This slice rewrites the classifier onto the navigation API delivered by the `parser-red-tree-navigation` slice, matching rust-analyzer's shape. Behavior is unchanged; the existing classifier/provider/server tests are the guardrail. + +## Chosen design + +- **Single anchor token.** Replace `findCursorContext`'s from-root scan with `tokenAtOffset(offset).leftBiased()` and navigate from the token's parent. Mirror rust-analyzer's `original_token` + `token.parent()` entry. +- **Parent/ancestor dispatch.** Collapse the independent re-derivations in `classifyDeclarationKeyword` / `classifyGenericBlockParameter` / `classifyModelFieldType` toward one dispatch keyed on the anchor's parent/ancestor AST type, mirroring `classify_name_ref`'s `match_ast!`. The existing `closestAst(node, …Ast.cast)` is our `match_ast!` analog. +- **One replacement-range helper.** Derive the edit range once from the anchor token — identifier/keyword token ⇒ its range; otherwise an empty range at the cursor — mirroring `source_range()`. Remove the three separate prefix-slice computations. +- **Trivia/line checks via navigation.** Replace `containsOnlyWhitespaceTokens` / `lineStartOffsetFromTokens` / `tokensBetween` with the slice-A non-trivia/sibling/previous-token helpers. +- **No fake identifier.** Keep relying on the error-tolerant single parse. + +## Coherence rationale + +One file rewrite in one package, with the existing language-server test suite (completion-context, completion-provider, server) holding behavior fixed. One reviewer can confirm "same behavior, rust-analyzer shape, bespoke plumbing deleted" in one sitting. + +## Scope + +**In:** + +- `packages/1-framework/3-tooling/language-server/src/completion-context.ts` — rewrite onto the navigation API; delete `findCursorContext`, located-element/token bookkeeping, `tokensBetween`, `lineStartOffsetFromTokens`, `containsOnlyWhitespaceTokens`, and the duplicated prefix/replacement-range logic. +- `completion-provider.ts` only if the context's public shape changes (e.g. a unified replacement range now travels on the context). +- Existing language-server tests updated only where they assert removed internals; behavior assertions must stay. + +**Deliberately out:** + +- New completion *features* or semantics (model/namespace/generic-block/declaration-keyword behavior is fixed). +- Parser changes (those are slice A; if a gap appears, halt and route back, do not patch the parser here). +- Fake-identifier reparsing. +- Server stale-artifact refresh and playground changes already landed earlier on the branch. + +## Pre-investigated edge cases + +| Case | Note | +| --- | --- | +| Empty type position `author |` | Must remain a `modelFieldType` context; previously relied on `containsOnlyWhitespaceTokens`. Re-express via non-trivia navigation, not text scan. | +| `author\n |` and `author // |` | Must remain unsupported; the previous-non-trivia-token / newline-trivia distinction now comes from navigation helpers. | +| Declaration keyword after another token on the same line (`model User {} mo|`) | Must remain unsupported; comes from the previous-non-trivia-token, not a line scan. | + +## Slice-specific done conditions + +- The bespoke whole-tree token plumbing named above is gone, and the full language-server test suite passes unchanged in behavior. + +## Open questions + +None. + +## References + +- `packages/1-framework/3-tooling/language-server/src/completion-context.ts` — current classifier. +- `packages/1-framework/3-tooling/language-server/test/completion-context.test.ts` — behavior guardrail. +- rust-analyzer `crates/ide-completion/src/context.rs` (`source_range`, `original_token`) and `context/analysis.rs` (`classify_name_ref`, `match_ast!`) — idiom reference. diff --git a/projects/lsp-autocomplete/slices/parser-red-tree-navigation/plan.md b/projects/lsp-autocomplete/slices/parser-red-tree-navigation/plan.md new file mode 100644 index 0000000000..629d8d4b76 --- /dev/null +++ b/projects/lsp-autocomplete/slices/parser-red-tree-navigation/plan.md @@ -0,0 +1,37 @@ +# Slice plan: parser-red-tree-navigation + +**Spec:** `projects/lsp-autocomplete/slices/parser-red-tree-navigation/spec.md` +**Parent project:** `projects/lsp-autocomplete/spec.md` + +## Dispatch plan + +### Dispatch 1: red-tree-cursor-navigation + +- **Outcome:** The PSL parser red tree exposes navigable tokens (parent + previous/next), `SyntaxNode.tokenAtOffset` with left/right bias, a covering-element lookup, and trivia-skip helpers, all exported from `@prisma-next/psl-parser/syntax` and covered by unit tests. +- **Builds on:** The existing red/green tree, AST wrappers, and node-level navigation (`children`/`ancestors`/`prevSibling`/`tokens`). +- **Hands to:** A cursor-navigation API the completion classifier consumes in the next slice: from an offset, get the anchor token, its parent node, previous/next token, and nearest non-trivia neighbor. +- **Focus:** Thread the already-known parent through `wrapElement` into the red token; add token prev/next traversal; add `tokenAtOffset` (representing the between-two-tokens case with left/right bias) and covering-element lookup on `SyntaxNode`; add `skip_trivia_token` / `non_trivia_sibling` / `previous_non_trivia_token` equivalents; export the new surface; update any in-package `SyntaxToken` construction sites; write unit tests. Use rust-analyzer idioms when in doubt about API shape or helper implementation. Do **not** add fake-identifier reparsing, touch the language server, or change grammar/tokenizer. +- **Completed when:** + - Red tokens expose their parent node and previous/next-token traversal. + - `SyntaxNode.tokenAtOffset(offset)` exists, represents the between-two-tokens case, and offers left/right bias selectors; a covering-element lookup exists. + - Non-trivia skip/sibling/previous helpers exist over the new token navigation. + - New primitives are exported from `src/exports/syntax.ts` and covered by unit tests, including the between-tokens, zero-width-node, and EOF cases named in the slice spec. +- **Validation gates:** + - `pnpm --filter @prisma-next/psl-parser test` + - `pnpm --filter @prisma-next/psl-parser typecheck` + - `pnpm --filter @prisma-next/psl-parser lint` + - `pnpm --filter @prisma-next/psl-parser build` (refresh `dist/*.d.mts` consumed by the language server in the next slice) + - Workspace guard for the changed token shape: `pnpm --filter @prisma-next/language-server typecheck` (must still pass; flags any token-shape break in the existing consumer) + +## Dispatch-INVEST check + +- **Independent:** Lands as one PR-able parser change; no concurrent sibling work required. +- **Negotiable:** Outcome (navigable tokens + offset queries + trivia helpers) is pinned; class-vs-free-function and exact selector names are the implementer's discovery, guided by rust-analyzer idioms. +- **Valuable:** Removes the root cause (non-navigable tokens) behind the completion classifier's whole-tree scans; reusable by hover/go-to-definition later. +- **Estimable:** Completed-when checklist is binary and test-backed. +- **Small:** One package, one coherent capability, no consumer rewrites; the language-server typecheck is a guard, not a migration. +- **Testable:** `psl-parser` unit tests plus the package gates verify it. + +## Open items + +None. diff --git a/projects/lsp-autocomplete/slices/parser-red-tree-navigation/spec.md b/projects/lsp-autocomplete/slices/parser-red-tree-navigation/spec.md new file mode 100644 index 0000000000..14c96d0b9b --- /dev/null +++ b/projects/lsp-autocomplete/slices/parser-red-tree-navigation/spec.md @@ -0,0 +1,66 @@ +# Slice spec: parser-red-tree-navigation + +**Parent project:** `projects/lsp-autocomplete/spec.md` + +## At a glance + +The PSL parser red tree (`packages/1-framework/2-authoring/psl-parser/src/syntax/red.ts`) currently lets you walk *down and across nodes* (`children`, `childNodes`, `ancestors`, `firstChild`, `lastChild`, `nextSibling`, `prevSibling`, `descendants`, `tokens`) but tokens are dead ends: `wrapElement` returns a bare `SyntaxToken` of shape `{ kind, text, offset }` with no link back to its parent, and there is no offset-query entry point. As a result the language-server completion classifier reconstructs token context by scanning `root.tokens()` from the document root and by reasoning over raw source text. + +This slice adds the rust-analyzer-style navigation primitives that make a cursor position a first-class, locally-navigable thing, so downstream tooling (completion now; hover/go-to-definition later) can start from a token and walk outward instead of re-scanning the whole tree. + +## Chosen design + +Adopt rust-analyzer's `syntax` navigation idioms, with one explicit exception: we do **not** adopt the fake-identifier completion marker. Our parser is error-tolerant and macro-free, so an empty position already yields usable nodes. + +Concretely: + +- **Navigable tokens.** Give the red token a link to its parent `SyntaxNode` and previous/next-token traversal. Follow rust-analyzer, where `SyntaxToken` carries its parent and offers `prev_token()` / `next_token()`. The parent is already known inside `wrapElement` (it is currently discarded) — thread it through rather than recomputing. +- **Offset queries on `SyntaxNode`.** Add `tokenAtOffset(offset)` mirroring rust-analyzer's `TokenAtOffset` (the between-two-tokens case must be representable, with `leftBiased()` / `rightBiased()` selectors), and a covering-element lookup mirroring `covering_element(range)`. +- **Trivia-skipping helpers.** Provide the equivalents of rust-analyzer's `skip_trivia_token` / `non_trivia_sibling` / `previous_non_trivia_token`, expressed over the new token navigation. Trivia kinds are the existing `Whitespace` / `Newline` / `Comment`. +- **Export surface.** Re-export the new types/functions from `@prisma-next/psl-parser/syntax` (`src/exports/syntax.ts`), where `SyntaxNode` / `SyntaxToken` / `SyntaxElement` already live. + +Implementation shape (class vs free functions, exact selector names) is the implementer's call — follow rust-analyzer idioms where in doubt. + +## Coherence rationale + +One reviewer can hold this in a sitting: it is a single capability ("the red tree is cursor-navigable and offset-queryable") landing in one package with unit tests, no consumer rewrites. The completion rewrite that consumes it is a separate slice, so this slice's diff stays about the parser substrate only. + +## Scope + +**In:** + +- `packages/1-framework/2-authoring/psl-parser/src/syntax/red.ts` — token parent linkage, token prev/next traversal, `tokenAtOffset`, covering element. +- A trivia/navigation helper home (new file under `syntax/`, or additions to `ast-helpers.ts`/`red.ts` — implementer's call). +- `packages/1-framework/2-authoring/psl-parser/src/exports/syntax.ts` — export the new surface. +- Unit tests in `psl-parser` covering the new primitives. +- Any in-package construction sites of `SyntaxToken` updated for the new shape. + +**Deliberately out:** + +- Any change to `completion-context.ts` or the language server (that is the next slice). +- Fake-identifier / completion-marker reparsing. +- Grammar, tokenizer, or green-tree changes beyond what token-parent linkage strictly requires. +- Performance work beyond not regressing the existing single-pass cost. + +## Pre-investigated edge cases + +| Case | Note | +| --- | --- | +| Cursor between two tokens (e.g. `foo|bar` boundary, or at a token seam) | `tokenAtOffset` must represent the two-token case and offer left/right bias, like rust-analyzer's `TokenAtOffset::Between`. The completion slice depends on `leftBiased()`. | +| Zero-width / empty nodes | `containsOffset` already special-cases `textLength === 0`; covering-element and `tokenAtOffset` must stay consistent with that rule. | +| EOF / offset at end of file | Left-biased lookup must still return the final significant token. | + +## Slice-specific done conditions + +- A consumer can, from an offset, obtain the anchor token and walk to its parent node, previous/next token, and nearest non-trivia neighbor without scanning from the document root. + +## Open questions + +None. + +## References + +- `packages/1-framework/2-authoring/psl-parser/src/syntax/red.ts` — current red tree. +- `packages/1-framework/2-authoring/psl-parser/src/syntax/ast-helpers.ts` — existing `findChildToken` / `filterChildren` helpers. +- `packages/1-framework/2-authoring/psl-parser/src/exports/syntax.ts` — export barrel. +- rust-analyzer `crates/syntax` (`SyntaxToken`, `TokenAtOffset`, `algo::{skip_trivia_token, non_trivia_sibling, previous_non_trivia_token}`) — idiom reference. diff --git a/projects/lsp-autocomplete/slices/top-level-keyword-completions/dispatches/02-review-feedback-and-classifier-navigation.md b/projects/lsp-autocomplete/slices/top-level-keyword-completions/dispatches/02-review-feedback-and-classifier-navigation.md new file mode 100644 index 0000000000..f15d02835d --- /dev/null +++ b/projects/lsp-autocomplete/slices/top-level-keyword-completions/dispatches/02-review-feedback-and-classifier-navigation.md @@ -0,0 +1,50 @@ +# Brief: review-feedback-and-classifier-navigation + +## Task + +Address the open PR review feedback on PR #871 and refactor the PSL completion classifier so it uses the parser's existing red-tree / AST navigation primitives instead of repeatedly rediscovering context by traversing the whole tree. Preserve the already-delivered autocomplete semantics: model field type completions, namespace qualifier/member completions, generic block parameter completions, and declaration keyword completions with snippet/plain variants. + +## Scope + +**In:** `packages/1-framework/3-tooling/language-server/src/completion-context.ts`, its focused tests, and any parser syntax exports/helpers needed to use already-present navigation APIs cleanly; `packages/1-framework/3-tooling/language-server/src/server.ts` and tests if stale cached artifacts are still a real completion-path bug; `apps/lsp-playground/src/cli.ts` for the malformed `Host` / URL parsing review comment; small README/test updates only if necessary to keep docs truthful. + +**Out:** Changing completion product semantics, adding ordinary `@` / `@@` attribute completions, changing PSL parser grammar, changing SQL interpreter namespace semantics, replying to or resolving GitHub review threads, broad parser rewrites, and unrelated playground/server cleanup. + +## Completed when + +- [ ] Each unresolved review comment fetched from PR #871 is implemented or explicitly reported as not applicable in the dispatch wrap-up; do not post replies to GitHub. +- [ ] `completion-context.ts` uses existing red-tree / AST navigation methods for token/node context instead of avoidable repeated whole-tree scans, and removes unused / over-complicated helper state called out in review. +- [ ] Tests cover any behavior changed to refresh completion artifacts from the current buffer and preserve classifier behavior after the navigation refactor. +- [ ] Language-server and playground validation gates listed in the slice plan pass, or any failure is reported with the failing command and relevant error. + +## Standing instruction + +Stay focused on review feedback and classifier navigation quality. Trivial-and-related fixes that obviously serve the goal go in the same dispatch with a one-line note in your wrap-up message. Anything that pulls you into completion semantics redesign, namespace resolution policy, or parser grammar changes halts and surfaces. + +## References + +**Slice-loop dispatch:** + +- Slice spec: `projects/lsp-autocomplete/slices/top-level-keyword-completions/spec.md` — chosen design + coherence rationale + slice-DoD. +- Slice plan entry: `projects/lsp-autocomplete/slices/top-level-keyword-completions/plan.md` § Dispatch 2 — outcome / builds-on / hands-to / focus. +- Prior dispatch artifacts in this slice: `projects/lsp-autocomplete/slices/top-level-keyword-completions/dispatches/01-declaration-keyword-completions.md`. +- Project spec: `projects/lsp-autocomplete/spec.md` — especially the no completion-marker reparsing constraint and the requirement to use cached parser artifacts / red-tree cursor utilities. +- PR: https://github.com/prisma/prisma-next/pull/871. + +**Open review comments fetched before dispatch:** + +- `packages/1-framework/3-tooling/language-server/src/server.ts` line 299, CodeRabbit: refresh artifacts from current `document.getText()` before classifying completions if the cached artifact can be stale. +- `apps/lsp-playground/src/cli.ts` line 199, CodeRabbit: stop using incoming `Host` header as the URL parsing base; parse against a fixed local base or return 400 on parse failure. +- `packages/1-framework/3-tooling/language-server/src/completion-context.ts` line 27, SevInf: question whether that type/member is needed. +- `packages/1-framework/3-tooling/language-server/src/completion-context.ts` line 85, SevInf: property is not used anywhere. +- `packages/1-framework/3-tooling/language-server/src/completion-context.ts` line 100, SevInf: use `SyntaxNode` sibling navigation methods rather than reinventing sibling lookup. +- `packages/1-framework/3-tooling/language-server/src/completion-context.ts` line 638, SevInf: use binary search if `SyntaxNode` allows indexed children access. +- `packages/1-framework/3-tooling/language-server/src/completion-context.ts` line 643, SevInf: condition seems to only need “offset directly at end of current token”. +- `packages/1-framework/3-tooling/language-server/src/completion-context.ts` line 661, SevInf: simplify object construction; explicit `undefined` property values are acceptable. +- `packages/1-framework/3-tooling/language-server/src/completion-context.ts` line 677, SevInf: check whether parser module already has a helper for this. + +## Operational metadata + +- **Model tier:** mid — this is a focused code-quality refactor with tests across two packages, not a broad architecture redesign. +- **Time-box:** 90 minutes wall-clock. Overrun → halt and report exact remaining work. +- **Halt conditions:** Halt if the desired refactor requires changing parser grammar, if the completion semantic contract becomes ambiguous, if more than one parser package API must be redesigned, if validation exposes unrelated failures, or if you need to reply to GitHub comments. diff --git a/projects/lsp-autocomplete/slices/top-level-keyword-completions/plan.md b/projects/lsp-autocomplete/slices/top-level-keyword-completions/plan.md index 7c59f66fcd..70adec2311 100644 --- a/projects/lsp-autocomplete/slices/top-level-keyword-completions/plan.md +++ b/projects/lsp-autocomplete/slices/top-level-keyword-completions/plan.md @@ -22,6 +22,23 @@ - `pnpm --filter @prisma-next/language-server lint` - If parser exports or generated parser declarations are changed: `pnpm --filter @prisma-next/psl-parser build`, then rerun the language-server gates. +### Dispatch 2: review-feedback-and-classifier-navigation + +- **Outcome:** PR review feedback is addressed with the completion classifier refactored away from repeated whole-tree traversals and toward existing red-tree / AST navigation primitives. +- **Builds on:** Dispatch 1's completion classifier, provider, server wiring, tests, and README updates. +- **Hands to:** A review-cleaner autocomplete implementation where `completion-context.ts` uses existing parser navigation APIs for nearest tokens, siblings, ancestors, and source offsets; stale completion artifacts are refreshed from the current buffer; playground runtime endpoint path parsing is robust against malformed `Host` headers; and tests/validation cover the changed behavior. +- **Focus:** Inspect the parser red/green node and AST helper APIs before editing; write or adjust tests first where behavior changes; remove unused context fields and helper indirection called out in review; avoid replying to or resolving GitHub review threads; keep completion semantics unchanged except where tests expose stale-buffer or cursor-context bugs. +- **Completed when:** + - Review comments on `completion-context.ts`, `server.ts`, and `apps/lsp-playground/src/cli.ts` are either implemented or explicitly reported as not applicable in the dispatch wrap-up. + - `completion-context.ts` no longer does avoidable repeated whole-AST searches for current/previous context when existing node/token navigation APIs can answer the question locally. + - Relevant tests cover any stale-buffer completion refresh behavior and classifier behavior preserved by the refactor. +- **Validation gates:** + - `pnpm --filter @prisma-next/language-server test` + - `pnpm --filter @prisma-next/language-server typecheck` + - `pnpm --filter @prisma-next/language-server lint` + - `pnpm --filter @prisma-next/lsp-playground typecheck` + - `pnpm --filter @prisma-next/lsp-playground lint` + ## Dispatch-INVEST check - **Independent:** The dispatch produces a complete reviewable surface on top of the already-landed completion route and does not require a sibling slice to merge concurrently. From 48dcdd3f345e6d0103290927331c4735a3c05035 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Fri, 26 Jun 2026 17:56:50 +0000 Subject: [PATCH 11/54] docs(language-server,psl-parser): drop rust-analyzer references from code comments Signed-off-by: Serhii Tatarintsev --- .../psl-parser/src/syntax/navigation.ts | 11 ++++------ .../2-authoring/psl-parser/src/syntax/red.ts | 21 +++++++++---------- .../language-server/src/completion-context.ts | 10 ++++----- 3 files changed, 19 insertions(+), 23 deletions(-) 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 index 19e55a36f9..ccd6f25908 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/syntax/navigation.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/syntax/navigation.ts @@ -1,7 +1,7 @@ import type { TokenKind } from '../tokenizer'; import { type SyntaxElement, SyntaxToken } from './red'; -/** Direction of a sibling/token walk. Mirrors rust-analyzer's `Direction`. */ +/** Direction of a sibling/token walk. */ export type Direction = 'next' | 'prev'; const TRIVIA_KINDS: ReadonlySet = new Set([ @@ -22,8 +22,7 @@ export function isTrivia(token: SyntaxToken): boolean { /** * The first non-trivia token at or beyond `token` in `direction`. Returns - * `token` itself when it is already significant. Mirrors rust-analyzer's - * `algo::skip_trivia_token`. + * `token` itself when it is already significant. */ export function skipTriviaToken(token: SyntaxToken, direction: Direction): SyntaxToken | undefined { let current: SyntaxToken | undefined = token; @@ -35,8 +34,7 @@ export function skipTriviaToken(token: SyntaxToken, direction: Direction): Synta /** * The nearest sibling of `element` (within the same parent) in `direction` that - * is not a trivia token. Nodes are always significant. Mirrors rust-analyzer's - * `algo::non_trivia_sibling`. + * is not a trivia token. Nodes are always significant. */ export function nonTriviaSibling( element: SyntaxElement, @@ -54,8 +52,7 @@ export function nonTriviaSibling( /** * The nearest significant token strictly before `element` in document order, - * crossing node boundaries. Mirrors rust-analyzer's - * `algo::previous_non_trivia_token`. + * crossing node boundaries. */ export function previousNonTriviaToken(element: SyntaxElement): SyntaxToken | undefined { const start = element instanceof SyntaxToken ? element : element.firstToken; 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 9c218284df..36a08f9d13 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 @@ -7,7 +7,7 @@ import type { SyntaxKind } from './syntax-kind'; * 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. Mirrors rust-analyzer's `SyntaxToken`. + * from the document root. */ export class SyntaxToken implements Token { readonly green: GreenToken; @@ -60,12 +60,11 @@ export class SyntaxToken implements Token { export type SyntaxElement = SyntaxNode | SyntaxToken; /** - * The result of {@link SyntaxNode.tokenAtOffset}. Mirrors rust-analyzer's - * `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`. + * 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`. */ export class TokenAtOffset { readonly #left: SyntaxToken | undefined; @@ -221,10 +220,10 @@ export class SyntaxNode { } /** - * The smallest element fully containing the range `[start, end]`. Mirrors - * rust-analyzer's `covering_element`. At a seam (and for empty ranges) the - * left-hand element is preferred, matching {@link containsOffset}'s zero-width - * rule (`textLength === 0` ⇒ contains only the empty range at `start`). + * 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 zero-width rule (`textLength === 0` ⇒ contains only + * the empty range at `start`). */ coveringElement(start: number, end: number): SyntaxElement { let result: SyntaxElement = this; 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 index 659db7195f..0c240617f3 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-context.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-context.ts @@ -88,8 +88,8 @@ export function classifyPslCompletionContext( return unsupported(offset); } - // Anchor on the token left of the cursor (rust-analyzer's `original_token`) and - // navigate outward via `token.parent` rather than scanning the whole tree. + // Anchor on the token left of the cursor and navigate outward via + // `token.parent` rather than scanning the whole tree. const anchorNode = at.leftBiased()?.parent; const previousSignificantNode = tokenContext.previousSignificant?.parent; const contextNode = nodeForContext(anchorNode, previousSignificantNode); @@ -97,8 +97,8 @@ export function classifyPslCompletionContext( return unsupported(offset); } - // rust-analyzer's `source_range()`: the edit replaces the identifier under the - // cursor, or is empty when the cursor sits in trivia. + // The edit replaces the identifier under the cursor, or is empty when the + // cursor sits in trivia. const replacementStartOffset = sourceRangeStart(tokenContext, offset); const declarationKeywordContext = classifyDeclarationKeyword({ @@ -547,7 +547,7 @@ function tokenContextAt(at: TokenAtOffset, offset: number): TokenContext { return { current, previousSignificant, touching }; } -/** The identifier token the cursor is editing, if any (rust-analyzer's name ref). */ +/** The identifier token the cursor is editing, if any. */ function cursorIdentifier(tokenContext: TokenContext, offset: number): SyntaxToken | undefined { if (tokenContext.current?.kind === 'Ident') { return tokenContext.current; From 65410f3e30bccc9b12c1b470b3f21fe46bc0fad4 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Fri, 26 Jun 2026 18:07:42 +0000 Subject: [PATCH 12/54] refactor(language-server): derive qualified type-name prefix from the AST Replace the splitQualifiedPrefix source scan (which re-tokenized the qualified name by counting : and . in raw text) with QualifiedNameAst navigation: contract-space/namespace/name roles are resolved from colon()/dot() offsets and segment IdentifierAst offsets relative to the cursor. The only source touch left is slicing the cursor segments own identifier-token text. Deletes splitQualifiedPrefix, pathFromSegments, and segmentAt; behavior preserved, two new tests pin the colon-without-dot and cursor-mid-name cases. Signed-off-by: Serhii Tatarintsev --- .../language-server/src/completion-context.ts | 166 ++++++++++-------- .../test/completion-context.test.ts | 16 ++ .../completion-context-simplification/plan.md | 15 ++ 3 files changed, 119 insertions(+), 78 deletions(-) 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 index 0c240617f3..2f82a80edc 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-context.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-context.ts @@ -4,7 +4,9 @@ import { type DocumentAst, FieldAttributeAst, FieldDeclarationAst, + filterChildren, GenericBlockDeclarationAst, + IdentifierAst, isTrivia, KeyValuePairAst, ModelAttributeAst, @@ -134,7 +136,6 @@ export function classifyPslCompletionContext( return classifyModelFieldType({ field, offset, - source: input.sourceFile.text, replacementStartOffset, }); } @@ -142,7 +143,6 @@ export function classifyPslCompletionContext( function classifyModelFieldType(input: { readonly field: FieldDeclarationAst; readonly offset: number; - readonly source: string; readonly replacementStartOffset: number; }): PslCompletionContext { const fieldName = input.field.name(); @@ -197,7 +197,7 @@ function classifyModelFieldType(input: { return unsupported(input.offset); } - const prefix = typeNamePrefix(name, input.offset, input.source); + const prefix = typeNamePrefix(name, input.offset); if (prefix === undefined) { return unsupported(input.offset); } @@ -417,93 +417,103 @@ function hasUnsupportedAncestor(node: SyntaxNode | undefined): boolean { ); } -function typeNamePrefix( - name: QualifiedNameAst, - offset: number, - source: string, -): TypeNamePrefix | undefined { - const end = Math.min(offset, endOffset(name.syntax)); - const raw = splitQualifiedPrefix(source.slice(name.syntax.offset, end)); - if (raw.colonCount > 1 || raw.dotCount > 1) { +/** + * Derives the qualified type-name prefix purely from the {@link QualifiedNameAst} + * segments and its `:` / `.` separator tokens, relative to the cursor. A + * separator counts only when it lies before the cursor, so structure is decided + * by what the user has actually typed: in `space:auth.User` the namespace role + * appears only once the cursor passes the dot. The single source touch is + * slicing the cursor segment's own identifier-token text. + */ +function typeNamePrefix(name: QualifiedNameAst, offset: number): TypeNamePrefix | undefined { + const cursor = Math.min(offset, endOffset(name.syntax)); + const segments = Array.from(filterChildren(name.syntax, IdentifierAst.cast)); + + const colon = name.colon(); + const dot = name.dot(); + const colonOffset = colon !== undefined && colon.offset < cursor ? colon.offset : undefined; + const dotOffset = dot !== undefined && dot.offset < cursor ? dot.offset : undefined; + + const contractSpaceSegment = + colonOffset === undefined ? undefined : lastSegmentBefore(segments, colonOffset); + const namespaceSegment = + dotOffset === undefined ? undefined : lastSegmentBetween(segments, colonOffset, dotOffset); + const lastSeparatorOffset = dotOffset ?? colonOffset; + const nameSegment = firstSegmentAfter(segments, lastSeparatorOffset, cursor); + + const contractSpace = colonOffset === undefined ? undefined : contractSpaceSegment?.name(); + if (colonOffset !== undefined && (contractSpace === undefined || contractSpace.length === 0)) { return undefined; } - - if (raw.colonCount === 0 && raw.dotCount === 0) { - const nameSegment = segmentAt(raw.segments, 0); - if (nameSegment === undefined) return undefined; - return { path: pathFromSegments(raw.segments), name: nameSegment }; + const namespace = dotOffset === undefined ? undefined : namespaceSegment?.name(); + if (dotOffset !== undefined && (namespace === undefined || namespace.length === 0)) { + return undefined; } - if (raw.colonCount === 0 && raw.dotCount === 1) { - const namespace = segmentAt(raw.segments, 0); - const nameSegment = segmentAt(raw.segments, 1); - if (namespace === undefined || namespace.length === 0 || nameSegment === undefined) { - return undefined; - } - return { path: pathFromSegments(raw.segments), namespace, name: nameSegment }; - } + const nameText = nameSegment === undefined ? '' : segmentTextBeforeCursor(nameSegment, cursor); + const path = [contractSpace, namespace, nameText].filter( + (segment): segment is string => segment !== undefined && segment.length > 0, + ); - if (raw.colonCount === 1 && raw.dotCount === 0) { - const contractSpace = segmentAt(raw.segments, 0); - const nameSegment = segmentAt(raw.segments, 1); - if (contractSpace === undefined || contractSpace.length === 0 || nameSegment === undefined) { - return undefined; - } - return { path: pathFromSegments(raw.segments), contractSpace, name: nameSegment }; - } - - const contractSpace = segmentAt(raw.segments, 0); - const namespace = segmentAt(raw.segments, 1); - const nameSegment = segmentAt(raw.segments, 2); - if ( - contractSpace === undefined || - contractSpace.length === 0 || - namespace === undefined || - namespace.length === 0 || - nameSegment === undefined - ) { - return undefined; - } return { - path: pathFromSegments(raw.segments), - contractSpace, - namespace, - name: nameSegment, + path, + name: nameText, + ...(contractSpace === undefined ? {} : { contractSpace }), + ...(namespace === undefined ? {} : { namespace }), }; } -function splitQualifiedPrefix(text: string): { - readonly segments: readonly string[]; - readonly colonCount: number; - readonly dotCount: number; -} { - const segments = ['']; - let colonCount = 0; - let dotCount = 0; - for (let index = 0; index < text.length; index++) { - const char = text.charAt(index); - if (char === ':') { - colonCount++; - segments.push(''); - continue; - } - if (char === '.') { - dotCount++; - segments.push(''); - continue; - } - const lastIndex = segments.length - 1; - segments[lastIndex] = `${segments[lastIndex] ?? ''}${char}`; +/** The last segment that starts strictly before `boundary`. */ +function lastSegmentBefore( + segments: readonly IdentifierAst[], + boundary: number, +): IdentifierAst | undefined { + let found: IdentifierAst | undefined; + for (const segment of segments) { + if (segment.syntax.offset >= boundary) break; + found = segment; + } + return found; +} + +/** The last segment that starts after `lowerBound` (if any) and before `upperBound`. */ +function lastSegmentBetween( + segments: readonly IdentifierAst[], + lowerBound: number | undefined, + upperBound: number, +): IdentifierAst | undefined { + let found: IdentifierAst | undefined; + for (const segment of segments) { + const start = segment.syntax.offset; + if (start >= upperBound) break; + if (lowerBound !== undefined && start < lowerBound) continue; + found = segment; + } + return found; +} + +/** The first segment that starts after `boundary` and before the cursor. */ +function firstSegmentAfter( + segments: readonly IdentifierAst[], + boundary: number | undefined, + cursor: number, +): IdentifierAst | undefined { + for (const segment of segments) { + const start = segment.syntax.offset; + if (boundary !== undefined && start <= boundary) continue; + if (start >= cursor) continue; + return segment; } - return { segments, colonCount, dotCount }; -} - -function pathFromSegments(segments: readonly string[]): readonly string[] { - return segments.filter((segment) => segment.length > 0); + return undefined; } -function segmentAt(segments: readonly string[], index: number): string | undefined { - return segments[index]; +/** The cursor segment's identifier text, truncated at the cursor. */ +function segmentTextBeforeCursor(segment: IdentifierAst, cursor: number): string { + const token = segment.token(); + if (token === undefined) return ''; + const take = cursor - token.offset; + if (take <= 0) return ''; + return token.text.slice(0, Math.min(take, token.text.length)); } function nodeForContext( 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 index 2c368cc468..90e3b9d167 100644 --- 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 @@ -142,6 +142,22 @@ describe('classifyPslCompletionContext', () => { }); }); + it('classifies a contract-space-qualified prefix without a namespace segment', () => { + expect(classify(['model Post {', ' external supabase:U|', '}'].join('\n'))).toMatchObject({ + kind: 'modelFieldType', + fieldName: 'external', + prefix: { path: ['supabase', 'U'], contractSpace: 'supabase', name: 'U' }, + }); + }); + + it('truncates the cursor segment at the offset when the cursor sits mid-name', () => { + expect(classify(['model Post {', ' owner auth.Use|r', '}'].join('\n'))).toMatchObject({ + kind: 'modelFieldType', + fieldName: 'owner', + prefix: { path: ['auth', 'Use'], namespace: 'auth', name: 'Use' }, + }); + }); + 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')); diff --git a/projects/lsp-autocomplete/slices/completion-context-simplification/plan.md b/projects/lsp-autocomplete/slices/completion-context-simplification/plan.md index c5010b8746..e6f6446ffc 100644 --- a/projects/lsp-autocomplete/slices/completion-context-simplification/plan.md +++ b/projects/lsp-autocomplete/slices/completion-context-simplification/plan.md @@ -21,6 +21,21 @@ - `pnpm --filter @prisma-next/language-server typecheck` - `pnpm --filter @prisma-next/language-server lint` +### Dispatch 2: derive-type-name-prefix-from-ast + +- **Outcome:** `typeNamePrefix` derives the qualified type-name prefix (contract-space / namespace / name segments) from the `QualifiedNameAst` and segment token offsets instead of slicing raw source and re-scanning for `:` / `.`; `splitQualifiedPrefix` and its text-only helpers are deleted. +- **Builds on:** Dispatch 1's classifier and the parser's `QualifiedNameAst` API (`space()`, `namespace()`, `identifier()`, `path()`, `colon()`, `dot()`, `isOverQualified()`) plus segment `IdentifierAst` offsets. +- **Hands to:** A type-position classifier with no text re-tokenization of qualified names; the only source touch is the partial identifier segment under the cursor (one token's text truncated at the offset). +- **Focus:** Replace the `source.slice(...)` + `splitQualifiedPrefix` character scan with AST navigation over the qualified-name segments; determine which segments precede the cursor and which segment the cursor sits in from token ranges; read contract-space/namespace from `space()`/`namespace()` and the trailing-separator (`auth.|`, `space:|`) cases from the `dot()`/`colon()` tokens; keep the partial-segment text as a single-token slice. Preserve every existing qualified-prefix case. Do not change the parser or grammar. +- **Completed when:** + - `splitQualifiedPrefix`, `pathFromSegments`, `segmentAt`, and any raw-source separator scanning are gone from `completion-context.ts`. + - `typeNamePrefix` produces the same `TypeNamePrefix` results as today for bare `U`, `auth.`/`auth.U`, `space:`/`space:U`, `space:auth.`/`space:auth.U`, and over-qualified (rejected) inputs, sourced from the AST. + - The full language-server test suite passes; tests are extended where a case was only implicitly covered. +- **Validation gates:** + - `pnpm --filter @prisma-next/language-server test` + - `pnpm --filter @prisma-next/language-server typecheck` + - `pnpm --filter @prisma-next/language-server lint` + ## Dispatch-INVEST check - **Independent:** Single-file rewrite once slice A has landed; no concurrent work. From 4f5a36b1d6c390374640a8918b357e6036d47478 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 29 Jun 2026 08:52:30 +0000 Subject: [PATCH 13/54] style(language-server): sort merged server imports after rebase Order the Position/Range/SemanticTokens type imports merged during the rebase against main (semantic tokens + completion landed in parallel). Signed-off-by: Serhii Tatarintsev --- packages/1-framework/3-tooling/language-server/src/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 dbc04a784b..946e7477dc 100644 --- a/packages/1-framework/3-tooling/language-server/src/server.ts +++ b/packages/1-framework/3-tooling/language-server/src/server.ts @@ -12,8 +12,8 @@ import { type FoldingRange, type InitializeParams, type InitializeResult, - type Range, type Position, + type Range, RegistrationRequest, type SemanticTokens, TextDocumentSyncKind, From 94920d9f325d199411dcc7a88c41e1342fe5bc9f Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 29 Jun 2026 11:59:33 +0000 Subject: [PATCH 14/54] refactor(language-server): drop trailing dot from namespace completion The namespace-qualifier candidate now inserts the bare namespace name (e.g. `auth`) instead of `auth.`; label/insertText/filterText all use the bare name. Member completion still works once the user types the dot. Signed-off-by: Serhii Tatarintsev --- .../3-tooling/language-server/src/completion-provider.ts | 7 +++---- .../language-server/test/completion-provider.test.ts | 8 ++++---- 2 files changed, 7 insertions(+), 8 deletions(-) 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 index be27fa688b..c2159051f1 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-provider.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-provider.ts @@ -368,12 +368,11 @@ function namespaceCandidates( } function namespaceQualifierCandidate(namespace: NamespaceSymbol): ModelTypeCompletionCandidate { - const qualifier = `${namespace.name}.`; return { category: 'namespace', - label: qualifier, - insertText: qualifier, - filterText: qualifier, + label: namespace.name, + insertText: namespace.name, + filterText: namespace.name, detail: 'Namespace', kind: CompletionItemKind.Module, }; 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 index dcd3b0adb3..4e590eea71 100644 --- 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 @@ -193,7 +193,7 @@ describe('providePslCompletionItems', () => { 'Address', 'Email', 'UserId', - 'auth.', + 'auth', ]); expect(items.map((item) => item.detail)).toEqual([ 'Configured scalar type', @@ -227,17 +227,17 @@ describe('providePslCompletionItems', () => { ['model Post {', ' reviewer a|', '}'].join('\n'), ); - expect(items.map((item) => item.label)).toEqual(['auth.']); + expect(items.map((item) => item.label)).toEqual(['auth']); expect(items[0]).toMatchObject({ kind: CompletionItemKind.Module, detail: 'Namespace', - filterText: 'auth.', + filterText: 'auth', textEdit: { range: { start: sourceFile.positionAt(cursorOffset - 'a'.length), end: sourceFile.positionAt(cursorOffset), }, - newText: 'auth.', + newText: 'auth', }, }); }); From d575ed794aa81f61d269ec9a4b20c5afa27e02b4 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 29 Jun 2026 12:43:33 +0000 Subject: [PATCH 15/54] chore(drive): drop transient lsp-autocomplete project artifacts These planning/dispatch/spec/trace files were working artifacts of the Drive workflow and do not belong in the shipped PR. Local copies are retained outside version control. Signed-off-by: Serhii Tatarintsev --- projects/lsp-autocomplete/plan.md | 62 ---------- .../completion-context-simplification/plan.md | 50 -------- .../completion-context-simplification/spec.md | 59 --------- .../01-cursor-classifier-substrate.md | 72 ----------- .../dispatches/02-model-type-provider.md | 70 ----------- .../dispatches/03-lsp-completion-route.md | 75 ------------ .../04-scope-docs-and-final-guardrails.md | 70 ----------- .../slices/model-type-completions/plan.md | 82 ------------- .../slices/model-type-completions/spec.md | 106 ---------------- .../slices/parser-red-tree-navigation/plan.md | 37 ------ .../slices/parser-red-tree-navigation/spec.md | 66 ---------- .../01-declaration-keyword-completions.md | 73 ----------- ...view-feedback-and-classifier-navigation.md | 50 -------- .../top-level-keyword-completions/plan.md | 53 -------- .../top-level-keyword-completions/spec.md | 99 --------------- projects/lsp-autocomplete/spec.md | 113 ------------------ projects/lsp-autocomplete/trace.jsonl | 37 ------ 17 files changed, 1174 deletions(-) delete mode 100644 projects/lsp-autocomplete/plan.md delete mode 100644 projects/lsp-autocomplete/slices/completion-context-simplification/plan.md delete mode 100644 projects/lsp-autocomplete/slices/completion-context-simplification/spec.md delete mode 100644 projects/lsp-autocomplete/slices/model-type-completions/dispatches/01-cursor-classifier-substrate.md delete mode 100644 projects/lsp-autocomplete/slices/model-type-completions/dispatches/02-model-type-provider.md delete mode 100644 projects/lsp-autocomplete/slices/model-type-completions/dispatches/03-lsp-completion-route.md delete mode 100644 projects/lsp-autocomplete/slices/model-type-completions/dispatches/04-scope-docs-and-final-guardrails.md delete mode 100644 projects/lsp-autocomplete/slices/model-type-completions/plan.md delete mode 100644 projects/lsp-autocomplete/slices/model-type-completions/spec.md delete mode 100644 projects/lsp-autocomplete/slices/parser-red-tree-navigation/plan.md delete mode 100644 projects/lsp-autocomplete/slices/parser-red-tree-navigation/spec.md delete mode 100644 projects/lsp-autocomplete/slices/top-level-keyword-completions/dispatches/01-declaration-keyword-completions.md delete mode 100644 projects/lsp-autocomplete/slices/top-level-keyword-completions/dispatches/02-review-feedback-and-classifier-navigation.md delete mode 100644 projects/lsp-autocomplete/slices/top-level-keyword-completions/plan.md delete mode 100644 projects/lsp-autocomplete/slices/top-level-keyword-completions/spec.md delete mode 100644 projects/lsp-autocomplete/spec.md delete mode 100644 projects/lsp-autocomplete/trace.jsonl diff --git a/projects/lsp-autocomplete/plan.md b/projects/lsp-autocomplete/plan.md deleted file mode 100644 index 71759e2972..0000000000 --- a/projects/lsp-autocomplete/plan.md +++ /dev/null @@ -1,62 +0,0 @@ -# lsp-autocomplete — Plan - -**Spec:** `projects/lsp-autocomplete/spec.md` -**Linear Project:** [Language Tools Support Prisma Next PSL](https://linear.app/prisma-company/project/language-tools-support-prisma-next-psl-3422a7e44b9c) - -## At a glance - -This project is a three-slice stack. Slice 1 lands the completion request path, shared cursor-context classifier, and first model-type provider; slice 2 reuses that hand-off to add descriptor-backed generic block entry/parameter completions; slice 3 extends the same classifier/provider seam with declaration keyword completions, including namespace-body filtering and snippet/plain insertion behavior. - -## Composition - -### Stack (deliver in order) - -1. **Slice `model-type-completions`** — Linear: [TML-2946](https://linear.app/prisma-company/issue/TML-2946/lsp-autocomplete-model-type-completions) - - **Outcome:** Configured PSL inputs answer `textDocument/completion` at model field type positions, including bare, namespace-qualified, and contract-space-qualified prefixes such as `User`, `auth.User`, and `supabase:auth.User`. - - **Builds on:** Existing language-server diagnostics/formatting ownership, cached `DocumentAst` / `SourceFile` / `SymbolTable` project artifacts, and the parser's recovered `TypeAnnotation` / `QualifiedName` CST shape. - - **Hands to:** A tested completion capability, request handler, cursor-context classifier, explicit provider dispatch shape, and model-type provider that unsupported contexts can safely bypass. - - **Focus:** LSP completion wiring for configured PSL documents, pure cursor classification over existing parse artifacts, symbol-table-backed model field type suggestions, namespace-qualified type positions, and guardrails that keep ordinary `@` / `@@` attribute contexts empty. - -2. **Slice `generic-block-completions`** — Linear: [TML-2945](https://linear.app/prisma-company/issue/TML-2945/lsp-autocomplete-generic-block-entry-completions) - - **Outcome:** Configured PSL inputs answer `textDocument/completion` inside descriptor-backed generic PSL blocks for blank entry positions and partial keys/parameters on the current `GenericBlockDeclaration`. - - **Builds on:** Slice `model-type-completions`' completion handler, cursor-context classifier, provider dispatch shape, unsupported-context behavior, and access to the language-server control stack. - - **Hands to:** The scoped first autocomplete surface described by the original project spec: model field type completions plus generic block entry/parameter completions, with ordinary PSL attributes still out of scope. - - **Focus:** Using `pslBlockDescriptors`, `GenericBlockDeclarationAst`, `KeyValuePairAst`, and reconstructed block symbols to produce descriptor-backed generic block suggestions; documentation for supported and excluded completion contexts. - -3. **Slice `top-level-keyword-completions`** — Linear: [TML-2947](https://linear.app/prisma-company/issue/TML-2947/lsp-autocomplete-top-level-keyword-completions) - - **Outcome:** Configured PSL inputs answer `textDocument/completion` at document-level and namespace-body declaration keyword positions, with native PSL block keywords, descriptor-backed generic block keywords, and snippet/plain insertion variants gated by client capability. - - **Builds on:** The established completion handler, cursor-context classifier, provider dispatch shape, generic block descriptor candidate source, and namespace-aware completion behavior already present on the branch. - - **Hands to:** The expanded first autocomplete surface now includes model field type completions, generic block entry/parameter completions, and declaration keyword completions without adding ordinary PSL attribute completions. - - **Focus:** Declaration-position classification; document-vs-namespace native keyword sets; descriptor-backed generic block keyword suggestions; snippet support derived from LSP initialize capabilities; plain-text fallbacks for non-snippet clients; README and guardrail tests. - -4. **Slice `parser-red-tree-navigation`** — Linear: TML-2948 (pending) - - **Outcome:** The PSL parser's red tree exposes rust-analyzer-style cursor navigation: navigable tokens (parent + previous/next), `SyntaxNode.tokenAtOffset`, covering-element lookup, and trivia-skipping helpers, exported from `@prisma-next/psl-parser/syntax`. - - **Builds on:** The existing red/green tree (`syntax/red.ts`), AST wrappers, and `tokens()`/`children()`/`ancestors()` iteration already present. - - **Hands to:** A navigation API the completion classifier (and future hover/go-to-definition) can drive instead of re-scanning the whole document, removing the need for source-text whitespace/line scans. - - **Focus:** Make red tokens carry their parent and support previous/next traversal; add offset-query entry points (`tokenAtOffset`, covering element) and non-trivia skip helpers; mirror rust-analyzer idioms (except the fake-identifier completion marker, which we deliberately do not adopt); unit tests in `psl-parser`. - -5. **Slice `completion-context-simplification`** — Linear: TML-2949 (pending) - - **Outcome:** `completion-context.ts` classifies from a single anchor token via parent/ancestor dispatch with one unified replacement-range helper, deleting the bespoke whole-tree token plumbing (`findCursorContext`, located-element bookkeeping, `tokensBetween`, `lineStartOffsetFromTokens`, `containsOnlyWhitespaceTokens`). - - **Builds on:** Slice `parser-red-tree-navigation`'s token-navigation API; the existing completion classifier tests as the behavior guardrail. - - **Hands to:** A smaller, rust-analyzer-shaped completion classifier with identical behavior, validated by the existing language-server test suite. - - **Focus:** Anchor on `tokenAtOffset(offset).leftBiased()`; classify by the parent/ancestor AST type in one dispatch; derive the edit range once from the anchor token; preserve completion semantics; no fake-identifier reparsing. - -### Parallel groups - -None. All slices touch the same language-server completion route and classifier. The generic block and declaration keyword providers are simpler and safer once the model-type slice has established provider dispatch, unsupported-context semantics, and server request wiring. - -Slices 4 and 5 are a stack: `completion-context-simplification` builds directly on `parser-red-tree-navigation`'s hand-off and must not start until the navigation API is landed. - -## Dependencies (external) - -- [x] Linear Project exists — [Language Tools Support Prisma Next PSL](https://linear.app/prisma-company/project/language-tools-support-prisma-next-psl-3422a7e44b9c), Terminal team. -- [x] Existing LSP scaffold exists — diagnostics and formatting already run through `packages/1-framework/3-tooling/language-server/src/server.ts` and cache project artifacts in `project-artifacts.ts`. -- [x] Existing parser/symbol substrate exists — `parseTypeAnnotation()` uses `QualifiedName`, `SourceFile` maps offsets, red-tree nodes expose offsets/parents/ancestors/tokens, and `buildSymbolTable()` exposes model/composite/scalar/type-alias/block symbols. -- [x] Existing generic block descriptor path exists — `pslBlockDescriptors` is resolved into the language-server control stack and `BlockSymbol.block` is reconstructed from `GenericBlockDeclarationAst`. -- [x] Linear slice issue exists for declaration keyword completion — [TML-2947](https://linear.app/prisma-company/issue/TML-2947/lsp-autocomplete-top-level-keyword-completions), Terminal team. - -## Sequencing rationale - -The only real dependency is the shared completion route and cursor-context classifier. Model type completions are the narrower end-to-end slice because they depend on existing `TypeAnnotation` / `QualifiedName` and symbol-table shapes but do not need descriptor-specific block semantics. Generic block completions then consume the established request path and classifier, adding descriptor-backed context handling without reopening the LSP wiring decision. Declaration keyword completions build last because they reuse both the classifier/provider dispatch seam and the generic block descriptor candidate source while adding LSP snippet capability threading. - -The slices are not parallelized because they would otherwise make independent edits to the same provider routing and unsupported-context behavior. Keeping them stacked avoids duplicate classifier designs and makes each later review about one syntactic domain rather than LSP plumbing. diff --git a/projects/lsp-autocomplete/slices/completion-context-simplification/plan.md b/projects/lsp-autocomplete/slices/completion-context-simplification/plan.md deleted file mode 100644 index e6f6446ffc..0000000000 --- a/projects/lsp-autocomplete/slices/completion-context-simplification/plan.md +++ /dev/null @@ -1,50 +0,0 @@ -# Slice plan: completion-context-simplification - -**Spec:** `projects/lsp-autocomplete/slices/completion-context-simplification/spec.md` -**Parent project:** `projects/lsp-autocomplete/spec.md` -**Depends on:** `projects/lsp-autocomplete/slices/parser-red-tree-navigation` - -## Dispatch plan - -### Dispatch 1: rewrite-classifier-on-navigation-api - -- **Outcome:** `completion-context.ts` classifies from a single anchor token via parent/ancestor dispatch with one unified replacement-range helper, with the bespoke whole-tree token plumbing deleted and all existing language-server tests passing with unchanged behavior. -- **Builds on:** Slice `parser-red-tree-navigation`'s token-navigation API (`tokenAtOffset` + left/right bias, navigable tokens, non-trivia helpers). -- **Hands to:** A smaller, rust-analyzer-shaped completion classifier ready for PR review on the existing branch. -- **Focus:** Anchor on `tokenAtOffset(offset).leftBiased()` and navigate from `token.parent`; collapse the three `classifyX` re-derivations toward one parent/ancestor dispatch (using `closestAst` as the `match_ast!` analog); derive the edit range once from the anchor token like rust-analyzer's `source_range()`; delete `findCursorContext`, located-element/token bookkeeping, `tokensBetween`, `lineStartOffsetFromTokens`, `containsOnlyWhitespaceTokens`, and the duplicated prefix logic; re-express whitespace/line checks via the slice-A non-trivia helpers. Preserve completion semantics. Use rust-analyzer idioms when in doubt. Do **not** change the parser, add fake-identifier reparsing, or alter the already-landed server/playground changes. -- **Completed when:** - - `completion-context.ts` no longer contains `findCursorContext`, `tokensBetween`, `lineStartOffsetFromTokens`, or `containsOnlyWhitespaceTokens`, and computes the edit range in one place. - - Classification anchors on the navigation API rather than scanning `root.tokens()` from the document root. - - The full language-server test suite passes with behavior unchanged; tests change only where they asserted removed internals. -- **Validation gates:** - - `pnpm --filter @prisma-next/language-server test` - - `pnpm --filter @prisma-next/language-server typecheck` - - `pnpm --filter @prisma-next/language-server lint` - -### Dispatch 2: derive-type-name-prefix-from-ast - -- **Outcome:** `typeNamePrefix` derives the qualified type-name prefix (contract-space / namespace / name segments) from the `QualifiedNameAst` and segment token offsets instead of slicing raw source and re-scanning for `:` / `.`; `splitQualifiedPrefix` and its text-only helpers are deleted. -- **Builds on:** Dispatch 1's classifier and the parser's `QualifiedNameAst` API (`space()`, `namespace()`, `identifier()`, `path()`, `colon()`, `dot()`, `isOverQualified()`) plus segment `IdentifierAst` offsets. -- **Hands to:** A type-position classifier with no text re-tokenization of qualified names; the only source touch is the partial identifier segment under the cursor (one token's text truncated at the offset). -- **Focus:** Replace the `source.slice(...)` + `splitQualifiedPrefix` character scan with AST navigation over the qualified-name segments; determine which segments precede the cursor and which segment the cursor sits in from token ranges; read contract-space/namespace from `space()`/`namespace()` and the trailing-separator (`auth.|`, `space:|`) cases from the `dot()`/`colon()` tokens; keep the partial-segment text as a single-token slice. Preserve every existing qualified-prefix case. Do not change the parser or grammar. -- **Completed when:** - - `splitQualifiedPrefix`, `pathFromSegments`, `segmentAt`, and any raw-source separator scanning are gone from `completion-context.ts`. - - `typeNamePrefix` produces the same `TypeNamePrefix` results as today for bare `U`, `auth.`/`auth.U`, `space:`/`space:U`, `space:auth.`/`space:auth.U`, and over-qualified (rejected) inputs, sourced from the AST. - - The full language-server test suite passes; tests are extended where a case was only implicitly covered. -- **Validation gates:** - - `pnpm --filter @prisma-next/language-server test` - - `pnpm --filter @prisma-next/language-server typecheck` - - `pnpm --filter @prisma-next/language-server lint` - -## Dispatch-INVEST check - -- **Independent:** Single-file rewrite once slice A has landed; no concurrent work. -- **Negotiable:** Outcome (anchor-token + parent dispatch + unified range, bespoke plumbing gone) is pinned; exact dispatch structure is the implementer's discovery against rust-analyzer idioms. -- **Valuable:** Delivers the simplification the operator approved; removes the workaround code root-caused in slice A. -- **Estimable:** Completed-when is binary and test-backed. -- **Small:** One file, one package, behavior fixed by existing tests. -- **Testable:** The language-server suite plus package gates verify it. - -## Open items - -None. diff --git a/projects/lsp-autocomplete/slices/completion-context-simplification/spec.md b/projects/lsp-autocomplete/slices/completion-context-simplification/spec.md deleted file mode 100644 index ba8d817696..0000000000 --- a/projects/lsp-autocomplete/slices/completion-context-simplification/spec.md +++ /dev/null @@ -1,59 +0,0 @@ -# Slice spec: completion-context-simplification - -**Parent project:** `projects/lsp-autocomplete/spec.md` -**Depends on:** `projects/lsp-autocomplete/slices/parser-red-tree-navigation` (its token-navigation API) - -## At a glance - -`packages/1-framework/3-tooling/language-server/src/completion-context.ts` currently reconstructs cursor context the hard way: `findCursorContext` scans `root.tokens()` from the document root, and helpers (`tokensBetween`, `lineStartOffsetFromTokens`, `containsOnlyWhitespaceTokens`) re-scan tokens or reason over offsets to answer "what is left of the cursor" and "is there only indentation here". rust-analyzer answers the same questions by anchoring on one token and walking the tree: `token_at_offset(offset).left_biased()`, then `token.parent()`, then a single `match_ast!` dispatch over the parent/ancestor type, with the edit range derived once from the anchor token (`source_range()`). - -This slice rewrites the classifier onto the navigation API delivered by the `parser-red-tree-navigation` slice, matching rust-analyzer's shape. Behavior is unchanged; the existing classifier/provider/server tests are the guardrail. - -## Chosen design - -- **Single anchor token.** Replace `findCursorContext`'s from-root scan with `tokenAtOffset(offset).leftBiased()` and navigate from the token's parent. Mirror rust-analyzer's `original_token` + `token.parent()` entry. -- **Parent/ancestor dispatch.** Collapse the independent re-derivations in `classifyDeclarationKeyword` / `classifyGenericBlockParameter` / `classifyModelFieldType` toward one dispatch keyed on the anchor's parent/ancestor AST type, mirroring `classify_name_ref`'s `match_ast!`. The existing `closestAst(node, …Ast.cast)` is our `match_ast!` analog. -- **One replacement-range helper.** Derive the edit range once from the anchor token — identifier/keyword token ⇒ its range; otherwise an empty range at the cursor — mirroring `source_range()`. Remove the three separate prefix-slice computations. -- **Trivia/line checks via navigation.** Replace `containsOnlyWhitespaceTokens` / `lineStartOffsetFromTokens` / `tokensBetween` with the slice-A non-trivia/sibling/previous-token helpers. -- **No fake identifier.** Keep relying on the error-tolerant single parse. - -## Coherence rationale - -One file rewrite in one package, with the existing language-server test suite (completion-context, completion-provider, server) holding behavior fixed. One reviewer can confirm "same behavior, rust-analyzer shape, bespoke plumbing deleted" in one sitting. - -## Scope - -**In:** - -- `packages/1-framework/3-tooling/language-server/src/completion-context.ts` — rewrite onto the navigation API; delete `findCursorContext`, located-element/token bookkeeping, `tokensBetween`, `lineStartOffsetFromTokens`, `containsOnlyWhitespaceTokens`, and the duplicated prefix/replacement-range logic. -- `completion-provider.ts` only if the context's public shape changes (e.g. a unified replacement range now travels on the context). -- Existing language-server tests updated only where they assert removed internals; behavior assertions must stay. - -**Deliberately out:** - -- New completion *features* or semantics (model/namespace/generic-block/declaration-keyword behavior is fixed). -- Parser changes (those are slice A; if a gap appears, halt and route back, do not patch the parser here). -- Fake-identifier reparsing. -- Server stale-artifact refresh and playground changes already landed earlier on the branch. - -## Pre-investigated edge cases - -| Case | Note | -| --- | --- | -| Empty type position `author |` | Must remain a `modelFieldType` context; previously relied on `containsOnlyWhitespaceTokens`. Re-express via non-trivia navigation, not text scan. | -| `author\n |` and `author // |` | Must remain unsupported; the previous-non-trivia-token / newline-trivia distinction now comes from navigation helpers. | -| Declaration keyword after another token on the same line (`model User {} mo|`) | Must remain unsupported; comes from the previous-non-trivia-token, not a line scan. | - -## Slice-specific done conditions - -- The bespoke whole-tree token plumbing named above is gone, and the full language-server test suite passes unchanged in behavior. - -## Open questions - -None. - -## References - -- `packages/1-framework/3-tooling/language-server/src/completion-context.ts` — current classifier. -- `packages/1-framework/3-tooling/language-server/test/completion-context.test.ts` — behavior guardrail. -- rust-analyzer `crates/ide-completion/src/context.rs` (`source_range`, `original_token`) and `context/analysis.rs` (`classify_name_ref`, `match_ast!`) — idiom reference. diff --git a/projects/lsp-autocomplete/slices/model-type-completions/dispatches/01-cursor-classifier-substrate.md b/projects/lsp-autocomplete/slices/model-type-completions/dispatches/01-cursor-classifier-substrate.md deleted file mode 100644 index b981ed9faf..0000000000 --- a/projects/lsp-autocomplete/slices/model-type-completions/dispatches/01-cursor-classifier-substrate.md +++ /dev/null @@ -1,72 +0,0 @@ -# Brief: cursor-classifier-substrate - -## Task - -Add the test-first cursor-classifier substrate for PSL completion in `@prisma-next/language-server`. The classifier must be pure and must identify model field type completion contexts from the existing cached parser artifacts (`DocumentAst`, `SourceFile`, red-tree syntax, AST ancestors) while returning an unsupported context for everything outside slice-1 scope. - -## Scope - -**In:** - -- New language-server classifier module and focused tests. -- Offset conversion with `SourceFile.offsetAt(position)`. -- Touching/current/previous token or node lookup implemented locally in the language-server package using existing red-tree traversal APIs. -- Model field type context detection for blank type positions, partial bare types, namespace-qualified prefixes, and contract-space-qualified prefixes. -- Unsupported classification for ordinary `@` / `@@` attributes, attribute arguments, generic blocks, comments/trivia, constructor argument positions, unconfigured provider contexts, and invalid over-qualified names that are not valid type-prefix contexts. - -**Out:** - -- LSP `completionProvider` advertisement and `connection.onCompletion(...)` handler. -- Completion item generation and symbol-table candidate extraction. -- Generic block entry/parameter completions. -- Ordinary PSL attribute completions or attribute argument completions. -- Relation-aware completions. -- Completion-marker reparsing. -- Parser behavior changes or new public parser exports unless you halt and surface evidence that local language-server traversal cannot work. - -## Completed when - -- [ ] Classifier tests cover blank type, partial bare type, namespace-qualified prefix, contract-space-qualified prefix, comments/trivia, ordinary `@` / `@@` attributes, attribute arguments, generic block contexts, and unsupported contexts. -- [ ] The classifier exposes a typed context union that later dispatches can route to a model-type provider or `[]`. -- [ ] No completion-marker reparsing, parser behavior changes, or public parser exports are introduced. -- [ ] Validation gates pass or any blocker is surfaced with concrete evidence. - -## Standing instruction - -Stay focused on the goal; control scope. Trivial-and-related fixes that obviously serve the goal go in the same dispatch with a one-line note in your wrap-up message. Anything that pulls you off the goal — even if it looks useful — halts and surfaces. - -## References - -**Slice-loop dispatch:** - -- Slice spec: `projects/lsp-autocomplete/slices/model-type-completions/spec.md` -- Slice plan entry: `projects/lsp-autocomplete/slices/model-type-completions/plan.md` § Dispatch 1: cursor-classifier-substrate -- Project spec / plan: `projects/lsp-autocomplete/spec.md`, `projects/lsp-autocomplete/plan.md` -- Code review log: `projects/lsp-autocomplete/reviews/code-review.md` -- Calibration: `drive/calibration/sizing.md` § Dispatch INVEST — specialised for this repo -- Relevant code surfaces from prior grounding: - - `packages/1-framework/3-tooling/language-server/src/server.ts` - - `packages/1-framework/3-tooling/language-server/src/project-artifacts.ts` - - `packages/1-framework/3-tooling/language-server/src/pipeline.ts` - - `packages/1-framework/3-tooling/language-server/test/server.test.ts` - - `packages/1-framework/2-authoring/psl-parser/src/source-file.ts` - - `packages/1-framework/2-authoring/psl-parser/src/syntax/red.ts` - - `packages/1-framework/2-authoring/psl-parser/src/syntax/ast/declarations.ts` - - `packages/1-framework/2-authoring/psl-parser/src/syntax/ast/type-annotation.ts` - - `packages/1-framework/2-authoring/psl-parser/src/syntax/ast/qualified-name.ts` - -## Operational metadata - -- **Model tier:** `implementer/fast` — routine code edits within a slice; persistent across rounds in this slice. -- **Time-box:** 90 minutes wall clock. Overrun means halt and surface current state rather than broadening scope. -- **Halt conditions:** - - A parser behavior change or new public parser export appears necessary. - - Completing the classifier requires touching LSP request handling, completion item generation, generic block provider behavior, ordinary attribute completion, or relation-aware completion. - - Existing parser recovery cannot represent one of the required contexts with current red-tree/AST artifacts. - - Validation gates fail for reasons that look unrelated to this dispatch. - -## Validation gates - -- `pnpm --filter @prisma-next/language-server test` -- `pnpm --filter @prisma-next/language-server typecheck` -- `pnpm --filter @prisma-next/language-server lint` diff --git a/projects/lsp-autocomplete/slices/model-type-completions/dispatches/02-model-type-provider.md b/projects/lsp-autocomplete/slices/model-type-completions/dispatches/02-model-type-provider.md deleted file mode 100644 index 9d77b7ac80..0000000000 --- a/projects/lsp-autocomplete/slices/model-type-completions/dispatches/02-model-type-provider.md +++ /dev/null @@ -1,70 +0,0 @@ -# Brief: model-type-provider - -## Task - -Add the model field type completion provider and explicit completion dispatch entry point in `@prisma-next/language-server`. The provider consumes Dispatch 1’s typed classifier output and returns stable LSP completion items for configured scalar types plus visible type-like symbols from the current `SymbolTable`. Unsupported contexts return `[]`. - -## Scope - -**In:** - -- Provider/dispatcher module and focused tests in the language-server package. -- Candidate extraction from configured scalar types and the current symbol table. -- Candidate categories: configured scalars, top-level models, composite types, scalars, type aliases, namespace models, and namespace composite types. -- Prefix filtering and replacement metadata for bare, namespace-qualified, and contract-space-qualified model field type contexts already classified by Dispatch 1. -- Stable ordering that tests can pin. -- Explicit empty-list behavior for unsupported contexts. - -**Out:** - -- LSP `completionProvider` advertisement and `connection.onCompletion(...)` handler. -- Server/open-document configured-input gating. -- Generic block entry/parameter completions. -- Ordinary PSL `@` / `@@` attribute completions or attribute argument completions. -- Relation-aware completions. -- Creating a new external contract-space symbol index. -- Parser behavior changes, parser public exports, or completion-marker reparsing. - -## Completed when - -- [ ] Provider tests cover bare model field type completion candidates. -- [ ] Provider tests cover namespace-qualified prefixes and contract-space-qualified syntax positions using visible candidate data. -- [ ] Provider tests prove unsupported classifier contexts return `[]`. -- [ ] Provider tests prove generic block symbols are not returned as model field type candidates. -- [ ] The dispatcher/candidate-source seam is ready for the server route in Dispatch 3 without adding LSP request handling in this dispatch. -- [ ] Validation gates pass or any blocker is surfaced with concrete evidence. - -## Standing instruction - -Stay focused on the goal; control scope. Trivial-and-related fixes that obviously serve the goal go in the same dispatch with a one-line note in your wrap-up message. Anything that pulls you off the goal — even if it looks useful — halts and surfaces. - -## References - -**Slice-loop dispatch:** - -- Slice spec: `projects/lsp-autocomplete/slices/model-type-completions/spec.md` -- Slice plan entry: `projects/lsp-autocomplete/slices/model-type-completions/plan.md` § Dispatch 2: model-type-provider -- Dispatch 1 hand-off: `packages/1-framework/3-tooling/language-server/src/completion-context.ts` and `packages/1-framework/3-tooling/language-server/test/completion-context.test.ts` -- Project spec / plan: `projects/lsp-autocomplete/spec.md`, `projects/lsp-autocomplete/plan.md` -- Code review log: `projects/lsp-autocomplete/reviews/code-review.md` -- Relevant code surfaces: - - `packages/1-framework/3-tooling/language-server/src/project-artifacts.ts` - - `packages/1-framework/3-tooling/language-server/src/pipeline.ts` - - `packages/1-framework/2-authoring/psl-parser/src/symbol-table.ts` - - `packages/1-framework/2-authoring/psl-parser/test/symbol-table.test.ts` - -## Operational metadata - -- **Model tier:** `implementer/fast` — routine code edits within a slice; persistent across rounds in this slice. -- **Time-box:** 90 minutes wall clock. Overrun means halt and surface current state rather than broadening scope. -- **Halt conditions:** - - Candidate extraction requires a new external contract-space symbol index. - - Completing provider tests requires LSP request handling or configured-input server wiring. - - Generic block, ordinary attribute, relation-aware, or parser behavior work appears necessary. - - Validation gates fail for reasons that look unrelated to this dispatch. - -## Validation gates - -- `pnpm --filter @prisma-next/language-server test` -- `pnpm --filter @prisma-next/language-server typecheck` -- `pnpm --filter @prisma-next/language-server lint` diff --git a/projects/lsp-autocomplete/slices/model-type-completions/dispatches/03-lsp-completion-route.md b/projects/lsp-autocomplete/slices/model-type-completions/dispatches/03-lsp-completion-route.md deleted file mode 100644 index 12b21e3b23..0000000000 --- a/projects/lsp-autocomplete/slices/model-type-completions/dispatches/03-lsp-completion-route.md +++ /dev/null @@ -1,75 +0,0 @@ -# Brief: lsp-completion-route - -## Task - -Wire the language-server completion request path for configured PSL inputs. The server should advertise completion support, route `textDocument/completion` requests through Dispatch 1's classifier and Dispatch 2's provider, and return `[]` for unconfigured, missing, artifact-less, or unsupported contexts. - -## Scope - -**In:** - -- `server.ts` completion capability advertisement in the initialize response. -- `connection.onCompletion(...)` request handler for open, configured PSL documents. -- Reuse of existing open-document lookup, configured-input gating, cached `DocumentAst` / `SourceFile`, and current project `SymbolTable` artifacts. -- Calling the existing model field type context classifier and provider dispatcher. -- Empty completion results for unconfigured documents, missing documents, unavailable artifacts, parse/artifact gaps, and unsupported classifier contexts. -- Server test harness helper for completion requests, analogous to the existing diagnostics/formatting helpers. -- Server tests for completion capability advertisement, configured model field type completions, unconfigured documents returning `[]`, and unsupported contexts returning `[]`. -- Preservation of existing diagnostics and formatting behavior. - -**Out:** - -- Generic block entry/parameter completions. -- Ordinary PSL `@` / `@@` attribute completions or attribute argument completions. -- Relation-aware completions. -- Changes to candidate extraction ordering or filtering beyond integration fixes strictly required by the server route. -- Creating a new external contract-space symbol index. -- Parser behavior changes, parser public exports, or completion-marker reparsing. -- Documentation updates; those are Dispatch 4. - -## Completed when - -- [ ] Server initialize tests prove `completionProvider` is advertised without regressing existing capabilities. -- [ ] Server completion tests prove a configured PSL document returns expected model field type completion labels at a type position. -- [ ] Server completion tests prove an unconfigured document returns `[]`. -- [ ] Server completion tests prove an unsupported context, including at least one ordinary PSL attribute context if easy to add here, returns `[]`. -- [ ] Existing diagnostics and formatting server tests still pass. -- [ ] Validation gates pass or any blocker is surfaced with concrete evidence. - -## Standing instruction - -Stay focused on the goal; control scope. Trivial-and-related fixes that obviously serve the goal go in the same dispatch with a one-line note in your wrap-up message. Anything that pulls you off the goal — even if it looks useful — halts and surfaces. - -## References - -**Slice-loop dispatch:** - -- Slice spec: `projects/lsp-autocomplete/slices/model-type-completions/spec.md` -- Slice plan entry: `projects/lsp-autocomplete/slices/model-type-completions/plan.md` § Dispatch 3: lsp-completion-route -- Dispatch 1 hand-off: `packages/1-framework/3-tooling/language-server/src/completion-context.ts` and `packages/1-framework/3-tooling/language-server/test/completion-context.test.ts` -- Dispatch 2 hand-off: `packages/1-framework/3-tooling/language-server/src/completion-provider.ts` and `packages/1-framework/3-tooling/language-server/test/completion-provider.test.ts` -- Project spec / plan: `projects/lsp-autocomplete/spec.md`, `projects/lsp-autocomplete/plan.md` -- Code review log: `projects/lsp-autocomplete/reviews/code-review.md` -- Relevant code surfaces: - - `packages/1-framework/3-tooling/language-server/src/server.ts` - - `packages/1-framework/3-tooling/language-server/src/project-artifacts.ts` - - `packages/1-framework/3-tooling/language-server/src/pipeline.ts` - - `packages/1-framework/3-tooling/language-server/test/server.test.ts` - -## Operational metadata - -- **Dispatch ID:** `0fb30343-7c83-48d5-af0a-c7e9dbfb771b` -- **Round ID:** `c3095776-c25f-4d4c-b780-f50cab74c1a3` -- **Model tier:** `implementer/fast` — routine language-server integration work within a slice; reuse the existing implementer session. -- **Time-box:** 90 minutes wall clock. Overrun means halt and surface current state rather than broadening scope. -- **Halt conditions:** - - Completion routing requires parser behavior changes, parser public exports, or completion-marker reparsing. - - Server tests require implementing generic block completions, ordinary attribute completions, attribute argument completions, relation-aware completions, or an external contract-space symbol index. - - Candidate extraction needs a scope expansion rather than a small integration fix. - - Validation gates fail for reasons that look unrelated to this dispatch. - -## Validation gates - -- `pnpm --filter @prisma-next/language-server test` -- `pnpm --filter @prisma-next/language-server typecheck` -- `pnpm --filter @prisma-next/language-server lint` diff --git a/projects/lsp-autocomplete/slices/model-type-completions/dispatches/04-scope-docs-and-final-guardrails.md b/projects/lsp-autocomplete/slices/model-type-completions/dispatches/04-scope-docs-and-final-guardrails.md deleted file mode 100644 index 9a9080cdbd..0000000000 --- a/projects/lsp-autocomplete/slices/model-type-completions/dispatches/04-scope-docs-and-final-guardrails.md +++ /dev/null @@ -1,70 +0,0 @@ -# Brief: scope-docs-and-final-guardrails - -## Task - -Finish the `model-type-completions` slice by documenting the newly supported narrow completion scope and ensuring the final guardrails protect against scope creep. The README should no longer say completion is wholly unsupported, but it must be precise: slice 1 supports configured PSL model field type completions only. - -## Scope - -**In:** - -- Update `packages/1-framework/3-tooling/language-server/README.md` to describe the supported completion surface. -- Document that completion is available only for configured PSL inputs and only at model field type positions. -- Document candidate sources accurately: configured scalar types plus visible model/composite/scalar/type-alias candidates from the current project symbol table, including namespace-qualified and contract-space-qualified type-position syntax when candidates are visible in current artifacts. -- Explicitly document exclusions: generic block entry/parameter completions, ordinary PSL `@` / `@@` attribute completions, attribute argument completions, relation-aware completions, and new external contract-space candidate discovery are not part of slice 1. -- Add or verify final server-level guardrail coverage for ordinary attribute contexts returning `[]`; if Dispatch 3 already added sufficient coverage, keep it and only add a narrowly useful missing assertion. -- Preserve existing diagnostics, formatting, and completion route behavior. - -**Out:** - -- Implementing generic block entry/parameter completions. -- Implementing ordinary PSL `@` / `@@` attribute completions or attribute argument completions. -- Implementing relation-aware completions. -- Adding or changing candidate extraction semantics beyond fixing a discovered guardrail gap. -- Creating a new external contract-space symbol index. -- Parser behavior changes, parser public exports, or completion-marker reparsing. -- Broad documentation rewrites unrelated to the language-server completion scope. - -## Completed when - -- [ ] The language-server README accurately describes configured PSL model field type completion support. -- [ ] The README explicitly excludes generic block completions, ordinary attribute completions, attribute argument completions, relation-aware completions, and external contract-space candidate discovery unless future slices add them. -- [ ] Server-level guardrail coverage proves ordinary attribute contexts return `[]` through the LSP route, either by existing Dispatch 3 coverage or a focused D4 test addition. -- [ ] No implementation scope is broadened while editing docs/guardrails. -- [ ] Validation gates pass or any blocker is surfaced with concrete evidence. - -## Standing instruction - -Stay focused on slice close-out. Do not use this dispatch to improve or reshape the provider, classifier, parser, symbol table, or LSP route unless a concrete guardrail failure proves a tiny fix is required. - -## References - -**Slice-loop dispatch:** - -- Slice spec: `projects/lsp-autocomplete/slices/model-type-completions/spec.md` -- Slice plan entry: `projects/lsp-autocomplete/slices/model-type-completions/plan.md` § Dispatch 4: scope-docs-and-final-guardrails -- Dispatch 1 hand-off: `packages/1-framework/3-tooling/language-server/src/completion-context.ts` -- Dispatch 2 hand-off: `packages/1-framework/3-tooling/language-server/src/completion-provider.ts` -- Dispatch 3 hand-off: `packages/1-framework/3-tooling/language-server/src/server.ts` and `packages/1-framework/3-tooling/language-server/test/server.test.ts` -- Project spec / plan: `projects/lsp-autocomplete/spec.md`, `projects/lsp-autocomplete/plan.md` -- Code review log: `projects/lsp-autocomplete/reviews/code-review.md` -- Relevant docs/tests: - - `packages/1-framework/3-tooling/language-server/README.md` - - `packages/1-framework/3-tooling/language-server/test/server.test.ts` - -## Operational metadata - -- **Dispatch ID:** `8c39fcaa-7306-425f-982b-fd5b4f7cdc5c` -- **Round ID:** `dc7ce623-26b7-41fb-9b78-34b6962a7269` -- **Model tier:** `implementer/fast` — focused documentation and final guardrail work; reuse the existing implementer session. -- **Time-box:** 45 minutes wall clock. Overrun means halt and surface current state rather than broadening scope. -- **Halt conditions:** - - Documentation accuracy requires changing product scope or resolving a new design question. - - Guardrail coverage requires implementing generic block completions, ordinary attribute completions, attribute argument completions, relation-aware completions, external contract-space indexes, parser changes, or marker reparsing. - - Validation gates fail for reasons that look unrelated to this dispatch. - -## Validation gates - -- `pnpm --filter @prisma-next/language-server test` -- `pnpm --filter @prisma-next/language-server typecheck` -- `pnpm --filter @prisma-next/language-server lint` diff --git a/projects/lsp-autocomplete/slices/model-type-completions/plan.md b/projects/lsp-autocomplete/slices/model-type-completions/plan.md deleted file mode 100644 index aada9df9b7..0000000000 --- a/projects/lsp-autocomplete/slices/model-type-completions/plan.md +++ /dev/null @@ -1,82 +0,0 @@ -# Slice plan: model-type-completions - -**Spec:** `projects/lsp-autocomplete/slices/model-type-completions/spec.md` -**Parent project:** `projects/lsp-autocomplete/spec.md` -**Linear issue:** [TML-2946](https://linear.app/prisma-company/issue/TML-2946/lsp-autocomplete-model-type-completions) - -## Dispatch plan - -### Dispatch 1: cursor-classifier-substrate - -- **Outcome:** A pure classifier identifies model field type completion contexts and unsupported contexts from existing cached `DocumentAst`, `SourceFile`, and syntax tree structure. -- **Builds on:** Existing parser recovery, `SourceFile.offsetAt(position)`, red-tree traversal, `FieldDeclarationAst.typeAnnotation()`, `TypeAnnotationAst`, and `QualifiedNameAst`. -- **Hands to:** A typed completion context union consumed by provider dispatch. -- **Focus:** Cursor offset conversion; touching/current/previous token handling; ancestor-based model field type detection; blank and partial type positions; bare, namespace-qualified, and contract-space-qualified prefixes; explicit rejection of ordinary `@` / `@@` attributes, attribute arguments, generic blocks, comments/trivia, constructor arguments, and invalid over-qualified names. -- **Completed when:** - - Classifier tests cover blank type, partial bare type, namespace-qualified prefixes, contract-space-qualified prefixes, comments/trivia, ordinary attributes, attribute arguments, generic blocks, and unsupported contexts. - - No completion-marker reparsing is introduced. -- **Validation gates:** - - `pnpm --filter @prisma-next/language-server test` - - `pnpm --filter @prisma-next/language-server typecheck` - - `pnpm --filter @prisma-next/language-server lint` - - If parser utilities or exports are changed: `pnpm --filter @prisma-next/psl-parser test`, `pnpm --filter @prisma-next/psl-parser typecheck`, `pnpm --filter @prisma-next/psl-parser lint`, and `pnpm lint:deps`. - -### Dispatch 2: model-type-provider - -- **Outcome:** The model-type provider returns stable LSP completion items for scalar and visible type-like symbol-table candidates when the classifier reports a model field type context. -- **Builds on:** Dispatch 1’s typed completion context union. -- **Hands to:** A completion entry point that can route context to provider output or `[]`. -- **Focus:** Candidate extraction from configured scalar types and the current `SymbolTable`; top-level models, composite types, scalars, type aliases; namespace models and composite types; stable ordering; prefix filtering for bare, namespace-qualified, and contract-space-qualified syntax; explicit exclusion of generic block symbols, ordinary attributes, relation-aware suggestions, and external contract-space indexes that do not exist in current artifacts. -- **Completed when:** - - Provider tests cover bare type completions, namespace-qualified completions, contract-space-qualified syntax positions, and unsupported analysis returning `[]`. - - Tests prove generic block symbols are not returned as model field type candidates. - - The candidate-source seam can be extended later without changing the classifier contract. -- **Validation gates:** - - `pnpm --filter @prisma-next/language-server test` - - `pnpm --filter @prisma-next/language-server typecheck` - - `pnpm --filter @prisma-next/language-server lint` - -### Dispatch 3: lsp-completion-route - -- **Outcome:** The language server advertises completion support and answers `textDocument/completion` for open, configured PSL inputs. -- **Builds on:** Dispatch 1’s classifier and Dispatch 2’s provider dispatch entry point. -- **Hands to:** End-to-end LSP completion behavior for configured documents. -- **Focus:** `server.ts` completion capability advertisement; `connection.onCompletion(...)` request handler; open-document lookup; configured-input gating; cached artifact lookup; empty results for unconfigured, missing, unsupported, or artifact-less documents; preservation of existing diagnostics and formatting behavior. -- **Completed when:** - - Server tests cover initialize capability advertisement. - - Server tests cover configured PSL completion returning expected labels at a model field type position. - - Server tests cover unconfigured documents and unsupported contexts returning `[]`. - - Existing diagnostics and formatting server tests still pass. -- **Validation gates:** - - `pnpm --filter @prisma-next/language-server test` - - `pnpm --filter @prisma-next/language-server typecheck` - - `pnpm --filter @prisma-next/language-server lint` - -### Dispatch 4: scope-docs-and-final-guardrails - -- **Outcome:** Documentation and final guardrail tests accurately describe and protect the slice-1 completion scope. -- **Builds on:** Dispatch 3’s end-to-end completion route. -- **Hands to:** Slice `generic-block-completions` can add descriptor-backed contexts/providers without reworking the LSP request path. -- **Focus:** Update `packages/1-framework/3-tooling/language-server/README.md` so completion is no longer documented as wholly out of scope; document configured PSL inputs, model field type positions, visible symbol-table candidates, and qualified syntax support; explicitly exclude generic block completions, ordinary `@` / `@@` attributes, attribute arguments, relation-aware completions, and external contract-space candidate discovery unless a future slice adds it. -- **Completed when:** - - README states the supported model-type completion scope without overclaiming slice-2 or future surfaces. - - Final guardrail coverage includes at least one ordinary attribute context returning `[]` through the server path. - - The slice-specific done conditions in `spec.md` are satisfied. -- **Validation gates:** - - `pnpm --filter @prisma-next/language-server test` - - `pnpm --filter @prisma-next/language-server typecheck` - - `pnpm --filter @prisma-next/language-server lint` - - If imports, exports, or package boundaries changed: `pnpm lint:deps`. - -## Dispatch-INVEST check - -- **Independent:** Each dispatch leaves a named hand-off usable by the next dispatch without concurrent sibling work. -- **Negotiable:** Dispatches name outcomes and leave implementation details to executor discovery inside the scoped surfaces. -- **Valuable:** Every dispatch materially advances the slice from classifier substrate to provider, LSP route, and final scope guardrails. -- **Estimable:** Each dispatch has binary completed-when checks and concrete validation commands. -- **Small:** Each dispatch is one coherent review lens: classifier, provider, route, or docs/guardrails. -- **Testable:** The language-server package test/typecheck/lint gates verify each dispatch, with parser gates added only if parser exports change. - -## Open items - -None. diff --git a/projects/lsp-autocomplete/slices/model-type-completions/spec.md b/projects/lsp-autocomplete/slices/model-type-completions/spec.md deleted file mode 100644 index 8735348645..0000000000 --- a/projects/lsp-autocomplete/slices/model-type-completions/spec.md +++ /dev/null @@ -1,106 +0,0 @@ -# Slice: model-type-completions - -Parent project: `projects/lsp-autocomplete/`. This slice contributes the first end-to-end PSL completion surface: model field type completions for configured language-server inputs. - -## At a glance - -Configured PSL documents answer `textDocument/completion` at model field type positions. The slice establishes the shared completion capability, request handler, cursor-context classifier, provider dispatch seam, and model-type provider that the later generic-block slice can extend without reshaping the LSP route. - -## Chosen design - -The language server adds completion as another configured-input-only surface next to diagnostics and formatting. The request path is: - -```text -textDocument/completion - → open document lookup - → configured PSL input check - → cached DocumentAst / SourceFile / SymbolTable lookup - → SourceFile.offsetAt(position) - → completion context analysis over the existing red tree + AST ancestors - → explicit provider dispatch - → CompletionItem[] -``` - -Unsupported, unconfigured, missing-document, and missing-artifact cases return an empty completion list. Completion does not parse a second copy of the document with a marker inserted. - -The classifier is a pure layer in the language-server package. It uses `SourceFile.offsetAt(position)`, red-tree offsets/tokens/ancestors, `FieldDeclarationAst.typeAnnotation()`, `TypeAnnotationAst.name()`, and `QualifiedNameAst` to identify model field type contexts. It recognizes blank and partial type positions such as: - -```prisma -model Post { - author | - reviewer U| - owner auth.| - editor auth.U| - external supabase:| - externalUser supabase:auth.| -} -``` - -The classifier explicitly rejects ordinary PSL `@` / `@@` attributes, attribute arguments, generic blocks, comments/trivia, constructor argument positions, and malformed over-qualified names that are not valid type-prefix contexts. This rejection is required because parser recovery can keep attribute syntax under nearby field structure; a field ancestor alone is not enough to classify a cursor as a type position. - -Provider dispatch is explicit. Slice 1 has one productive provider: - -| Context | Provider behavior | -| --- | --- | -| model field type position | Complete configured scalar types plus visible model, composite type, scalar, and type-alias symbols from the current project symbol table. | -| unsupported context | Return `[]`. | - -The model-type provider reads candidates from the configured scalar type set and the current `SymbolTable`: top-level models, composite types, scalars, type aliases, and namespace models/composite types. It ignores generic block symbols and does not provide relation-aware suggestions. Qualified prefixes preserve the user’s syntactic shape: bare `User`, namespace-qualified `auth.User`, and contract-space-qualified `supabase:auth.User` positions are classified and filtered as type positions. This slice does not invent a new external contract-space symbol index; contract-space-qualified completions use the visible candidate data already available to the language server. - -The server test harness grows a completion request helper analogous to the existing diagnostics/formatting helpers. Server tests cover capability advertisement and configured/unconfigured document behavior. - -## Coherence rationale - -This is one reviewable PR because the capability, classifier, provider dispatch seam, and first provider are one end-to-end feature path. Splitting the LSP route from the model-type provider would ship a completion capability with no useful completion behavior, while bundling generic block descriptor semantics would add a second syntactic domain and exceed the slice’s review coherence. - -## Scope - -**In:** - -- `packages/1-framework/3-tooling/language-server` completion capability and request handler. -- A pure language-server cursor-context classifier over cached `DocumentAst`, `SourceFile`, red-tree syntax, and the current `SymbolTable`. -- Model field type completion provider backed by configured scalar types and visible type-like symbol-table entries. -- Bare, namespace-qualified, and contract-space-qualified type-position classification and prefix filtering. -- Empty results for unsupported contexts, unconfigured documents, missing documents, and unavailable artifacts. -- Focused classifier/provider/server tests for model type positions and unsupported contexts. -- Language-server README update for the newly supported narrow completion scope. - -**Out:** - -- Generic block entry/parameter completions; those belong to slice `generic-block-completions`. -- Ordinary PSL `@` / `@@` attribute name completions. -- Attribute argument completions. -- Relation-aware completions such as `fields`, `references`, inverse relation suggestions, or field-specific relation ranking. -- A new external contract-space symbol index beyond the language server’s current project artifacts. -- Parser-wide public cursor helper exports unless implementation proves a local language-server helper is insufficient. -- Completion-marker reparsing. - -## Pre-investigated edge cases - -**None pre-investigated from outside the codebase.** The known code-grounded constraints are captured in the chosen design; new edge cases that surface at dispatch time amend the spec via `drive-discussion` per invariant I12. - -## Slice-specific done conditions - -- [ ] The completed slice leaves a typed classifier/provider dispatch seam that `generic-block-completions` can extend without changing the LSP request handler. -- [ ] Language-server documentation no longer says completion is wholly out of scope and does not overclaim generic block, ordinary attribute, relation-aware, or external contract-space candidate support. - -## Open Questions - -None. - -## References - -- Parent project: `projects/lsp-autocomplete/spec.md` -- Project plan: `projects/lsp-autocomplete/plan.md` -- Linear issue: [TML-2946](https://linear.app/prisma-company/issue/TML-2946/lsp-autocomplete-model-type-completions) -- Relevant code surfaces: - - `packages/1-framework/3-tooling/language-server/src/server.ts` - - `packages/1-framework/3-tooling/language-server/src/project-artifacts.ts` - - `packages/1-framework/3-tooling/language-server/src/pipeline.ts` - - `packages/1-framework/3-tooling/language-server/test/server.test.ts` - - `packages/1-framework/2-authoring/psl-parser/src/source-file.ts` - - `packages/1-framework/2-authoring/psl-parser/src/syntax/red.ts` - - `packages/1-framework/2-authoring/psl-parser/src/syntax/ast/declarations.ts` - - `packages/1-framework/2-authoring/psl-parser/src/syntax/ast/type-annotation.ts` - - `packages/1-framework/2-authoring/psl-parser/src/syntax/ast/qualified-name.ts` - - `packages/1-framework/2-authoring/psl-parser/src/symbol-table.ts` diff --git a/projects/lsp-autocomplete/slices/parser-red-tree-navigation/plan.md b/projects/lsp-autocomplete/slices/parser-red-tree-navigation/plan.md deleted file mode 100644 index 629d8d4b76..0000000000 --- a/projects/lsp-autocomplete/slices/parser-red-tree-navigation/plan.md +++ /dev/null @@ -1,37 +0,0 @@ -# Slice plan: parser-red-tree-navigation - -**Spec:** `projects/lsp-autocomplete/slices/parser-red-tree-navigation/spec.md` -**Parent project:** `projects/lsp-autocomplete/spec.md` - -## Dispatch plan - -### Dispatch 1: red-tree-cursor-navigation - -- **Outcome:** The PSL parser red tree exposes navigable tokens (parent + previous/next), `SyntaxNode.tokenAtOffset` with left/right bias, a covering-element lookup, and trivia-skip helpers, all exported from `@prisma-next/psl-parser/syntax` and covered by unit tests. -- **Builds on:** The existing red/green tree, AST wrappers, and node-level navigation (`children`/`ancestors`/`prevSibling`/`tokens`). -- **Hands to:** A cursor-navigation API the completion classifier consumes in the next slice: from an offset, get the anchor token, its parent node, previous/next token, and nearest non-trivia neighbor. -- **Focus:** Thread the already-known parent through `wrapElement` into the red token; add token prev/next traversal; add `tokenAtOffset` (representing the between-two-tokens case with left/right bias) and covering-element lookup on `SyntaxNode`; add `skip_trivia_token` / `non_trivia_sibling` / `previous_non_trivia_token` equivalents; export the new surface; update any in-package `SyntaxToken` construction sites; write unit tests. Use rust-analyzer idioms when in doubt about API shape or helper implementation. Do **not** add fake-identifier reparsing, touch the language server, or change grammar/tokenizer. -- **Completed when:** - - Red tokens expose their parent node and previous/next-token traversal. - - `SyntaxNode.tokenAtOffset(offset)` exists, represents the between-two-tokens case, and offers left/right bias selectors; a covering-element lookup exists. - - Non-trivia skip/sibling/previous helpers exist over the new token navigation. - - New primitives are exported from `src/exports/syntax.ts` and covered by unit tests, including the between-tokens, zero-width-node, and EOF cases named in the slice spec. -- **Validation gates:** - - `pnpm --filter @prisma-next/psl-parser test` - - `pnpm --filter @prisma-next/psl-parser typecheck` - - `pnpm --filter @prisma-next/psl-parser lint` - - `pnpm --filter @prisma-next/psl-parser build` (refresh `dist/*.d.mts` consumed by the language server in the next slice) - - Workspace guard for the changed token shape: `pnpm --filter @prisma-next/language-server typecheck` (must still pass; flags any token-shape break in the existing consumer) - -## Dispatch-INVEST check - -- **Independent:** Lands as one PR-able parser change; no concurrent sibling work required. -- **Negotiable:** Outcome (navigable tokens + offset queries + trivia helpers) is pinned; class-vs-free-function and exact selector names are the implementer's discovery, guided by rust-analyzer idioms. -- **Valuable:** Removes the root cause (non-navigable tokens) behind the completion classifier's whole-tree scans; reusable by hover/go-to-definition later. -- **Estimable:** Completed-when checklist is binary and test-backed. -- **Small:** One package, one coherent capability, no consumer rewrites; the language-server typecheck is a guard, not a migration. -- **Testable:** `psl-parser` unit tests plus the package gates verify it. - -## Open items - -None. diff --git a/projects/lsp-autocomplete/slices/parser-red-tree-navigation/spec.md b/projects/lsp-autocomplete/slices/parser-red-tree-navigation/spec.md deleted file mode 100644 index 14c96d0b9b..0000000000 --- a/projects/lsp-autocomplete/slices/parser-red-tree-navigation/spec.md +++ /dev/null @@ -1,66 +0,0 @@ -# Slice spec: parser-red-tree-navigation - -**Parent project:** `projects/lsp-autocomplete/spec.md` - -## At a glance - -The PSL parser red tree (`packages/1-framework/2-authoring/psl-parser/src/syntax/red.ts`) currently lets you walk *down and across nodes* (`children`, `childNodes`, `ancestors`, `firstChild`, `lastChild`, `nextSibling`, `prevSibling`, `descendants`, `tokens`) but tokens are dead ends: `wrapElement` returns a bare `SyntaxToken` of shape `{ kind, text, offset }` with no link back to its parent, and there is no offset-query entry point. As a result the language-server completion classifier reconstructs token context by scanning `root.tokens()` from the document root and by reasoning over raw source text. - -This slice adds the rust-analyzer-style navigation primitives that make a cursor position a first-class, locally-navigable thing, so downstream tooling (completion now; hover/go-to-definition later) can start from a token and walk outward instead of re-scanning the whole tree. - -## Chosen design - -Adopt rust-analyzer's `syntax` navigation idioms, with one explicit exception: we do **not** adopt the fake-identifier completion marker. Our parser is error-tolerant and macro-free, so an empty position already yields usable nodes. - -Concretely: - -- **Navigable tokens.** Give the red token a link to its parent `SyntaxNode` and previous/next-token traversal. Follow rust-analyzer, where `SyntaxToken` carries its parent and offers `prev_token()` / `next_token()`. The parent is already known inside `wrapElement` (it is currently discarded) — thread it through rather than recomputing. -- **Offset queries on `SyntaxNode`.** Add `tokenAtOffset(offset)` mirroring rust-analyzer's `TokenAtOffset` (the between-two-tokens case must be representable, with `leftBiased()` / `rightBiased()` selectors), and a covering-element lookup mirroring `covering_element(range)`. -- **Trivia-skipping helpers.** Provide the equivalents of rust-analyzer's `skip_trivia_token` / `non_trivia_sibling` / `previous_non_trivia_token`, expressed over the new token navigation. Trivia kinds are the existing `Whitespace` / `Newline` / `Comment`. -- **Export surface.** Re-export the new types/functions from `@prisma-next/psl-parser/syntax` (`src/exports/syntax.ts`), where `SyntaxNode` / `SyntaxToken` / `SyntaxElement` already live. - -Implementation shape (class vs free functions, exact selector names) is the implementer's call — follow rust-analyzer idioms where in doubt. - -## Coherence rationale - -One reviewer can hold this in a sitting: it is a single capability ("the red tree is cursor-navigable and offset-queryable") landing in one package with unit tests, no consumer rewrites. The completion rewrite that consumes it is a separate slice, so this slice's diff stays about the parser substrate only. - -## Scope - -**In:** - -- `packages/1-framework/2-authoring/psl-parser/src/syntax/red.ts` — token parent linkage, token prev/next traversal, `tokenAtOffset`, covering element. -- A trivia/navigation helper home (new file under `syntax/`, or additions to `ast-helpers.ts`/`red.ts` — implementer's call). -- `packages/1-framework/2-authoring/psl-parser/src/exports/syntax.ts` — export the new surface. -- Unit tests in `psl-parser` covering the new primitives. -- Any in-package construction sites of `SyntaxToken` updated for the new shape. - -**Deliberately out:** - -- Any change to `completion-context.ts` or the language server (that is the next slice). -- Fake-identifier / completion-marker reparsing. -- Grammar, tokenizer, or green-tree changes beyond what token-parent linkage strictly requires. -- Performance work beyond not regressing the existing single-pass cost. - -## Pre-investigated edge cases - -| Case | Note | -| --- | --- | -| Cursor between two tokens (e.g. `foo|bar` boundary, or at a token seam) | `tokenAtOffset` must represent the two-token case and offer left/right bias, like rust-analyzer's `TokenAtOffset::Between`. The completion slice depends on `leftBiased()`. | -| Zero-width / empty nodes | `containsOffset` already special-cases `textLength === 0`; covering-element and `tokenAtOffset` must stay consistent with that rule. | -| EOF / offset at end of file | Left-biased lookup must still return the final significant token. | - -## Slice-specific done conditions - -- A consumer can, from an offset, obtain the anchor token and walk to its parent node, previous/next token, and nearest non-trivia neighbor without scanning from the document root. - -## Open questions - -None. - -## References - -- `packages/1-framework/2-authoring/psl-parser/src/syntax/red.ts` — current red tree. -- `packages/1-framework/2-authoring/psl-parser/src/syntax/ast-helpers.ts` — existing `findChildToken` / `filterChildren` helpers. -- `packages/1-framework/2-authoring/psl-parser/src/exports/syntax.ts` — export barrel. -- rust-analyzer `crates/syntax` (`SyntaxToken`, `TokenAtOffset`, `algo::{skip_trivia_token, non_trivia_sibling, previous_non_trivia_token}`) — idiom reference. diff --git a/projects/lsp-autocomplete/slices/top-level-keyword-completions/dispatches/01-declaration-keyword-completions.md b/projects/lsp-autocomplete/slices/top-level-keyword-completions/dispatches/01-declaration-keyword-completions.md deleted file mode 100644 index 9e8f9f8c0c..0000000000 --- a/projects/lsp-autocomplete/slices/top-level-keyword-completions/dispatches/01-declaration-keyword-completions.md +++ /dev/null @@ -1,73 +0,0 @@ -# Brief: declaration-keyword-completions - -## Task - -Implement top-level declaration keyword completions for configured PSL inputs in the language server. Extend the existing completion classifier/provider route so document-level declaration positions complete native PSL block keywords plus descriptor-backed generic block keywords, namespace-body declaration positions complete only namespace-valid native keywords plus descriptor-backed generic block keywords, and completion item insertion is snippet-capability gated with a plain-text fallback. - -## Scope - -**In:** - -- Language-server tests first for classifier/provider/server behavior covering document-level and namespace-body declaration keyword completions. -- `packages/1-framework/3-tooling/language-server/src/completion-context.ts` declaration-position context, including blank and partial keyword prefixes and replacement range metadata. -- `packages/1-framework/3-tooling/language-server/src/completion-provider.ts` native keyword and descriptor-backed generic block keyword candidates, stable filtering/ordering, snippet/plain insertion variants, and unsupported-context routing. -- `packages/1-framework/3-tooling/language-server/src/server.ts` snippet-support detection from `InitializeParams.capabilities.textDocument?.completion?.completionItem?.snippetSupport === true` and threading into provider input. -- Server capability preservation, including `completionProvider.triggerCharacters: ['.']` if that is not already advertised for namespace-member completion. -- `packages/1-framework/3-tooling/language-server/README.md` documentation of top-level and namespace-body keyword completion scope, snippet capability gating, and explicit exclusions. - -**Out:** - -- Ordinary PSL `@` / `@@` field or model attribute name completions. -- Attribute argument completions. -- Generic block value completions. -- Relation-aware completions. -- Nested `namespace` or `types` keyword suggestions inside namespace bodies. -- Completion-marker reparsing or a second parser pass. -- Parser public API changes unless implementation proves the existing red-tree/AST surface is insufficient. If parser exports or generated declarations must change, halt and explain why before doing so. -- Reapplying or modifying unrelated local dirt, especially `apps/lsp-playground/src/client/runtime.ts`. - -## Completed when - -- [ ] Tests cover blank and partial document-level declaration keyword positions; blank and partial namespace-body declaration keyword positions; namespace-body exclusion of `namespace` and `types`; descriptor-backed generic block keyword candidates; snippet-supported output; plain-text fallback output; and ordinary `@` / `@@` attribute contexts returning no scoped keyword suggestions. -- [ ] The implementation derives and threads `clientSupportsSnippets` from initialize capabilities, emitting snippet items only when true and plain-text items otherwise. -- [ ] Existing model type completions, namespace member completions, generic block key completions, diagnostics, formatting, and folding-range behavior remain covered and green. -- [ ] README accurately documents the expanded completion scope and exclusions. - -## Standing instruction - -Stay focused on the goal; control scope. Trivial-and-related fixes that obviously serve the goal go in the same dispatch with a one-line note in your wrap-up message. Anything that pulls you off the goal — even if it looks useful — halts and surfaces. - -## References - -**Slice-loop dispatch:** - -- Slice spec: `projects/lsp-autocomplete/slices/top-level-keyword-completions/spec.md` -- Slice plan entry: `projects/lsp-autocomplete/slices/top-level-keyword-completions/plan.md` § Dispatch 1: declaration-keyword-completions -- Parent project spec: `projects/lsp-autocomplete/spec.md` -- Parent project plan: `projects/lsp-autocomplete/plan.md` -- Code review log: `projects/lsp-autocomplete/reviews/code-review.md` -- Existing completion slice artifacts: `projects/lsp-autocomplete/slices/model-type-completions/` -- Linear issue: `TML-2947` — https://linear.app/prisma-company/issue/TML-2947/lsp-autocomplete-top-level-keyword-completions - -**Code surfaces to inspect during pre-flight:** - -- `packages/1-framework/3-tooling/language-server/src/completion-context.ts` -- `packages/1-framework/3-tooling/language-server/src/completion-provider.ts` -- `packages/1-framework/3-tooling/language-server/src/server.ts` -- `packages/1-framework/3-tooling/language-server/test/completion-context.test.ts` -- `packages/1-framework/3-tooling/language-server/test/completion-provider.test.ts` -- `packages/1-framework/3-tooling/language-server/test/server.test.ts` -- `packages/1-framework/3-tooling/language-server/README.md` - -## Operational metadata - -- **Model tier:** mid — one coherent language-server feature touching classifier/provider/server tests and docs, with snippet capability gating and namespace-specific behavior. -- **Time-box:** 90 minutes wall-clock. If this runs materially longer, pause and report the current state rather than expanding scope. -- **Halt conditions:** Halt if declaration-position detection cannot be implemented from existing cached parse artifacts; if parser public exports or generated parser declarations appear necessary; if ordinary PSL attributes or attribute arguments must be touched to complete this; if completion-marker reparsing seems necessary; if unrelated local changes block validation; or if validation failures appear unrelated to this dispatch and cannot be isolated. - -## Validation gates - -- `pnpm --filter @prisma-next/language-server test` -- `pnpm --filter @prisma-next/language-server typecheck` -- `pnpm --filter @prisma-next/language-server lint` -- If parser exports or generated parser declarations are changed after an explicit halt/approval: `pnpm --filter @prisma-next/psl-parser build`, then rerun the language-server gates. diff --git a/projects/lsp-autocomplete/slices/top-level-keyword-completions/dispatches/02-review-feedback-and-classifier-navigation.md b/projects/lsp-autocomplete/slices/top-level-keyword-completions/dispatches/02-review-feedback-and-classifier-navigation.md deleted file mode 100644 index f15d02835d..0000000000 --- a/projects/lsp-autocomplete/slices/top-level-keyword-completions/dispatches/02-review-feedback-and-classifier-navigation.md +++ /dev/null @@ -1,50 +0,0 @@ -# Brief: review-feedback-and-classifier-navigation - -## Task - -Address the open PR review feedback on PR #871 and refactor the PSL completion classifier so it uses the parser's existing red-tree / AST navigation primitives instead of repeatedly rediscovering context by traversing the whole tree. Preserve the already-delivered autocomplete semantics: model field type completions, namespace qualifier/member completions, generic block parameter completions, and declaration keyword completions with snippet/plain variants. - -## Scope - -**In:** `packages/1-framework/3-tooling/language-server/src/completion-context.ts`, its focused tests, and any parser syntax exports/helpers needed to use already-present navigation APIs cleanly; `packages/1-framework/3-tooling/language-server/src/server.ts` and tests if stale cached artifacts are still a real completion-path bug; `apps/lsp-playground/src/cli.ts` for the malformed `Host` / URL parsing review comment; small README/test updates only if necessary to keep docs truthful. - -**Out:** Changing completion product semantics, adding ordinary `@` / `@@` attribute completions, changing PSL parser grammar, changing SQL interpreter namespace semantics, replying to or resolving GitHub review threads, broad parser rewrites, and unrelated playground/server cleanup. - -## Completed when - -- [ ] Each unresolved review comment fetched from PR #871 is implemented or explicitly reported as not applicable in the dispatch wrap-up; do not post replies to GitHub. -- [ ] `completion-context.ts` uses existing red-tree / AST navigation methods for token/node context instead of avoidable repeated whole-tree scans, and removes unused / over-complicated helper state called out in review. -- [ ] Tests cover any behavior changed to refresh completion artifacts from the current buffer and preserve classifier behavior after the navigation refactor. -- [ ] Language-server and playground validation gates listed in the slice plan pass, or any failure is reported with the failing command and relevant error. - -## Standing instruction - -Stay focused on review feedback and classifier navigation quality. Trivial-and-related fixes that obviously serve the goal go in the same dispatch with a one-line note in your wrap-up message. Anything that pulls you into completion semantics redesign, namespace resolution policy, or parser grammar changes halts and surfaces. - -## References - -**Slice-loop dispatch:** - -- Slice spec: `projects/lsp-autocomplete/slices/top-level-keyword-completions/spec.md` — chosen design + coherence rationale + slice-DoD. -- Slice plan entry: `projects/lsp-autocomplete/slices/top-level-keyword-completions/plan.md` § Dispatch 2 — outcome / builds-on / hands-to / focus. -- Prior dispatch artifacts in this slice: `projects/lsp-autocomplete/slices/top-level-keyword-completions/dispatches/01-declaration-keyword-completions.md`. -- Project spec: `projects/lsp-autocomplete/spec.md` — especially the no completion-marker reparsing constraint and the requirement to use cached parser artifacts / red-tree cursor utilities. -- PR: https://github.com/prisma/prisma-next/pull/871. - -**Open review comments fetched before dispatch:** - -- `packages/1-framework/3-tooling/language-server/src/server.ts` line 299, CodeRabbit: refresh artifacts from current `document.getText()` before classifying completions if the cached artifact can be stale. -- `apps/lsp-playground/src/cli.ts` line 199, CodeRabbit: stop using incoming `Host` header as the URL parsing base; parse against a fixed local base or return 400 on parse failure. -- `packages/1-framework/3-tooling/language-server/src/completion-context.ts` line 27, SevInf: question whether that type/member is needed. -- `packages/1-framework/3-tooling/language-server/src/completion-context.ts` line 85, SevInf: property is not used anywhere. -- `packages/1-framework/3-tooling/language-server/src/completion-context.ts` line 100, SevInf: use `SyntaxNode` sibling navigation methods rather than reinventing sibling lookup. -- `packages/1-framework/3-tooling/language-server/src/completion-context.ts` line 638, SevInf: use binary search if `SyntaxNode` allows indexed children access. -- `packages/1-framework/3-tooling/language-server/src/completion-context.ts` line 643, SevInf: condition seems to only need “offset directly at end of current token”. -- `packages/1-framework/3-tooling/language-server/src/completion-context.ts` line 661, SevInf: simplify object construction; explicit `undefined` property values are acceptable. -- `packages/1-framework/3-tooling/language-server/src/completion-context.ts` line 677, SevInf: check whether parser module already has a helper for this. - -## Operational metadata - -- **Model tier:** mid — this is a focused code-quality refactor with tests across two packages, not a broad architecture redesign. -- **Time-box:** 90 minutes wall-clock. Overrun → halt and report exact remaining work. -- **Halt conditions:** Halt if the desired refactor requires changing parser grammar, if the completion semantic contract becomes ambiguous, if more than one parser package API must be redesigned, if validation exposes unrelated failures, or if you need to reply to GitHub comments. diff --git a/projects/lsp-autocomplete/slices/top-level-keyword-completions/plan.md b/projects/lsp-autocomplete/slices/top-level-keyword-completions/plan.md deleted file mode 100644 index 70adec2311..0000000000 --- a/projects/lsp-autocomplete/slices/top-level-keyword-completions/plan.md +++ /dev/null @@ -1,53 +0,0 @@ -# Slice plan: top-level-keyword-completions - -**Spec:** `projects/lsp-autocomplete/slices/top-level-keyword-completions/spec.md` -**Parent project:** `projects/lsp-autocomplete/spec.md` -**Linear issue:** [TML-2947](https://linear.app/prisma-company/issue/TML-2947/lsp-autocomplete-top-level-keyword-completions) - -## Dispatch plan - -### Dispatch 1: declaration-keyword-completions - -- **Outcome:** Configured PSL inputs complete declaration keywords at document top level and inside namespace bodies, with descriptor-backed generic block keywords and client-capability-gated snippet/plain insertion. -- **Builds on:** The existing LSP completion request path, completion context classifier, provider dispatch seam, generic block descriptor candidate source, namespace-aware type-position completion work, and generic block key completion work already present on the branch. -- **Hands to:** A completed top-level keyword completion surface for PR review: scoped declaration-position classification, scope-appropriate keyword candidates, snippet/plain insertion behavior, server capability threading, tests, and README coverage. -- **Focus:** Write tests before implementation; extend the language-server completion context with document-level and namespace-body declaration keyword positions; add provider output for native PSL block keywords and descriptor-backed generic block keywords; thread `clientSupportsSnippets` from initialize capabilities to provider input; emit snippet items only for snippet-capable clients and plain-text items otherwise; keep ordinary PSL attributes, attribute arguments, generic block values, relation-aware completions, and completion-marker reparsing out of scope; preserve existing model type, namespace member, generic block key, diagnostics, formatting, and folding-range behavior. -- **Completed when:** - - Classifier/provider/server tests cover blank and partial document-level declaration keyword positions; blank and partial namespace-body declaration keyword positions; namespace-body exclusion of `namespace` and `types`; descriptor-backed generic block keyword candidates; snippet-supported output; plain-text fallback output; and unsupported ordinary `@` / `@@` attribute contexts returning no scoped keyword suggestions. - - The server derives snippet support from `InitializeParams.capabilities.textDocument?.completion?.completionItem?.snippetSupport === true` and threads the boolean into completion provider input without assuming snippets for CodeMirror-style clients. - - The README documents top-level and namespace-body keyword completion scope, snippet capability gating, and the continued exclusion of ordinary attributes and attribute arguments. -- **Validation gates:** - - `pnpm --filter @prisma-next/language-server test` - - `pnpm --filter @prisma-next/language-server typecheck` - - `pnpm --filter @prisma-next/language-server lint` - - If parser exports or generated parser declarations are changed: `pnpm --filter @prisma-next/psl-parser build`, then rerun the language-server gates. - -### Dispatch 2: review-feedback-and-classifier-navigation - -- **Outcome:** PR review feedback is addressed with the completion classifier refactored away from repeated whole-tree traversals and toward existing red-tree / AST navigation primitives. -- **Builds on:** Dispatch 1's completion classifier, provider, server wiring, tests, and README updates. -- **Hands to:** A review-cleaner autocomplete implementation where `completion-context.ts` uses existing parser navigation APIs for nearest tokens, siblings, ancestors, and source offsets; stale completion artifacts are refreshed from the current buffer; playground runtime endpoint path parsing is robust against malformed `Host` headers; and tests/validation cover the changed behavior. -- **Focus:** Inspect the parser red/green node and AST helper APIs before editing; write or adjust tests first where behavior changes; remove unused context fields and helper indirection called out in review; avoid replying to or resolving GitHub review threads; keep completion semantics unchanged except where tests expose stale-buffer or cursor-context bugs. -- **Completed when:** - - Review comments on `completion-context.ts`, `server.ts`, and `apps/lsp-playground/src/cli.ts` are either implemented or explicitly reported as not applicable in the dispatch wrap-up. - - `completion-context.ts` no longer does avoidable repeated whole-AST searches for current/previous context when existing node/token navigation APIs can answer the question locally. - - Relevant tests cover any stale-buffer completion refresh behavior and classifier behavior preserved by the refactor. -- **Validation gates:** - - `pnpm --filter @prisma-next/language-server test` - - `pnpm --filter @prisma-next/language-server typecheck` - - `pnpm --filter @prisma-next/language-server lint` - - `pnpm --filter @prisma-next/lsp-playground typecheck` - - `pnpm --filter @prisma-next/lsp-playground lint` - -## Dispatch-INVEST check - -- **Independent:** The dispatch produces a complete reviewable surface on top of the already-landed completion route and does not require a sibling slice to merge concurrently. -- **Negotiable:** The dispatch pins the outcome and capability behavior while leaving implementation discovery over the existing classifier/provider/server files to the executor. -- **Valuable:** It directly closes the user's requested declaration-keyword completion gap, including namespace-body behavior and snippet/plain insertion variants. -- **Estimable:** The completed-when checklist is binary and covered by tests plus package gates. -- **Small:** The work is one coherent language-server completion surface in one package, with one syntactic context family and one provider output family. -- **Testable:** The language-server package tests/typecheck/lint gates verify the slice; parser build is only required if implementation changes parser declarations. - -## Open items - -None. diff --git a/projects/lsp-autocomplete/slices/top-level-keyword-completions/spec.md b/projects/lsp-autocomplete/slices/top-level-keyword-completions/spec.md deleted file mode 100644 index 73def4815d..0000000000 --- a/projects/lsp-autocomplete/slices/top-level-keyword-completions/spec.md +++ /dev/null @@ -1,99 +0,0 @@ -# Slice: top-level-keyword-completions - -Parent project: `projects/lsp-autocomplete/`. This slice contributes declaration-keyword completion for configured PSL inputs at document top level and inside namespace bodies. - -## At a glance - -Configured PSL documents answer `textDocument/completion` where the cursor is positioned to start a block declaration. The slice adds native PSL declaration keywords, descriptor-backed generic block keywords, namespace-body filtering, and snippet/plain-text insertion variants gated by client completion capabilities. - -## Chosen design - -The existing completion route remains the only LSP entry point. This slice extends the classifier/provider seam with a declaration-keyword context instead of adding a second completion path: - -```text -textDocument/completion - → configured PSL input check - → cached DocumentAst / SourceFile / SymbolTable lookup - → SourceFile.offsetAt(position) - → completion context analysis over the existing red tree + AST ancestors - → declaration-keyword provider - → CompletionItem[] -``` - -The classifier recognizes declaration positions in two scopes: - -| Scope | Completion position | Native keyword candidates | -| --- | --- | --- | -| `document` | top-level document body | `model`, `type`, `types`, `namespace` | -| `namespace` | direct body of a `namespace` declaration | `model`, `type` | - -Both scopes also include descriptor-backed generic PSL block keywords from `project.controlStack.pslBlockDescriptors`. Namespace-body completion deliberately excludes nested `namespace` and `types` because the parser diagnoses those inside namespaces even though recovery may still produce nodes for invalid syntax. - -Prefix handling follows the existing tsserver-style previous/current token shape already used by model type and generic block completions: blank declaration positions have an empty prefix, partial identifiers like `mo|` filter to matching declaration keywords, and replacement starts at the beginning of the typed keyword prefix. Comments, model fields, ordinary PSL `@` / `@@` attributes, attribute arguments, type positions, and generic block member positions stay unsupported for this provider. - -The provider emits the same labels in both insertion modes, but insertion detail is capability-gated: - -- When `clientSupportsSnippets` is `true`, block keywords use `InsertTextFormat.Snippet` and snippets place the final cursor inside the new block body, for example `model ${1:Name} {\n $0\n}`. -- When `clientSupportsSnippets` is `false`, the server returns plain-text completion items with no snippet syntax. - -The language server derives `clientSupportsSnippets` from `InitializeParams.capabilities.textDocument?.completion?.completionItem?.snippetSupport === true` and threads that boolean into the completion provider input. This keeps CodeMirror-style clients that do not advertise snippet support on plain text while allowing snippet-capable editors to place the cursor inside completed blocks. - -The existing namespace-member completion flow should remain intact. If the server capability does not already advertise `.` as a completion trigger character, this slice may add `triggerCharacters: ['.']` while preserving completion behavior for clients that invoke completion manually. - -## Coherence rationale - -This is one reviewable slice because declaration-position classification, keyword candidate selection, and snippet/plain insertion are one user-visible completion surface. Splitting snippets into a separate slice would ship the new keyword provider in the wrong insertion shape for snippet-capable clients, while folding ordinary attribute completions into this slice would add a separate syntactic domain and break the project's non-goals. - -## Scope - -**In:** - -- Declaration-keyword completion contexts at document top level and direct namespace-body declaration positions. -- Native PSL declaration keyword candidates for each scope. -- Descriptor-backed generic block keyword candidates from `pslBlockDescriptors` in both document and namespace scopes. -- Prefix filtering and replacement ranges for blank and partial declaration keywords. -- Snippet completion items when the client advertises snippet support. -- Plain-text fallback completion items when snippets are not supported. -- Threading client snippet capability from LSP initialize state to completion provider input. -- Tests for classifier/provider/server behavior and README updates for the expanded completion scope. - -**Out:** - -- Ordinary PSL `@` / `@@` field or model attribute name completions. -- Attribute argument completions. -- Generic block value completions beyond descriptor-backed block/key surfaces already in scope for the project. -- Relation-aware completions. -- Nested namespace or `types` suggestions inside namespace bodies. -- Completion-marker reparsing. - -## Pre-investigated edge cases - -| Edge case | Disposition | Notes | -| --------- | ----------- | ----- | -| Namespace-body native keyword set differs from document top-level keyword set. | In scope. | `model`, `type`, and descriptor-backed generic blocks are namespace-valid; `namespace` and `types` are document-only suggestions for this slice. | -| Snippet support varies by LSP client. | In scope. | Snippet syntax must be gated by the initialize capability and have a plain-text fallback. | - -## Slice-specific done conditions - -- [ ] Configured PSL completion suggests document-level and namespace-body declaration keywords with scope-appropriate filtering. -- [ ] Snippet-capable clients receive snippet insertion items, and non-snippet clients receive plain-text insertion items for the same keyword labels. -- [ ] The slice does not add ordinary PSL `@` / `@@` attribute completions or completion-marker reparsing. - -## Open Questions - -None. - -## References - -- Parent project: `projects/lsp-autocomplete/spec.md` -- Project plan: `projects/lsp-autocomplete/plan.md` -- Linear issue: [TML-2947](https://linear.app/prisma-company/issue/TML-2947/lsp-autocomplete-top-level-keyword-completions) -- Existing completion slice: `projects/lsp-autocomplete/slices/model-type-completions/` -- Relevant code surfaces: - - `packages/1-framework/3-tooling/language-server/src/completion-context.ts` - - `packages/1-framework/3-tooling/language-server/src/completion-provider.ts` - - `packages/1-framework/3-tooling/language-server/src/server.ts` - - `packages/1-framework/3-tooling/language-server/test/completion-context.test.ts` - - `packages/1-framework/3-tooling/language-server/test/completion-provider.test.ts` - - `packages/1-framework/3-tooling/language-server/test/server.test.ts` - - `packages/1-framework/3-tooling/language-server/README.md` diff --git a/projects/lsp-autocomplete/spec.md b/projects/lsp-autocomplete/spec.md deleted file mode 100644 index e3bbc617b1..0000000000 --- a/projects/lsp-autocomplete/spec.md +++ /dev/null @@ -1,113 +0,0 @@ -# lsp-autocomplete - -## Purpose - -Give PSL authors the first useful LSP autocomplete loop for schema authoring while validating the repo's cursor-context design against the existing recovered CST/AST. The project exists to prove completion can be built on the current parser, source-file, red-tree, and symbol-table artifacts without introducing speculative completion-marker reparsing. - -## At a glance - -The first autocomplete surface is intentionally narrow: when editing a configured PSL input, the language server classifies the cursor against the cached `DocumentAst`, `SourceFile`, and project `SymbolTable`, then routes to focused completion providers. - -```prisma -model Post { - author | -} -``` - -At a model field type position, completion suggests valid type names from the configured scalar set and known schema symbols such as models, composite types, scalar aliases, and type aliases. Qualified type positions are in scope: completion must handle bare `User`, namespace-qualified `auth.User`, and contract-space-qualified `supabase:auth.User` prefixes in the existing type-annotation grammar. - -```prisma -datasource db { - | -} -``` - -Inside a generic block, completion suggests descriptor-backed block entries/parameters for that block kind. In this spec, “generic block attributes” means the key/value-style generic block members represented by `GenericBlockDeclaration` / `KeyValuePairAst` and extension-block descriptors. Ordinary PSL `@` / `@@` field/model attributes are deliberately out of scope for this part. - -At declaration positions, completion suggests top-level PSL block keywords and descriptor-backed generic block keywords. Document top level includes `model`, `type`, `types`, and `namespace`; namespace bodies include only namespace-valid declaration keywords such as `model`, `type`, and descriptor-backed generic block keywords. Completion items support snippet-capable clients while retaining plain-text fallbacks for clients that do not advertise snippet support. - -The intended shape is: - -```text -LSP completion position - → configured PSL document check - → SourceFile.offsetAt(position) - → red-tree touching token / previous token / ancestor context - → CompletionAnalysis - → explicit provider dispatch - → LSP CompletionItem[] -``` - -## Non-goals - -- Ordinary PSL `@` / `@@` field or model attribute name completions. -- Attribute argument completions, including relation-specific argument completions. -- Relation-aware completions such as `fields`, `references`, or inverse relation suggestions. -- Completion for unopened configured inputs or cross-file project tables beyond the current cached project artifact shape. -- Speculative completion-marker reparsing. -- Hover, go-to-definition, semantic tokens, diagnostics redesign, or formatting changes. -- A complete ranking/scoring engine for completion entries beyond stable, useful ordering for the scoped providers. - -## Place in the larger world - -This project layers on the existing language-server and parser artifacts rather than replacing them. - -- `packages/1-framework/3-tooling/language-server/src/server.ts` currently supports diagnostics and formatting. It also exposes `getDocumentAst()` and `getProjectSymbolTable()` as future-feature hooks. -- `packages/1-framework/3-tooling/language-server/src/project-artifacts.ts` preserves each open document's `DocumentAst` and `SourceFile`, plus one project `SymbolTable`. -- `packages/1-framework/3-tooling/language-server/src/pipeline.ts` runs `parse()` and `buildSymbolTable()` and documents that malformed input should not throw. -- `packages/1-framework/2-authoring/psl-parser/src/parse.ts` already has fault-tolerant recovery for block declarations, fields, type annotations, attributes, and generic block key/value entries. Its `parseTypeAnnotation()` consumes a `QualifiedName`, and `parseQualifiedName()` accepts `[space ':']? Ident ('.' Ident)*`, matching type references such as `auth.User` and `supabase:auth.User`. -- `packages/1-framework/2-authoring/psl-parser/src/syntax/red.ts` provides offsets, parents, ancestors, child iteration, descendants, and token iteration. -- `packages/1-framework/2-authoring/psl-parser/src/symbol-table.ts` provides model, composite type, scalar, type-alias, block, and field symbols that completion can reuse. The resolved field shape already splits type references into `typeName`, `typeNamespaceId`, and `typeContractSpaceId`, and reports `PSL_INVALID_QUALIFIED_TYPE` for over-qualified field types. -- Generic block entry completions depend on the `pslBlockDescriptors` already passed through the language-server control stack. - -No contract or adapter impact is expected. This project changes editor tooling behavior only; it should not change emitted contracts, runtime behavior, migration output, or target adapters. - -## Cross-cutting requirements - -- Completion uses the existing cached parse artifacts for the open configured PSL input. It must not create a parallel parse pipeline for normal operation. -- Cursor classification is implemented as a pure, testable layer that maps `(document, sourceFile, offset, symbolTable, controlStack)` to a typed completion context. -- The classifier uses tsserver-style previous/current token handling where prefix typing matters, for example partial identifiers after a field name or inside a generic block. -- Provider routing is explicit. Completion providers should not independently rediscover syntactic context. -- Completion is available only for configured PSL inputs, matching the current diagnostics/formatting ownership rules. -- Diagnostics and formatting behavior remain unchanged while completion is added. -- The first provider set is limited to model field type completions, including namespace-qualified type positions, generic block entry/parameter completions, and declaration keyword completions at document top level and inside namespace bodies. -- Ordinary PSL `@` / `@@` attribute contexts must not accidentally receive the new generic block, model type, or declaration keyword suggestions. -- Tests are written before or alongside implementation changes. - -## Transitional-shape constraints - -- Do not introduce speculative completion-marker reparsing unless a concrete parser/AST gap is proven by a failing test that cannot be solved with red-tree cursor utilities. -- Parser or syntax export changes must be minimal and justified by completion’s cursor-context needs. -- Each intermediate slice keeps diagnostics and formatting tests green. -- The language server may advertise completion only once unsupported contexts safely return empty results rather than misleading suggestions. -- Generic block completions may initially be descriptor-name focused; richer value-specific completions can come later without changing the classifier shape. - -## Project Definition of Done - -- [ ] Team-DoD floor items inherited from `drive/calibration/dod.md`. -- [ ] The language server advertises completion support for configured PSL inputs without changing diagnostics or formatting behavior. -- [ ] Model field type positions complete configured scalar types plus visible model, composite type, scalar, and type-alias symbols from the current project symbol table, including namespace-qualified and contract-space-qualified type prefixes. -- [ ] Generic block entry/parameter positions complete descriptor-backed keys or values for the current `GenericBlockDeclaration` where descriptor data is available. -- [ ] Document top-level and namespace-body declaration positions complete scope-appropriate native PSL block keywords plus descriptor-backed generic block keywords, with snippet-capable and plain-text client behavior covered. -- [ ] Ordinary PSL `@` / `@@` field/model attribute contexts return no scoped suggestions from this project’s providers. -- [ ] Cursor-context classification has focused tests for blank model bodies, field-name prefixes, bare and namespace-qualified field-type positions, generic block blank lines, generic block partial keys, declaration keyword positions at document top level and inside namespace bodies, comments/trivia, and unsupported contexts. -- [ ] LSP/server tests cover completion requests against an open configured PSL document. -- [ ] Package documentation or README notes the supported completion scope and explicitly names the out-of-scope attribute surfaces. -- [ ] Validation gates for touched packages pass, including typecheck, lint, and relevant package tests. - -## Open Questions - -None. - -## References - -- Linear Project: not linked in this chat. -- Sibling / dependent projects: current language-server diagnostics and formatting surfaces. -- ADRs: N/A — no durable architectural shift expected beyond the local completion-context pattern. -- Code references: - - `packages/1-framework/3-tooling/language-server/src/server.ts` - - `packages/1-framework/3-tooling/language-server/src/project-artifacts.ts` - - `packages/1-framework/3-tooling/language-server/src/pipeline.ts` - - `packages/1-framework/2-authoring/psl-parser/src/parse.ts` - - `packages/1-framework/2-authoring/psl-parser/src/syntax/red.ts` - - `packages/1-framework/2-authoring/psl-parser/src/symbol-table.ts` diff --git a/projects/lsp-autocomplete/trace.jsonl b/projects/lsp-autocomplete/trace.jsonl deleted file mode 100644 index 20e411b7ed..0000000000 --- a/projects/lsp-autocomplete/trace.jsonl +++ /dev/null @@ -1,37 +0,0 @@ -{"event_id":"4ed57784-402f-4dde-9a2e-4845293db750","event_type":"spec-authored","schema_version":"1","ts":"2026-06-24T15:00:58.405Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"spec_path":"projects/lsp-autocomplete/spec.md","spec_kind":"project","byte_length":7527,"edge_cases_count":null,"open_questions_count":0,"dod_items_count":9} -{"event_id":"0e623027-20e6-4b45-94d8-8785e391dbd9","event_type":"spec-amended","schema_version":"1","ts":"2026-06-24T15:06:37.042Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"spec_path":"projects/lsp-autocomplete/spec.md","spec_kind":"project","byte_length":8274,"bytes_delta":747,"edge_cases_count":null,"open_questions_count":0,"dod_items_count":9,"reason":"scope-shift","sections_changed":["At a glance","Place in the larger world","Cross-cutting requirements","Project Definition of Done"]} -{"event_id":"0f4cf6ce-9e0a-43b4-b1a9-bedb1d9176c8","event_type":"plan-authored","schema_version":"1","ts":"2026-06-24T16:21:14.109Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"plan_path":"projects/lsp-autocomplete/plan.md","plan_kind":"project","byte_length":4599,"dispatch_count":null,"slice_count":2,"dispatch_size_distribution":null,"open_items_count":0} -{"event_id":"aa153178-fea9-45ac-b5e4-ff673ddcea84","event_type":"health-check-fired","schema_version":"1","ts":"2026-06-25T11:03:43.828Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"cadence":"opening-rollup","drift_signal_count":0,"max_drift_severity":"none","recommended_next":"Start slice model-type-completions (TML-2946)."} -{"event_id":"4751a039-5eb0-4b7f-bb7a-8462fc4053ed","event_type":"spec-authored","schema_version":"1","ts":"2026-06-25T11:21:45.497Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"spec_path":"projects/lsp-autocomplete/slices/model-type-completions/spec.md","spec_kind":"slice","byte_length":6762,"edge_cases_count":0,"open_questions_count":0,"dod_items_count":2} -{"event_id":"88acb6b4-94da-4676-9291-5cdc9036976a","event_type":"plan-authored","schema_version":"1","ts":"2026-06-25T11:21:45.498Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"plan_path":"projects/lsp-autocomplete/slices/model-type-completions/plan.md","plan_kind":"slice","byte_length":6687,"dispatch_count":4,"slice_count":null,"dispatch_size_distribution":{"S":0,"M":4,"L":0,"XL":0},"open_items_count":0} -{"event_id":"e3793112-e9d2-4392-bfec-80f379b37cd6","event_type":"slice-started","schema_version":"1","ts":"2026-06-25T11:22:23.553Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"slice_slug":"model-type-completions","slice_index":1,"linear_ref":"TML-2946"} -{"event_id":"ff05c1d9-417e-48ed-994c-46fc247b5126","event_type":"dispatch-start","schema_version":"1","ts":"2026-06-25T11:25:08.893Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"5947dc2d-4f4b-49d4-a1fb-1f4afbfdca23","dispatch_name":"cursor-classifier-substrate","subagent_type":"implementer/fast","model":"GPT-5.5","parent_dispatch_id":null} -{"event_id":"9835a693-0e26-46c2-bf93-2138560abe24","event_type":"round-start","schema_version":"1","ts":"2026-06-25T11:25:08.893Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"5947dc2d-4f4b-49d4-a1fb-1f4afbfdca23","round_id":"d645299c-0031-4a43-9b59-ed920dcff41d","round_number":1} -{"event_id":"29283204-e15d-4429-b877-2c3281daaf74","event_type":"brief-issued","schema_version":"1","ts":"2026-06-25T11:25:08.893Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"5947dc2d-4f4b-49d4-a1fb-1f4afbfdca23","round_id":"d645299c-0031-4a43-9b59-ed920dcff41d","brief_byte_length":4573,"brief_content_hash":"fb41d44eb8f28dd310937df4d66494ed14a24313ac737100aecbbde20a99c4ee","brief_disposition":"initial"} -{"event_id":"0b88efbe-8ab0-44c5-b51c-58ed28343cb8","event_type":"round-end","schema_version":"1","ts":"2026-06-25T11:57:39.436Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"5947dc2d-4f4b-49d4-a1fb-1f4afbfdca23","round_id":"d645299c-0031-4a43-9b59-ed920dcff41d","verdict":"satisfied","findings_filed":0,"wall_clock_ms":1950543} -{"event_id":"29d1e947-700e-43fa-a832-0296691673cd","event_type":"dispatch-end","schema_version":"1","ts":"2026-06-25T11:57:39.436Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"5947dc2d-4f4b-49d4-a1fb-1f4afbfdca23","result":"completed","wall_clock_ms":1950543} -{"event_id":"1b8b899e-6b04-4f86-94c8-0b97690b7471","event_type":"dispatch-start","schema_version":"1","ts":"2026-06-25T11:58:59.102Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"e5d21f42-c48e-46bc-a8f4-1839303f2958","dispatch_name":"model-type-provider","subagent_type":"implementer/fast","model":"GPT-5.5","parent_dispatch_id":"5947dc2d-4f4b-49d4-a1fb-1f4afbfdca23"} -{"event_id":"f00b324a-bede-4e3e-b8f0-322e5cc37cd4","event_type":"round-start","schema_version":"1","ts":"2026-06-25T11:58:59.102Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"e5d21f42-c48e-46bc-a8f4-1839303f2958","round_id":"b7306d31-7eb6-41e6-a7cf-f77c5ee71892","round_number":1} -{"event_id":"4440685f-19ea-4fcc-af06-b7dbe77721d1","event_type":"brief-issued","schema_version":"1","ts":"2026-06-25T11:58:59.102Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"e5d21f42-c48e-46bc-a8f4-1839303f2958","round_id":"b7306d31-7eb6-41e6-a7cf-f77c5ee71892","brief_byte_length":4036,"brief_content_hash":"f7024f5b519b4e39d690e12b236573d4d00384ad44a02bfd5f80da45a14ab17a","brief_disposition":"initial"} -{"event_id":"b87ad897-2907-4587-9a36-8716035ba9ea","event_type":"round-end","schema_version":"1","ts":"2026-06-25T12:18:52.716Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"e5d21f42-c48e-46bc-a8f4-1839303f2958","round_id":"b7306d31-7eb6-41e6-a7cf-f77c5ee71892","verdict":"satisfied","findings_filed":0,"wall_clock_ms":1193614} -{"event_id":"b181df1e-7e70-4d3b-b6d5-22850216340a","event_type":"dispatch-end","schema_version":"1","ts":"2026-06-25T12:18:52.716Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"e5d21f42-c48e-46bc-a8f4-1839303f2958","result":"completed","wall_clock_ms":1193614} -{"event_id":"d88d2aa7-b26b-4088-9845-461666d0d951","event_type":"dispatch-start","schema_version":"1","ts":"2026-06-25T12:20:48.885Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"0fb30343-7c83-48d5-af0a-c7e9dbfb771b","dispatch_name":"lsp-completion-route","subagent_type":"implementer/fast","model":"GPT-5.5","parent_dispatch_id":"e5d21f42-c48e-46bc-a8f4-1839303f2958"} -{"event_id":"420f4094-41fd-40d4-839b-f028971625df","event_type":"round-start","schema_version":"1","ts":"2026-06-25T12:20:48.885Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"0fb30343-7c83-48d5-af0a-c7e9dbfb771b","round_id":"c3095776-c25f-4d4c-b780-f50cab74c1a3","round_number":1} -{"event_id":"68736cb8-5b56-485b-9b76-734fee524bc9","event_type":"brief-issued","schema_version":"1","ts":"2026-06-25T12:20:48.885Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"0fb30343-7c83-48d5-af0a-c7e9dbfb771b","round_id":"c3095776-c25f-4d4c-b780-f50cab74c1a3","brief_byte_length":4826,"brief_content_hash":"d8c80db10794af37454b9085bc369eb9e98b1cdba1cad715289f5e17a94da2da","brief_disposition":"initial"} -{"event_id":"5391fb9b-f18a-4c8b-9c1a-056674e47147","event_type":"round-end","schema_version":"1","ts":"2026-06-25T13:11:28.273Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"0fb30343-7c83-48d5-af0a-c7e9dbfb771b","round_id":"c3095776-c25f-4d4c-b780-f50cab74c1a3","verdict":"satisfied","findings_filed":0,"wall_clock_ms":3039388} -{"event_id":"0fa28427-b23c-49cc-9a6a-f76a2eda10c3","event_type":"dispatch-end","schema_version":"1","ts":"2026-06-25T13:11:28.273Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"0fb30343-7c83-48d5-af0a-c7e9dbfb771b","result":"completed","wall_clock_ms":3039388} -{"event_id":"45e6d883-5c44-4a6c-b112-758e82a8fb2a","event_type":"dispatch-start","schema_version":"1","ts":"2026-06-25T13:12:48.946Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"8c39fcaa-7306-425f-982b-fd5b4f7cdc5c","dispatch_name":"scope-docs-and-final-guardrails","subagent_type":"implementer/fast","model":"GPT-5.5","parent_dispatch_id":"0fb30343-7c83-48d5-af0a-c7e9dbfb771b"} -{"event_id":"ba489f0c-a63f-4984-b69c-747fb579bdb6","event_type":"round-start","schema_version":"1","ts":"2026-06-25T13:12:48.946Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"8c39fcaa-7306-425f-982b-fd5b4f7cdc5c","round_id":"dc7ce623-26b7-41fb-9b78-34b6962a7269","round_number":1} -{"event_id":"3c8d8e21-eb71-43e4-9381-db9806243c25","event_type":"brief-issued","schema_version":"1","ts":"2026-06-25T13:12:48.946Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"8c39fcaa-7306-425f-982b-fd5b4f7cdc5c","round_id":"dc7ce623-26b7-41fb-9b78-34b6962a7269","brief_byte_length":4831,"brief_content_hash":"b5629c09b472447ef5e3be56f6efab6b3bf701d409279d65b33dd9939ed5090d","brief_disposition":"initial"} -{"event_id":"11258695-c9fa-4a3f-9150-8b19c9f4528b","event_type":"round-end","schema_version":"1","ts":"2026-06-25T13:21:21.652Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"8c39fcaa-7306-425f-982b-fd5b4f7cdc5c","round_id":"dc7ce623-26b7-41fb-9b78-34b6962a7269","verdict":"satisfied","findings_filed":0,"wall_clock_ms":512706} -{"event_id":"ff35f2ce-eb15-4d7a-92ed-0f9f7f17aaf6","event_type":"dispatch-end","schema_version":"1","ts":"2026-06-25T13:21:21.652Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"dispatch_id":"8c39fcaa-7306-425f-982b-fd5b4f7cdc5c","result":"completed","wall_clock_ms":512706} -{"event_id":"9077f136-092e-4264-a069-49526e026e1c","schema_version":"1","ts":"2026-06-25T16:10:07.920Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"event_type":"spec-amended","spec_path":"projects/lsp-autocomplete/spec.md","spec_kind":"project","byte_length":9155,"bytes_delta":881,"edge_cases_count":null,"open_questions_count":0,"dod_items_count":10,"reason":"scope-shift","sections_changed":["At a glance","Cross-cutting requirements","Project Definition of Done"]} -{"event_id":"a51509d6-a65c-46b8-8299-f14d78cfa043","schema_version":"1","ts":"2026-06-25T16:10:08.389Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"event_type":"plan-amended","plan_path":"projects/lsp-autocomplete/plan.md","plan_kind":"project","byte_length":6360,"bytes_delta":1761,"dispatch_count":null,"slice_count":3,"dispatch_size_distribution":null,"open_items_count":0,"reason":"scope-shift","dispatches_added":null,"dispatches_removed":null,"dispatches_resized":null} -{"event_id":"e665fc15-4825-4946-a36e-bb2cd8004166","schema_version":"1","ts":"2026-06-25T16:10:08.866Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"event_type":"spec-authored","spec_path":"projects/lsp-autocomplete/slices/top-level-keyword-completions/spec.md","spec_kind":"slice","byte_length":6665,"edge_cases_count":2,"open_questions_count":0,"dod_items_count":3} -{"event_id":"b2d09aa2-f68a-4774-b87a-7759439c28ee","schema_version":"1","ts":"2026-06-25T16:10:09.338Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"event_type":"plan-authored","plan_path":"projects/lsp-autocomplete/slices/top-level-keyword-completions/plan.md","plan_kind":"slice","byte_length":4027,"dispatch_count":1,"slice_count":null,"dispatch_size_distribution":{"S":0,"M":1,"L":0,"XL":0},"open_items_count":0} -{"event_id":"d5faa1b8-1354-4f35-a338-e1a3441c81ae","schema_version":"1","ts":"2026-06-25T16:10:21.998Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"event_type":"slice-started","slice_slug":"top-level-keyword-completions","slice_index":3,"linear_ref":"TML-2947"} -{"event_id":"1e748a14-288a-4e31-ac0b-6957e8ceb919","schema_version":"1","ts":"2026-06-25T16:12:45.212Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"event_type":"dispatch-start","dispatch_id":"a8e0dc43-b04f-4edf-ab22-d79f80cc5957","dispatch_name":"declaration-keyword-completions","subagent_type":"implementer/mid","model":"GPT-5.5","parent_dispatch_id":"8c39fcaa-7306-425f-982b-fd5b4f7cdc5c"} -{"event_id":"4bdd6834-21bc-4317-894b-4244f5e6d3e5","schema_version":"1","ts":"2026-06-25T16:12:45.697Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"event_type":"round-start","dispatch_id":"a8e0dc43-b04f-4edf-ab22-d79f80cc5957","round_id":"f42bee87-498e-41bf-8cbd-f1a497764e2f","round_number":1} -{"event_id":"08e80c4d-fa6d-48e1-99b9-b9b4f8c9f28d","schema_version":"1","ts":"2026-06-25T16:12:46.157Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"event_type":"brief-issued","dispatch_id":"a8e0dc43-b04f-4edf-ab22-d79f80cc5957","round_id":"f42bee87-498e-41bf-8cbd-f1a497764e2f","brief_byte_length":5865,"brief_content_hash":"8b7927766a32521446ef7153fd7d50c498bd5d45b741b65dc9e535664a5ab981","brief_disposition":"initial"} -{"event_id":"e9d73a33-5b37-48d2-ac4f-d55c91facfd7","schema_version":"1","ts":"2026-06-26T09:45:00.921Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"event_type":"round-end","dispatch_id":"a8e0dc43-b04f-4edf-ab22-d79f80cc5957","round_id":"f42bee87-498e-41bf-8cbd-f1a497764e2f","verdict":"satisfied","findings_filed":0,"wall_clock_ms":63121996} -{"event_id":"fd4fdc3b-fc24-41c8-ac83-1e7ae273e902","schema_version":"1","ts":"2026-06-26T09:45:01.388Z","project_run_id":"lsp-autocomplete","orchestrator_agent_id":null,"event_type":"dispatch-end","dispatch_id":"a8e0dc43-b04f-4edf-ab22-d79f80cc5957","result":"completed","wall_clock_ms":63122481} From c58a12ff88c71142a9d5b963b1ad72f1d7f4f1f8 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 29 Jun 2026 13:10:07 +0000 Subject: [PATCH 16/54] refactor(psl-parser): model TokenAtOffset with a private discriminated union Replaces the two nullable left/right fields with a private none/single/between union, so the illegal left-only state is unrepresentable and isBetween no longer rides on reference identity. Public API (leftBiased/rightBiased/isEmpty/isBetween) is unchanged. Signed-off-by: Serhii Tatarintsev --- .../2-authoring/psl-parser/src/syntax/red.ts | 41 +++++++++++++------ .../psl-parser/test/syntax/red.test.ts | 41 ++++++++++++++++++- 2 files changed, 69 insertions(+), 13 deletions(-) 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 36a08f9d13..f8c6a79370 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 @@ -66,41 +66,58 @@ export type SyntaxElement = SyntaxNode | SyntaxToken; * 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 #left: SyntaxToken | undefined; - readonly #right: SyntaxToken | undefined; + readonly #state: TokenAtOffsetState; - private constructor(left: SyntaxToken | undefined, right: SyntaxToken | undefined) { - this.#left = left; - this.#right = right; + private constructor(state: TokenAtOffsetState) { + this.#state = state; } static none(): TokenAtOffset { - return new TokenAtOffset(undefined, undefined); + return new TokenAtOffset({ kind: 'none' }); } static single(token: SyntaxToken): TokenAtOffset { - return new TokenAtOffset(token, token); + return new TokenAtOffset({ kind: 'single', token }); } static between(left: SyntaxToken, right: SyntaxToken): TokenAtOffset { - return new TokenAtOffset(left, right); + return new TokenAtOffset({ kind: 'between', left, right }); } get isEmpty(): boolean { - return this.#left === undefined && this.#right === undefined; + return this.#state.kind === 'none'; } get isBetween(): boolean { - return this.#left !== undefined && this.#right !== undefined && this.#left !== this.#right; + return this.#state.kind === 'between'; } leftBiased(): SyntaxToken | undefined { - return this.#left; + switch (this.#state.kind) { + case 'none': + return undefined; + case 'single': + return this.#state.token; + case 'between': + return this.#state.left; + } } rightBiased(): SyntaxToken | undefined { - return this.#right; + switch (this.#state.kind) { + case 'none': + return undefined; + case 'single': + return this.#state.token; + case 'between': + return this.#state.right; + } } } 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 657f78fd0f..0a0e77ee00 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,6 @@ import { describe, expect, it } from 'vitest'; import { GreenNodeBuilder } from '../../src/syntax/green-builder'; -import { createSyntaxTree, SyntaxNode, SyntaxToken } 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. */ @@ -346,6 +346,45 @@ describe('SyntaxNode.tokenAtOffset', () => { }); }); +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()); From 97099f809d4015dbb9f3a989a960ff9d21f4ddb4 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 29 Jun 2026 13:10:18 +0000 Subject: [PATCH 17/54] refactor(language-server): dissolve TokenContext into on-demand token navigation Removes the precomputed current/touching/previousSignificant record and derives each from the TokenAtOffset anchor at the point of use (leftBiased/rightBiased + previousNonTriviaToken), matching the rust-analyzer idiom. No behavioural change; completion-context suite unchanged and green. Signed-off-by: Serhii Tatarintsev --- .../language-server/src/completion-context.ts | 78 ++++++++++--------- 1 file changed, 40 insertions(+), 38 deletions(-) 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 index 2f82a80edc..41dc3d9236 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-context.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-context.ts @@ -73,27 +73,23 @@ export type PslCompletionContext = | ModelFieldTypeCompletionContext | UnsupportedPslCompletionContext; -interface TokenContext { - readonly current: SyntaxToken | undefined; - readonly previousSignificant: SyntaxToken | undefined; - readonly touching: SyntaxToken | undefined; -} - export function classifyPslCompletionContext( input: ClassifyPslCompletionContextInput, ): PslCompletionContext { const root = input.document.syntax; const offset = input.sourceFile.offsetAt(input.position); const at = root.tokenAtOffset(offset); - const tokenContext = tokenContextAt(at, offset); - if (tokenContext.current?.kind === 'Comment' || tokenContext.touching?.kind === 'Comment') { + if ( + currentToken(at, offset)?.kind === 'Comment' || + touchingToken(at, offset)?.kind === 'Comment' + ) { return unsupported(offset); } // Anchor on the token left of the cursor and navigate outward via // `token.parent` rather than scanning the whole tree. const anchorNode = at.leftBiased()?.parent; - const previousSignificantNode = tokenContext.previousSignificant?.parent; + const previousSignificantNode = previousSignificantToken(at, offset)?.parent; const contextNode = nodeForContext(anchorNode, previousSignificantNode); if (hasUnsupportedAncestor(contextNode)) { return unsupported(offset); @@ -101,13 +97,13 @@ export function classifyPslCompletionContext( // The edit replaces the identifier under the cursor, or is empty when the // cursor sits in trivia. - const replacementStartOffset = sourceRangeStart(tokenContext, offset); + const replacementStartOffset = sourceRangeStart(at, offset); const declarationKeywordContext = classifyDeclarationKeyword({ node: contextNode, offset, source: input.sourceFile.text, - tokenContext, + at, replacementStartOffset, }); if (declarationKeywordContext !== undefined) { @@ -118,7 +114,7 @@ export function classifyPslCompletionContext( node: contextNode, offset, source: input.sourceFile.text, - tokenContext, + at, replacementStartOffset, }); if (genericBlockContext !== undefined) { @@ -218,7 +214,7 @@ function classifyDeclarationKeyword(input: { readonly node: SyntaxNode | undefined; readonly offset: number; readonly source: string; - readonly tokenContext: TokenContext; + readonly at: TokenAtOffset; readonly replacementStartOffset: number; }): DeclarationKeywordCompletionContext | undefined { if (isInsideNonDeclarationKeywordBody(input.node, input.offset)) { @@ -228,7 +224,7 @@ function classifyDeclarationKeyword(input: { const namespace = closestAst(input.node, NamespaceDeclarationAst.cast); const scope = namespaceBodyContainsOffset(namespace, input.offset) ? 'namespace' : 'document'; - const prefixToken = cursorIdentifier(input.tokenContext, input.offset); + const prefixToken = cursorIdentifier(input.at, input.offset); if (!declarationKeywordAllowed(prefixToken, namespace, input)) { return undefined; } @@ -250,12 +246,12 @@ function classifyDeclarationKeyword(input: { function declarationKeywordAllowed( prefixToken: SyntaxToken | undefined, namespace: NamespaceDeclarationAst | undefined, - input: { readonly offset: number; readonly tokenContext: TokenContext }, + input: { readonly offset: number; readonly at: TokenAtOffset }, ): boolean { const previous = prefixToken !== undefined ? previousNonTriviaToken(prefixToken) - : input.tokenContext.previousSignificant; + : previousSignificantToken(input.at, input.offset); if (previous === undefined) { return true; } @@ -321,7 +317,7 @@ function classifyGenericBlockParameter(input: { readonly node: SyntaxNode | undefined; readonly offset: number; readonly source: string; - readonly tokenContext: TokenContext; + readonly at: TokenAtOffset; readonly replacementStartOffset: number; }): PslCompletionContext | undefined { const block = closestAst(input.node, GenericBlockDeclarationAst.cast); @@ -344,7 +340,7 @@ function classifyGenericBlockParameter(input: { return unsupported(input.offset); } - if (input.tokenContext.previousSignificant?.kind === 'Equals') { + if (previousSignificantToken(input.at, input.offset)?.kind === 'Equals') { return unsupported(input.offset); } @@ -544,36 +540,42 @@ function closestAst( return undefined; } -function tokenContextAt(at: TokenAtOffset, offset: number): TokenContext { - const left = at.leftBiased(); +/** The token the cursor sits strictly inside, if any. */ +function currentToken(at: TokenAtOffset, offset: number): SyntaxToken | undefined { const right = at.rightBiased(); - const current = right !== undefined && offset < tokenEndOffset(right) ? right : undefined; - const touching = left !== undefined && tokenEndOffset(left) === offset ? left : undefined; - let previousSignificant: SyntaxToken | undefined; - if (left !== undefined) { - previousSignificant = - tokenEndOffset(left) <= offset && !isTrivia(left) ? left : previousNonTriviaToken(left); + return right !== undefined && offset < tokenEndOffset(right) ? right : undefined; +} + +/** The token whose right edge the cursor touches, if any. */ +function touchingToken(at: TokenAtOffset, offset: number): SyntaxToken | undefined { + const left = at.leftBiased(); + return left !== undefined && tokenEndOffset(left) === offset ? left : undefined; +} + +/** The nearest non-trivia token ending at or before the cursor. */ +function previousSignificantToken(at: TokenAtOffset, offset: number): SyntaxToken | undefined { + const left = at.leftBiased(); + if (left === undefined) { + return undefined; } - return { current, previousSignificant, touching }; + return tokenEndOffset(left) <= offset && !isTrivia(left) ? left : previousNonTriviaToken(left); } /** The identifier token the cursor is editing, if any. */ -function cursorIdentifier(tokenContext: TokenContext, offset: number): SyntaxToken | undefined { - if (tokenContext.current?.kind === 'Ident') { - return tokenContext.current; - } - if (tokenContext.touching?.kind === 'Ident') { - return tokenContext.touching; +function cursorIdentifier(at: TokenAtOffset, offset: number): SyntaxToken | undefined { + const current = currentToken(at, offset); + if (current?.kind === 'Ident') { + return current; } - const previous = tokenContext.previousSignificant; - if (previous?.kind === 'Ident' && tokenEndOffset(previous) === offset) { - return previous; + const touching = touchingToken(at, offset); + if (touching?.kind === 'Ident') { + return touching; } return undefined; } -function sourceRangeStart(tokenContext: TokenContext, offset: number): number { - return cursorIdentifier(tokenContext, offset)?.offset ?? offset; +function sourceRangeStart(at: TokenAtOffset, offset: number): number { + return cursorIdentifier(at, offset)?.offset ?? offset; } /** Whether a newline trivia token separates `from` from `toOffset`. */ From 23cda1b87f67c7a105e270e666d42cd16fea6533 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 29 Jun 2026 13:18:55 +0000 Subject: [PATCH 18/54] refactor(psl-parser): expose endOffset on SyntaxNode Adds a SyntaxNode.endOffset getter (offset + textLength) and re-points the completion classifier at it, removing the local endOffset helper. endOffset is intrinsic to a node, not a concern of the language server. Signed-off-by: Serhii Tatarintsev --- .../2-authoring/psl-parser/src/syntax/red.ts | 4 ++++ .../psl-parser/test/syntax/red.test.ts | 8 ++++++++ .../language-server/src/completion-context.ts | 16 ++++++---------- 3 files changed, 18 insertions(+), 10 deletions(-) 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 f8c6a79370..3d787b2c52 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 @@ -140,6 +140,10 @@ export class SyntaxNode { return this.green.textLength; } + get endOffset(): number { + return this.offset + this.textLength; + } + get firstChild(): SyntaxElement | undefined { return childAt(this, 0); } 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 0a0e77ee00..b0df711783 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 @@ -201,6 +201,14 @@ 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('SyntaxNode.ancestors', () => { it('walks from node to root', () => { const root = createSyntaxTree(buildSampleTree()); 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 index 41dc3d9236..fe638820f1 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-context.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-context.ts @@ -148,7 +148,7 @@ function classifyModelFieldType(input: { } const fieldNameStart = fieldName.syntax.offset; - const fieldNameEnd = endOffset(fieldName.syntax); + const fieldNameEnd = fieldName.syntax.endOffset; if (input.offset >= fieldNameStart && input.offset <= fieldNameEnd) { return unsupported(input.offset); } @@ -159,7 +159,7 @@ function classifyModelFieldType(input: { } const typeStart = typeAnnotation.syntax.offset; - const typeEnd = endOffset(typeAnnotation.syntax); + const typeEnd = typeAnnotation.syntax.endOffset; if (typeAnnotation.syntax.textLength === 0) { const fieldNameToken = fieldName.syntax.lastToken; if ( @@ -292,7 +292,7 @@ function declarationBodyContainsOffset( } const bodyStart = lbrace.offset + lbrace.text.length; const rbrace = declaration.rbrace(); - const bodyEnd = rbrace?.offset ?? endOffset(declaration.syntax); + const bodyEnd = rbrace?.offset ?? declaration.syntax.endOffset; return offset >= bodyStart && offset <= bodyEnd; } @@ -309,7 +309,7 @@ function namespaceBodyContainsOffset( } const bodyStart = lbrace.offset + lbrace.text.length; const rbrace = namespace.rbrace(); - const bodyEnd = rbrace?.offset ?? endOffset(namespace.syntax); + const bodyEnd = rbrace?.offset ?? namespace.syntax.endOffset; return offset >= bodyStart && offset <= bodyEnd; } @@ -422,7 +422,7 @@ function hasUnsupportedAncestor(node: SyntaxNode | undefined): boolean { * slicing the cursor segment's own identifier-token text. */ function typeNamePrefix(name: QualifiedNameAst, offset: number): TypeNamePrefix | undefined { - const cursor = Math.min(offset, endOffset(name.syntax)); + const cursor = Math.min(offset, name.syntax.endOffset); const segments = Array.from(filterChildren(name.syntax, IdentifierAst.cast)); const colon = name.colon(); @@ -602,14 +602,10 @@ function onlyWhitespaceBetween(from: SyntaxToken, toOffset: number): boolean { function containsOffset(node: SyntaxNode, offset: number): boolean { const start = node.offset; - const end = endOffset(node); + const end = node.endOffset; return node.textLength === 0 ? offset === start : offset >= start && offset <= end; } -function endOffset(node: SyntaxNode): number { - return node.offset + node.textLength; -} - function tokenEndOffset(token: SyntaxToken): number { return token.offset + token.text.length; } From 49b2280469c59d89791ed2440237d1648105616b Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 29 Jun 2026 13:20:58 +0000 Subject: [PATCH 19/54] refactor(language-server): inline currentToken/touchingToken into cursorIdentifier The comment guard reads leftBiased/rightBiased directly (equivalent for a kind check), and cursorIdentifier inlines the inside-vs-touching choice. Removes two single-purpose wrappers with no behavioural change. Signed-off-by: Serhii Tatarintsev --- .../language-server/src/completion-context.ts | 29 +++++-------------- 1 file changed, 7 insertions(+), 22 deletions(-) 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 index fe638820f1..414e2c467e 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-context.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-context.ts @@ -79,10 +79,7 @@ export function classifyPslCompletionContext( const root = input.document.syntax; const offset = input.sourceFile.offsetAt(input.position); const at = root.tokenAtOffset(offset); - if ( - currentToken(at, offset)?.kind === 'Comment' || - touchingToken(at, offset)?.kind === 'Comment' - ) { + if (at.rightBiased()?.kind === 'Comment' || at.leftBiased()?.kind === 'Comment') { return unsupported(offset); } @@ -540,18 +537,6 @@ function closestAst( return undefined; } -/** The token the cursor sits strictly inside, if any. */ -function currentToken(at: TokenAtOffset, offset: number): SyntaxToken | undefined { - const right = at.rightBiased(); - return right !== undefined && offset < tokenEndOffset(right) ? right : undefined; -} - -/** The token whose right edge the cursor touches, if any. */ -function touchingToken(at: TokenAtOffset, offset: number): SyntaxToken | undefined { - const left = at.leftBiased(); - return left !== undefined && tokenEndOffset(left) === offset ? left : undefined; -} - /** The nearest non-trivia token ending at or before the cursor. */ function previousSignificantToken(at: TokenAtOffset, offset: number): SyntaxToken | undefined { const left = at.leftBiased(); @@ -563,13 +548,13 @@ function previousSignificantToken(at: TokenAtOffset, offset: number): SyntaxToke /** The identifier token the cursor is editing, if any. */ function cursorIdentifier(at: TokenAtOffset, offset: number): SyntaxToken | undefined { - const current = currentToken(at, offset); - if (current?.kind === 'Ident') { - return current; + const right = at.rightBiased(); + if (right?.kind === 'Ident' && offset < tokenEndOffset(right)) { + return right; } - const touching = touchingToken(at, offset); - if (touching?.kind === 'Ident') { - return touching; + const left = at.leftBiased(); + if (left?.kind === 'Ident' && tokenEndOffset(left) === offset) { + return left; } return undefined; } From 936b5d4d9d93ee494e251fd5b0713b2867123c51 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 29 Jun 2026 13:57:56 +0000 Subject: [PATCH 20/54] feat(psl-parser): add isInside/isOutside containment helpers SyntaxNode and SyntaxToken gain isInside(offset)/isOutside(offset), using the same zero-width containment rule as the internal containsOffset. Consumers no longer hand-roll offset comparisons. Signed-off-by: Serhii Tatarintsev --- .../2-authoring/psl-parser/src/syntax/red.ts | 21 ++++++++ .../psl-parser/test/syntax/red.test.ts | 48 +++++++++++++++++++ 2 files changed, 69 insertions(+) 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 3d787b2c52..8831b44806 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 @@ -28,6 +28,17 @@ export class SyntaxToken implements Token { return this.text.length; } + /** Whether `offset` falls within this token, using the zero-width containment rule. */ + isInside(offset: number): boolean { + const start = this.offset; + const len = this.text.length; + return len === 0 ? offset === start : offset >= start && offset <= start + len; + } + + isOutside(offset: number): boolean { + return !this.isInside(offset); + } + /** The sibling element immediately after this token within its parent. */ get nextSiblingOrToken(): SyntaxElement | undefined { return siblingAfter(this.parent, this.green, this.offset); @@ -144,6 +155,16 @@ export class SyntaxNode { return this.offset + this.textLength; } + /** Whether `offset` falls within this node, using the zero-width containment rule. */ + isInside(offset: number): boolean { + const len = this.textLength; + return len === 0 ? offset === this.offset : offset >= this.offset && offset <= this.endOffset; + } + + isOutside(offset: number): boolean { + return !this.isInside(offset); + } + get firstChild(): SyntaxElement | undefined { return childAt(this, 0); } 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 b0df711783..b8f6bb2d86 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 @@ -209,6 +209,54 @@ describe('SyntaxNode.endOffset', () => { }); }); +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()); From 085c52cc54ab35cfef01029073f99e001ef90a8a Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 29 Jun 2026 13:58:03 +0000 Subject: [PATCH 21/54] refactor(language-server): use isInside/isOutside in field-type classification Replaces the local containsOffset helper and raw offset arithmetic with the new SyntaxNode/SyntaxToken containment methods, and restructures the redundant fieldName/fieldNameText guard into sequential narrowing. No behavioural change. Signed-off-by: Serhii Tatarintsev --- .../language-server/src/completion-context.ts | 29 +++++++++---------- 1 file changed, 13 insertions(+), 16 deletions(-) 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 index 414e2c467e..76bdcf6152 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-context.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-context.ts @@ -139,14 +139,17 @@ function classifyModelFieldType(input: { readonly replacementStartOffset: number; }): PslCompletionContext { const fieldName = input.field.name(); - const fieldNameText = fieldName?.name(); - if (fieldName === undefined || fieldNameText === undefined) { + if (fieldName === undefined) { + return unsupported(input.offset); + } + const fieldNameText = fieldName.name(); + if (fieldNameText === undefined) { return unsupported(input.offset); } - const fieldNameStart = fieldName.syntax.offset; const fieldNameEnd = fieldName.syntax.endOffset; - if (input.offset >= fieldNameStart && input.offset <= fieldNameEnd) { + + if (fieldName.syntax.isInside(input.offset)) { return unsupported(input.offset); } @@ -156,7 +159,7 @@ function classifyModelFieldType(input: { } const typeStart = typeAnnotation.syntax.offset; - const typeEnd = typeAnnotation.syntax.endOffset; + if (typeAnnotation.syntax.textLength === 0) { const fieldNameToken = fieldName.syntax.lastToken; if ( @@ -170,12 +173,12 @@ function classifyModelFieldType(input: { return unsupported(input.offset); } - if (input.offset < typeStart || input.offset > typeEnd) { + if (typeAnnotation.syntax.isOutside(input.offset)) { return unsupported(input.offset); } const constructorArgList = typeAnnotation.argList(); - if (constructorArgList !== undefined && containsOffset(constructorArgList.syntax, input.offset)) { + if (constructorArgList?.syntax.isInside(input.offset)) { return unsupported(input.offset); } @@ -183,7 +186,7 @@ function classifyModelFieldType(input: { if (name === undefined) { return unsupported(input.offset); } - if (!containsOffset(name.syntax, input.offset)) { + if (name.syntax.isOutside(input.offset)) { return unsupported(input.offset); } if (name.isOverQualified()) { @@ -333,7 +336,7 @@ function classifyGenericBlockParameter(input: { } const field = closestAst(input.node, FieldDeclarationAst.cast); - if (field !== undefined && containsOffset(field.syntax, input.offset)) { + if (field?.syntax.isInside(input.offset)) { return unsupported(input.offset); } @@ -366,7 +369,7 @@ function activeKeyValuePair( offset: number, ): KeyValuePairAst | undefined { const pair = closestAst(node, KeyValuePairAst.cast); - if (pair === undefined || !containsOffset(pair.syntax, offset)) { + if (pair === undefined || pair.syntax.isOutside(offset)) { return undefined; } return pair; @@ -585,12 +588,6 @@ function onlyWhitespaceBetween(from: SyntaxToken, toOffset: number): boolean { return true; } -function containsOffset(node: SyntaxNode, offset: number): boolean { - const start = node.offset; - const end = node.endOffset; - return node.textLength === 0 ? offset === start : offset >= start && offset <= end; -} - function tokenEndOffset(token: SyntaxToken): number { return token.offset + token.text.length; } From 1c1c87e5807011758aef5d07592d6971497dc731 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 29 Jun 2026 14:14:03 +0000 Subject: [PATCH 22/54] fix(psl-parser): make QualifiedName roles separator-positional space/namespace/identifier are now decided by the position of the : and . separators rather than a penultimate/last-segment heuristic. This corrects partial names produced while typing: auth. now resolves namespace=auth (not undefined), and supabase:auth. resolves namespace=auth (not supabase). Complete well-formed names are unaffected. Signed-off-by: Serhii Tatarintsev --- .../src/syntax/ast/qualified-name.ts | 34 ++++++++------ .../psl-parser/test/syntax/ast.test.ts | 47 +++++++++++++++++++ 2 files changed, 66 insertions(+), 15 deletions(-) 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/test/syntax/ast.test.ts b/packages/1-framework/2-authoring/psl-parser/test/syntax/ast.test.ts index 8d18d65d54..7d1fb82aec 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 @@ -1180,6 +1180,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'); From d1c92e0346ec1dac8c8941ab53ce4f653da30cd3 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 29 Jun 2026 14:14:09 +0000 Subject: [PATCH 23/54] refactor(language-server): derive type-name prefix from QualifiedName roles typeNamePrefix now reads the separator-positional space()/namespace()/identifier() accessors and only truncates the name segment at the cursor, dropping the bespoke cursor-relative segment scanners. No behavioural change. Signed-off-by: Serhii Tatarintsev --- .../language-server/src/completion-context.ts | 83 +++---------------- 1 file changed, 11 insertions(+), 72 deletions(-) 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 index 76bdcf6152..f3571cb074 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-context.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-context.ts @@ -4,9 +4,8 @@ import { type DocumentAst, FieldAttributeAst, FieldDeclarationAst, - filterChildren, GenericBlockDeclarationAst, - IdentifierAst, + type IdentifierAst, isTrivia, KeyValuePairAst, ModelAttributeAst, @@ -414,43 +413,27 @@ function hasUnsupportedAncestor(node: SyntaxNode | undefined): boolean { } /** - * Derives the qualified type-name prefix purely from the {@link QualifiedNameAst} - * segments and its `:` / `.` separator tokens, relative to the cursor. A - * separator counts only when it lies before the cursor, so structure is decided - * by what the user has actually typed: in `space:auth.User` the namespace role - * appears only once the cursor passes the dot. The single source touch is - * slicing the cursor segment's own identifier-token text. + * Derives the qualified type-name prefix from the {@link QualifiedNameAst} + * roles, which are themselves separator-positional: space, namespace, and name + * are decided purely by the `:` / `.` separator tokens, independent of the + * cursor. The single cursor-dependent step is slicing the name segment's own + * identifier-token text to the prefix the user has typed. */ function typeNamePrefix(name: QualifiedNameAst, offset: number): TypeNamePrefix | undefined { const cursor = Math.min(offset, name.syntax.endOffset); - const segments = Array.from(filterChildren(name.syntax, IdentifierAst.cast)); - - const colon = name.colon(); - const dot = name.dot(); - const colonOffset = colon !== undefined && colon.offset < cursor ? colon.offset : undefined; - const dotOffset = dot !== undefined && dot.offset < cursor ? dot.offset : undefined; - - const contractSpaceSegment = - colonOffset === undefined ? undefined : lastSegmentBefore(segments, colonOffset); - const namespaceSegment = - dotOffset === undefined ? undefined : lastSegmentBetween(segments, colonOffset, dotOffset); - const lastSeparatorOffset = dotOffset ?? colonOffset; - const nameSegment = firstSegmentAfter(segments, lastSeparatorOffset, cursor); - - const contractSpace = colonOffset === undefined ? undefined : contractSpaceSegment?.name(); - if (colonOffset !== undefined && (contractSpace === undefined || contractSpace.length === 0)) { + const contractSpace = name.colon() === undefined ? undefined : name.space()?.name(); + if (name.colon() !== undefined && (contractSpace === undefined || contractSpace.length === 0)) { return undefined; } - const namespace = dotOffset === undefined ? undefined : namespaceSegment?.name(); - if (dotOffset !== undefined && (namespace === undefined || namespace.length === 0)) { + const namespace = name.dot() === undefined ? undefined : name.namespace()?.name(); + if (name.dot() !== undefined && (namespace === undefined || namespace.length === 0)) { return undefined; } - + const nameSegment = name.identifier(); const nameText = nameSegment === undefined ? '' : segmentTextBeforeCursor(nameSegment, cursor); const path = [contractSpace, namespace, nameText].filter( (segment): segment is string => segment !== undefined && segment.length > 0, ); - return { path, name: nameText, @@ -459,50 +442,6 @@ function typeNamePrefix(name: QualifiedNameAst, offset: number): TypeNamePrefix }; } -/** The last segment that starts strictly before `boundary`. */ -function lastSegmentBefore( - segments: readonly IdentifierAst[], - boundary: number, -): IdentifierAst | undefined { - let found: IdentifierAst | undefined; - for (const segment of segments) { - if (segment.syntax.offset >= boundary) break; - found = segment; - } - return found; -} - -/** The last segment that starts after `lowerBound` (if any) and before `upperBound`. */ -function lastSegmentBetween( - segments: readonly IdentifierAst[], - lowerBound: number | undefined, - upperBound: number, -): IdentifierAst | undefined { - let found: IdentifierAst | undefined; - for (const segment of segments) { - const start = segment.syntax.offset; - if (start >= upperBound) break; - if (lowerBound !== undefined && start < lowerBound) continue; - found = segment; - } - return found; -} - -/** The first segment that starts after `boundary` and before the cursor. */ -function firstSegmentAfter( - segments: readonly IdentifierAst[], - boundary: number | undefined, - cursor: number, -): IdentifierAst | undefined { - for (const segment of segments) { - const start = segment.syntax.offset; - if (boundary !== undefined && start <= boundary) continue; - if (start >= cursor) continue; - return segment; - } - return undefined; -} - /** The cursor segment's identifier text, truncated at the cursor. */ function segmentTextBeforeCursor(segment: IdentifierAst, cursor: number): string { const token = segment.token(); From 2ffc4df7b65d749d383081f3d47b4b0191add31b Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 29 Jun 2026 14:31:47 +0000 Subject: [PATCH 24/54] refactor(language-server): let the client filter completions by prefix Drops the three server-side startsWith prefix filters. Per the LSP spec the client is responsible for filtering/sorting a complete list; the server returns the full candidate set with filterText and a replace-range textEdit, and the editor narrows as the user types. Removing the prefilter also fixes the incoherent pairing of a prefix-narrowed list returned as isIncomplete:false. Semantic scoping (namespace selection) and the existing-parameter exclusion are preserved. Signed-off-by: Serhii Tatarintsev --- .../src/completion-provider.ts | 51 ++++---- .../test/completion-provider.test.ts | 119 +++++++++++++----- .../language-server/test/server.test.ts | 11 +- 3 files changed, 122 insertions(+), 59 deletions(-) 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 index c2159051f1..fdba29811d 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-provider.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-provider.ts @@ -132,20 +132,18 @@ function provideDeclarationKeywordCompletionItems( end: sourceFile.positionAt(context.offset), }; - return declarationKeywordCandidates(context.scope, source) - .filter((candidate) => candidate.label.startsWith(context.prefix)) - .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, - }, - })); + 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( @@ -231,7 +229,6 @@ function provideGenericBlockParameterCompletionItems( return Object.keys(descriptor.parameters) .filter((parameterName) => !existing.has(parameterName)) - .filter((parameterName) => parameterName.startsWith(context.prefix)) .map((parameterName, index) => ({ label: parameterName, kind: CompletionItemKind.Property, @@ -255,19 +252,17 @@ function provideModelFieldTypeCompletionItems( end: sourceFile.positionAt(context.offset), }; - return candidatesForContext(context, source) - .filter((candidate) => candidate.filterText.startsWith(context.prefix.name)) - .map((candidate) => ({ - label: candidate.label, - kind: candidate.kind, - detail: candidate.detail, - sortText: sortText(candidate), - filterText: candidate.filterText, - textEdit: { - range: replacementRange, - newText: candidate.insertText, - }, - })); + return candidatesForContext(context, source).map((candidate) => ({ + label: candidate.label, + kind: candidate.kind, + detail: candidate.detail, + sortText: sortText(candidate), + filterText: candidate.filterText, + textEdit: { + range: replacementRange, + newText: candidate.insertText, + }, + })); } function candidatesForContext( 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 index 4e590eea71..7a1ebd4a3a 100644 --- 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 @@ -132,16 +132,26 @@ describe('providePslCompletionItems', () => { expect(items[0]?.insertTextFormat).toBeUndefined(); }); - it('filters document-level declaration keyword prefixes and replaces the typed segment', () => { + 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']); - expect(items[0]?.textEdit).toEqual({ - range: { - start: sourceFile.positionAt(cursorOffset - 'mo'.length), - end: sourceFile.positionAt(cursorOffset), + 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 ', }, - newText: 'model ', }); }); @@ -153,10 +163,22 @@ describe('providePslCompletionItems', () => { expect(items.map((item) => item.label)).not.toContain('namespace'); }); - it('filters namespace-body declaration keyword prefixes', () => { - const { items } = complete(['namespace feature {', ' po|', '}'].join('\n')); + 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(['policy']); + 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', () => { @@ -216,19 +238,53 @@ describe('providePslCompletionItems', () => { }); }); - it('filters bare prefixes against visible candidate labels', () => { - const { items } = complete(['model Post {', ' reviewer U|', '}'].join('\n')); + 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(['User', 'UserId']); + 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('filters bare namespace prefixes and replaces the typed segment with the namespace qualifier', () => { + 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(['auth']); - expect(items[0]).toMatchObject({ + 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', @@ -257,13 +313,14 @@ describe('providePslCompletionItems', () => { }); }); - it('returns namespace-qualified candidates with replacement metadata for the typed segment', () => { + 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(['User']); - expect(items[0]).toMatchObject({ + 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: { @@ -275,13 +332,14 @@ describe('providePslCompletionItems', () => { }); }); - it('returns contract-space-qualified candidates from visible namespace data', () => { + it('returns the full contract-space-qualified namespace member set from visible namespace data', () => { const { items, sourceFile, cursorOffset } = complete( ['model Post {', ' owner supabase:auth.P|', '}'].join('\n'), ); - expect(items.map((item) => item.label)).toEqual(['Profile']); - expect(items[0]).toMatchObject({ + expect(items.map((item) => item.label)).toEqual(['Account', 'User', 'Profile']); + expect(items.find((item) => item.label === 'Profile')).toMatchObject({ + filterText: 'Profile', detail: 'Composite type in namespace auth', textEdit: { range: { @@ -312,18 +370,21 @@ describe('providePslCompletionItems', () => { }); }); - it('filters descriptor-backed generic block parameters and excludes sibling keys', () => { + 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']); - expect(items[0]?.textEdit).toEqual({ - range: { - start: sourceFile.positionAt(cursorOffset - 'wh'.length), - end: sourceFile.positionAt(cursorOffset), + 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', }, - newText: 'where', }); }); 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 a01a10674d..6e20ee5864 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 @@ -540,7 +540,14 @@ describe('language server', { timeout: timeouts.databaseOperation }, () => { }); const items = completionItems(await requestCompletion(harness, schemaUri, updated.position)); - expect(items.map((item) => item.label)).toEqual(['User']); + expect(items.map((item) => item.label)).toEqual([ + 'Boolean', + 'DateTime', + 'Int', + 'String', + 'Post', + 'User', + ]); await republished; }); @@ -552,7 +559,7 @@ describe('language server', { timeout: timeouts.databaseOperation }, () => { await harness.waitForDiagnostics(schemaUri); const items = completionItems(await requestCompletion(harness, schemaUri, position)); - expect(items.map((item) => item.label)).toEqual(['where']); + expect(items.map((item) => item.label)).toEqual(['on', 'where', 'mode']); }); it('returns declaration keyword completions with plain-text edits by default', async () => { From db372d92bc012fa364764dbc60a0bd17c1030019 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 29 Jun 2026 14:45:24 +0000 Subject: [PATCH 25/54] refactor(language-server): drop dead completion prefix payload With client-side filtering, the classifier no longer needs to carry the typed prefix. Removes the prefix string from the declaration-keyword and generic-block contexts and shrinks the model-field-type prefix to just the namespace it selects (deleting TypeNamePrefix and the cursor-truncation helper). Completability and namespace resolution are unchanged. Signed-off-by: Serhii Tatarintsev --- .../language-server/src/completion-context.ts | 82 ++++++------------- .../src/completion-provider.ts | 2 +- .../test/completion-context.test.ts | 37 +++------ 3 files changed, 38 insertions(+), 83 deletions(-) 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 index f3571cb074..6b10b1c0e5 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-context.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-context.ts @@ -5,7 +5,6 @@ import { FieldAttributeAst, FieldDeclarationAst, GenericBlockDeclarationAst, - type IdentifierAst, isTrivia, KeyValuePairAst, ModelAttributeAst, @@ -27,18 +26,11 @@ export interface ClassifyPslCompletionContextInput { readonly position: Position; } -export interface TypeNamePrefix { - readonly path: readonly string[]; - readonly contractSpace?: string; - readonly namespace?: string; - readonly name: string; -} - export interface ModelFieldTypeCompletionContext { readonly kind: 'modelFieldType'; readonly offset: number; readonly fieldName: string; - readonly prefix: TypeNamePrefix; + readonly namespace: string | undefined; readonly replacementStartOffset: number; } @@ -46,7 +38,6 @@ export interface GenericBlockParameterCompletionContext { readonly kind: 'genericBlockParameter'; readonly offset: number; readonly blockKeyword: string; - readonly prefix: string; readonly replacementStartOffset: number; readonly existingParameterNames: readonly string[]; } @@ -57,7 +48,6 @@ export interface DeclarationKeywordCompletionContext { readonly kind: 'declarationKeyword'; readonly offset: number; readonly scope: DeclarationKeywordCompletionScope; - readonly prefix: string; readonly replacementStartOffset: number; } @@ -98,7 +88,6 @@ export function classifyPslCompletionContext( const declarationKeywordContext = classifyDeclarationKeyword({ node: contextNode, offset, - source: input.sourceFile.text, at, replacementStartOffset, }); @@ -109,7 +98,6 @@ export function classifyPslCompletionContext( const genericBlockContext = classifyGenericBlockParameter({ node: contextNode, offset, - source: input.sourceFile.text, at, replacementStartOffset, }); @@ -167,7 +155,7 @@ function classifyModelFieldType(input: { input.offset <= typeStart && onlyWhitespaceBetween(fieldNameToken, input.offset) ) { - return modelFieldType(input.offset, fieldNameText, { path: [], name: '' }, input.offset); + return modelFieldType(input.offset, fieldNameText, undefined, input.offset); } return unsupported(input.offset); } @@ -192,27 +180,26 @@ function classifyModelFieldType(input: { return unsupported(input.offset); } - const prefix = typeNamePrefix(name, input.offset); - if (prefix === undefined) { + const namespace = typeCompletionNamespace(name); + if (namespace === undefined) { return unsupported(input.offset); } - return modelFieldType(input.offset, fieldNameText, prefix, input.replacementStartOffset); + return modelFieldType(input.offset, fieldNameText, namespace.value, input.replacementStartOffset); } function modelFieldType( offset: number, fieldName: string, - prefix: TypeNamePrefix, + namespace: string | undefined, replacementStartOffset: number, ): ModelFieldTypeCompletionContext { - return { kind: 'modelFieldType', offset, fieldName, prefix, replacementStartOffset }; + return { kind: 'modelFieldType', offset, fieldName, namespace, replacementStartOffset }; } function classifyDeclarationKeyword(input: { readonly node: SyntaxNode | undefined; readonly offset: number; - readonly source: string; readonly at: TokenAtOffset; readonly replacementStartOffset: number; }): DeclarationKeywordCompletionContext | undefined { @@ -232,7 +219,6 @@ function classifyDeclarationKeyword(input: { kind: 'declarationKeyword', offset: input.offset, scope, - prefix: input.source.slice(input.replacementStartOffset, input.offset), replacementStartOffset: input.replacementStartOffset, }; } @@ -315,7 +301,6 @@ function namespaceBodyContainsOffset( function classifyGenericBlockParameter(input: { readonly node: SyntaxNode | undefined; readonly offset: number; - readonly source: string; readonly at: TokenAtOffset; readonly replacementStartOffset: number; }): PslCompletionContext | undefined { @@ -357,7 +342,6 @@ function classifyGenericBlockParameter(input: { kind: 'genericBlockParameter', offset: input.offset, blockKeyword: keyword, - prefix: input.source.slice(input.replacementStartOffset, input.offset), replacementStartOffset: input.replacementStartOffset, existingParameterNames: existingParameterNames(block, activePair), }; @@ -413,42 +397,30 @@ function hasUnsupportedAncestor(node: SyntaxNode | undefined): boolean { } /** - * Derives the qualified type-name prefix from the {@link QualifiedNameAst} - * roles, which are themselves separator-positional: space, namespace, and name - * are decided purely by the `:` / `.` separator tokens, independent of the - * cursor. The single cursor-dependent step is slicing the name segment's own - * identifier-token text to the prefix the user has typed. + * Selects the namespace whose members completion should offer, gating on the + * same separator-positional validity rules as the qualified type name: a `:` + * requires a contract-space segment and a `.` requires a namespace segment. + * Returns `undefined` when a gate fails (the caller yields `unsupported`); on + * success wraps the namespace string, which is itself `undefined` when the name + * has no `.`. */ -function typeNamePrefix(name: QualifiedNameAst, offset: number): TypeNamePrefix | undefined { - const cursor = Math.min(offset, name.syntax.endOffset); - const contractSpace = name.colon() === undefined ? undefined : name.space()?.name(); - if (name.colon() !== undefined && (contractSpace === undefined || contractSpace.length === 0)) { - return undefined; +function typeCompletionNamespace( + name: QualifiedNameAst, +): { readonly value: string | undefined } | undefined { + if (name.colon() !== undefined) { + const contractSpace = name.space()?.name(); + if (contractSpace === undefined || contractSpace.length === 0) { + return undefined; + } + } + if (name.dot() === undefined) { + return { value: undefined }; } - const namespace = name.dot() === undefined ? undefined : name.namespace()?.name(); - if (name.dot() !== undefined && (namespace === undefined || namespace.length === 0)) { + const namespace = name.namespace()?.name(); + if (namespace === undefined || namespace.length === 0) { return undefined; } - const nameSegment = name.identifier(); - const nameText = nameSegment === undefined ? '' : segmentTextBeforeCursor(nameSegment, cursor); - const path = [contractSpace, namespace, nameText].filter( - (segment): segment is string => segment !== undefined && segment.length > 0, - ); - return { - path, - name: nameText, - ...(contractSpace === undefined ? {} : { contractSpace }), - ...(namespace === undefined ? {} : { namespace }), - }; -} - -/** The cursor segment's identifier text, truncated at the cursor. */ -function segmentTextBeforeCursor(segment: IdentifierAst, cursor: number): string { - const token = segment.token(); - if (token === undefined) return ''; - const take = cursor - token.offset; - if (take <= 0) return ''; - return token.text.slice(0, Math.min(take, token.text.length)); + return { value: namespace }; } function nodeForContext( 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 index fdba29811d..01b71d4262 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-provider.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-provider.ts @@ -269,7 +269,7 @@ function candidatesForContext( context: ModelFieldTypeCompletionContext, source: PslCompletionCandidateSource, ): readonly ModelTypeCompletionCandidate[] { - const namespace = context.prefix.namespace; + const namespace = context.namespace; if (namespace !== undefined) { return namespaceCandidates(source.symbolTable?.topLevel.namespaces[namespace]); } 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 index 90e3b9d167..c5e525131c 100644 --- 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 @@ -26,7 +26,6 @@ describe('classifyPslCompletionContext', () => { expect(context).toMatchObject({ kind: 'declarationKeyword', scope: 'document', - prefix: '', replacementStartOffset: 0, offset: 0, }); @@ -38,7 +37,6 @@ describe('classifyPslCompletionContext', () => { expect(context).toMatchObject({ kind: 'declarationKeyword', scope: 'document', - prefix: 'mo', replacementStartOffset: 0, offset: 2, }); @@ -54,7 +52,6 @@ describe('classifyPslCompletionContext', () => { expect(context).toMatchObject({ kind: 'declarationKeyword', scope: 'namespace', - prefix: '', }); }); @@ -64,7 +61,6 @@ describe('classifyPslCompletionContext', () => { expect(context).toMatchObject({ kind: 'declarationKeyword', scope: 'namespace', - prefix: 'ty', }); }); @@ -74,7 +70,7 @@ describe('classifyPslCompletionContext', () => { expect(context).toMatchObject({ kind: 'modelFieldType', fieldName: 'author', - prefix: { path: [], name: '' }, + namespace: undefined, }); }); @@ -92,7 +88,7 @@ describe('classifyPslCompletionContext', () => { expect(context).toMatchObject({ kind: 'modelFieldType', fieldName: 'reviewer', - prefix: { path: ['U'], name: 'U' }, + namespace: undefined, }); }); @@ -100,13 +96,13 @@ describe('classifyPslCompletionContext', () => { expect(classify(['model Post {', ' owner auth.|', '}'].join('\n'))).toMatchObject({ kind: 'modelFieldType', fieldName: 'owner', - prefix: { path: ['auth'], namespace: 'auth', name: '' }, + namespace: 'auth', }); expect(classify(['model Post {', ' editor auth.U|', '}'].join('\n'))).toMatchObject({ kind: 'modelFieldType', fieldName: 'editor', - prefix: { path: ['auth', 'U'], namespace: 'auth', name: 'U' }, + namespace: 'auth', }); }); @@ -114,7 +110,7 @@ describe('classifyPslCompletionContext', () => { expect(classify(['model Post {', ' external supabase:|', '}'].join('\n'))).toMatchObject({ kind: 'modelFieldType', fieldName: 'external', - prefix: { path: ['supabase'], contractSpace: 'supabase', name: '' }, + namespace: undefined, }); expect( @@ -122,23 +118,13 @@ describe('classifyPslCompletionContext', () => { ).toMatchObject({ kind: 'modelFieldType', fieldName: 'externalUser', - prefix: { - path: ['supabase', 'auth'], - contractSpace: 'supabase', - namespace: 'auth', - name: '', - }, + namespace: 'auth', }); expect(classify(['model Post {', ' owner supabase:auth.U|', '}'].join('\n'))).toMatchObject({ kind: 'modelFieldType', fieldName: 'owner', - prefix: { - path: ['supabase', 'auth', 'U'], - contractSpace: 'supabase', - namespace: 'auth', - name: 'U', - }, + namespace: 'auth', }); }); @@ -146,15 +132,15 @@ describe('classifyPslCompletionContext', () => { expect(classify(['model Post {', ' external supabase:U|', '}'].join('\n'))).toMatchObject({ kind: 'modelFieldType', fieldName: 'external', - prefix: { path: ['supabase', 'U'], contractSpace: 'supabase', name: 'U' }, + namespace: undefined, }); }); - it('truncates the cursor segment at the offset when the cursor sits mid-name', () => { + it('resolves the namespace when the cursor sits mid-name', () => { expect(classify(['model Post {', ' owner auth.Use|r', '}'].join('\n'))).toMatchObject({ kind: 'modelFieldType', fieldName: 'owner', - prefix: { path: ['auth', 'Use'], namespace: 'auth', name: 'Use' }, + namespace: 'auth', }); }); @@ -177,7 +163,6 @@ describe('classifyPslCompletionContext', () => { expect(classify(['policy UserAccess {', ' |', '}'].join('\n'))).toMatchObject({ kind: 'genericBlockParameter', blockKeyword: 'policy', - prefix: '', existingParameterNames: [], }); }); @@ -187,7 +172,6 @@ describe('classifyPslCompletionContext', () => { { kind: 'genericBlockParameter', blockKeyword: 'policy', - prefix: 'wh', existingParameterNames: ['on'], }, ); @@ -203,7 +187,6 @@ describe('classifyPslCompletionContext', () => { expect(context).toMatchObject({ kind: 'genericBlockParameter', blockKeyword: 'datasource', - prefix: '', }); // 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. From 67c53f0d05eae3c7a3d2f5a0438f174ee5de5517 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 29 Jun 2026 16:29:39 +0000 Subject: [PATCH 26/54] refactor(language-server): split type and generic-block completion by position Replaces the single modelFieldType context with position-specific modelType / spaceMember / namespaceMember, and splits genericBlockParameter into genericBlockKey / genericBlockValue. Each position now maps to a focused provider. spaceMember and genericBlockValue are seams for behaviour not yet backed by data (foreign contract-space members need the multi-input symbol table; parameter-value completion is future work) and return no items. The only changed outcome: a :-qualified type with no . now classifies as spaceMember rather than offering bare model-type completions. Signed-off-by: Serhii Tatarintsev --- .../language-server/src/completion-context.ts | 160 +++++++++++++----- .../src/completion-provider.ts | 98 ++++++----- .../test/completion-context.test.ts | 47 ++--- .../test/completion-provider.test.ts | 12 ++ 4 files changed, 206 insertions(+), 111 deletions(-) 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 index 6b10b1c0e5..c4350dec5d 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-context.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-context.ts @@ -26,22 +26,44 @@ export interface ClassifyPslCompletionContextInput { readonly position: Position; } -export interface ModelFieldTypeCompletionContext { - readonly kind: 'modelFieldType'; +export interface ModelTypeCompletionContext { + readonly kind: 'modelType'; readonly offset: number; readonly fieldName: string; - readonly namespace: string | undefined; readonly replacementStartOffset: number; } -export interface GenericBlockParameterCompletionContext { - readonly kind: 'genericBlockParameter'; +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; +} + +export interface GenericBlockKeyCompletionContext { + readonly kind: 'genericBlockKey'; readonly offset: number; readonly blockKeyword: string; readonly replacementStartOffset: number; readonly existingParameterNames: readonly string[]; } +export interface GenericBlockValueCompletionContext { + readonly kind: 'genericBlockValue'; + readonly offset: number; + readonly blockKeyword: string; + readonly replacementStartOffset: number; +} + export type DeclarationKeywordCompletionScope = 'document' | 'namespace'; export interface DeclarationKeywordCompletionContext { @@ -58,8 +80,11 @@ export interface UnsupportedPslCompletionContext { export type PslCompletionContext = | DeclarationKeywordCompletionContext - | GenericBlockParameterCompletionContext - | ModelFieldTypeCompletionContext + | GenericBlockKeyCompletionContext + | GenericBlockValueCompletionContext + | ModelTypeCompletionContext + | NamespaceMemberCompletionContext + | SpaceMemberCompletionContext | UnsupportedPslCompletionContext; export function classifyPslCompletionContext( @@ -155,7 +180,7 @@ function classifyModelFieldType(input: { input.offset <= typeStart && onlyWhitespaceBetween(fieldNameToken, input.offset) ) { - return modelFieldType(input.offset, fieldNameText, undefined, input.offset); + return modelType(input.offset, fieldNameText, input.offset); } return unsupported(input.offset); } @@ -180,21 +205,82 @@ function classifyModelFieldType(input: { return unsupported(input.offset); } - const namespace = typeCompletionNamespace(name); - if (namespace === undefined) { + const position = classifyTypePosition(name); + if (position === undefined) { return unsupported(input.offset); } - return modelFieldType(input.offset, fieldNameText, namespace.value, input.replacementStartOffset); + switch (position.kind) { + case 'modelType': + return modelType(input.offset, fieldNameText, input.replacementStartOffset); + case 'spaceMember': + return spaceMember(input.offset, fieldNameText, input.replacementStartOffset, position.space); + case 'namespaceMember': + return namespaceMember( + input.offset, + fieldNameText, + input.replacementStartOffset, + position.namespace, + ); + } +} + +type TypePosition = + | { readonly kind: 'modelType' } + | { readonly kind: 'spaceMember'; readonly space: string } + | { readonly kind: 'namespaceMember'; readonly namespace: string }; + +/** + * Resolves which type-completion position a qualified name sits in, gating on + * the same separator validity as the name itself: a `:` requires a contract- + * space segment and a `.` requires a namespace segment. A failed gate yields + * `undefined` (the caller maps it to `unsupported`). + * + * Behaviour change: a `:`-qualified name with no `.` (e.g. `supabase:`, + * `supabase:U`) is now a `spaceMember` position rather than falling through to + * bare model-type completions. + */ +function classifyTypePosition(name: QualifiedNameAst): TypePosition | undefined { + if (name.colon() !== undefined) { + const space = name.space()?.name(); + if (space === undefined || space.length === 0) return undefined; + if (name.dot() === undefined) return { kind: 'spaceMember', space }; + const namespace = name.namespace()?.name(); + if (namespace === undefined || namespace.length === 0) return undefined; + return { kind: 'namespaceMember', namespace }; + } + if (name.dot() !== undefined) { + const namespace = name.namespace()?.name(); + if (namespace === undefined || namespace.length === 0) return undefined; + return { kind: 'namespaceMember', namespace }; + } + return { kind: 'modelType' }; +} + +function modelType( + offset: number, + fieldName: string, + replacementStartOffset: number, +): ModelTypeCompletionContext { + return { kind: 'modelType', offset, fieldName, replacementStartOffset }; } -function modelFieldType( +function spaceMember( offset: number, fieldName: string, - namespace: string | undefined, replacementStartOffset: number, -): ModelFieldTypeCompletionContext { - return { kind: 'modelFieldType', offset, fieldName, namespace, replacementStartOffset }; + space: string, +): SpaceMemberCompletionContext { + return { kind: 'spaceMember', offset, fieldName, replacementStartOffset, space }; +} + +function namespaceMember( + offset: number, + fieldName: string, + replacementStartOffset: number, + namespace: string, +): NamespaceMemberCompletionContext { + return { kind: 'namespaceMember', offset, fieldName, replacementStartOffset, namespace }; } function classifyDeclarationKeyword(input: { @@ -324,22 +410,29 @@ function classifyGenericBlockParameter(input: { return unsupported(input.offset); } - if (previousSignificantToken(input.at, input.offset)?.kind === 'Equals') { - return unsupported(input.offset); - } - const keyword = block.keyword()?.text; if (keyword === undefined || keyword.length === 0) { return unsupported(input.offset); } + // Value position: the cursor follows a `=`. The position is now classified + // distinctly from keys; populating value candidates is the provider's concern. + if (previousSignificantToken(input.at, input.offset)?.kind === 'Equals') { + return { + kind: 'genericBlockValue', + offset: input.offset, + blockKeyword: keyword, + replacementStartOffset: input.replacementStartOffset, + }; + } + const activePair = activeKeyValuePair(input.node, input.offset); if (activePair !== undefined && isAfterEquals(activePair, input.offset)) { return unsupported(input.offset); } return { - kind: 'genericBlockParameter', + kind: 'genericBlockKey', offset: input.offset, blockKeyword: keyword, replacementStartOffset: input.replacementStartOffset, @@ -396,33 +489,6 @@ function hasUnsupportedAncestor(node: SyntaxNode | undefined): boolean { ); } -/** - * Selects the namespace whose members completion should offer, gating on the - * same separator-positional validity rules as the qualified type name: a `:` - * requires a contract-space segment and a `.` requires a namespace segment. - * Returns `undefined` when a gate fails (the caller yields `unsupported`); on - * success wraps the namespace string, which is itself `undefined` when the name - * has no `.`. - */ -function typeCompletionNamespace( - name: QualifiedNameAst, -): { readonly value: string | undefined } | undefined { - if (name.colon() !== undefined) { - const contractSpace = name.space()?.name(); - if (contractSpace === undefined || contractSpace.length === 0) { - return undefined; - } - } - if (name.dot() === undefined) { - return { value: undefined }; - } - const namespace = name.namespace()?.name(); - if (namespace === undefined || namespace.length === 0) { - return undefined; - } - return { value: namespace }; -} - function nodeForContext( node: SyntaxNode | undefined, previousNode: SyntaxNode | 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 index 01b71d4262..9899a446f0 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-provider.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-provider.ts @@ -11,8 +11,9 @@ import type { SourceFile } from '@prisma-next/psl-parser/syntax'; import { type CompletionItem, CompletionItemKind, InsertTextFormat } from 'vscode-languageserver'; import type { DeclarationKeywordCompletionContext, - GenericBlockParameterCompletionContext, - ModelFieldTypeCompletionContext, + GenericBlockKeyCompletionContext, + ModelTypeCompletionContext, + NamespaceMemberCompletionContext, PslCompletionContext, } from './completion-context'; @@ -100,25 +101,32 @@ const namespaceNativeDeclarationKeywords: readonly DeclarationKeywordCompletionC export function providePslCompletionItems( input: ProvidePslCompletionItemsInput, ): readonly CompletionItem[] { - if (input.context.kind === 'unsupported') { - return []; - } - if (input.context.kind === 'declarationKeyword') { - return provideDeclarationKeywordCompletionItems( - input.context, - input.sourceFile, - input.candidates, - input.clientSupportsSnippets, - ); + 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); } - if (input.context.kind === 'genericBlockParameter') { - return provideGenericBlockParameterCompletionItems( - input.context, - input.sourceFile, - input.candidates, - ); - } - return provideModelFieldTypeCompletionItems(input.context, input.sourceFile, input.candidates); } function provideDeclarationKeywordCompletionItems( @@ -211,8 +219,8 @@ function declarationKeywordSortText(candidate: DeclarationKeywordCompletionCandi return `${declarationKeywordCategoryOrder[candidate.category]}:${candidate.label}`; } -function provideGenericBlockParameterCompletionItems( - context: GenericBlockParameterCompletionContext, +function provideGenericBlockKeyCompletionItems( + context: GenericBlockKeyCompletionContext, sourceFile: SourceFile, source: PslCompletionCandidateSource, ): readonly CompletionItem[] { @@ -242,17 +250,41 @@ function provideGenericBlockParameterCompletionItems( })); } -function provideModelFieldTypeCompletionItems( - context: ModelFieldTypeCompletionContext, +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[] { + 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 candidatesForContext(context, source).map((candidate) => ({ + return candidates.map((candidate) => ({ label: candidate.label, kind: candidate.kind, detail: candidate.detail, @@ -265,22 +297,6 @@ function provideModelFieldTypeCompletionItems( })); } -function candidatesForContext( - context: ModelFieldTypeCompletionContext, - source: PslCompletionCandidateSource, -): readonly ModelTypeCompletionCandidate[] { - const namespace = context.namespace; - if (namespace !== undefined) { - return namespaceCandidates(source.symbolTable?.topLevel.namespaces[namespace]); - } - - return [ - ...configuredScalarCandidates(source.scalarTypes), - ...topLevelSymbolCandidates(source.symbolTable), - ...allNamespaceCandidates(source.symbolTable), - ]; -} - function configuredScalarCandidates( scalarTypes: readonly string[], ): readonly ModelTypeCompletionCandidate[] { 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 index c5e525131c..cdf0f58190 100644 --- 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 @@ -68,9 +68,8 @@ describe('classifyPslCompletionContext', () => { const context = classify(['model Post {', ' author |', '}'].join('\n')); expect(context).toMatchObject({ - kind: 'modelFieldType', + kind: 'modelType', fieldName: 'author', - namespace: undefined, }); }); @@ -86,21 +85,20 @@ describe('classifyPslCompletionContext', () => { const context = classify(['model Post {', ' reviewer U|', '}'].join('\n')); expect(context).toMatchObject({ - kind: 'modelFieldType', + kind: 'modelType', fieldName: 'reviewer', - namespace: undefined, }); }); it('classifies namespace-qualified model field type prefixes', () => { expect(classify(['model Post {', ' owner auth.|', '}'].join('\n'))).toMatchObject({ - kind: 'modelFieldType', + kind: 'namespaceMember', fieldName: 'owner', namespace: 'auth', }); expect(classify(['model Post {', ' editor auth.U|', '}'].join('\n'))).toMatchObject({ - kind: 'modelFieldType', + kind: 'namespaceMember', fieldName: 'editor', namespace: 'auth', }); @@ -108,37 +106,37 @@ describe('classifyPslCompletionContext', () => { it('classifies contract-space-qualified model field type prefixes', () => { expect(classify(['model Post {', ' external supabase:|', '}'].join('\n'))).toMatchObject({ - kind: 'modelFieldType', + kind: 'spaceMember', fieldName: 'external', - namespace: undefined, + space: 'supabase', }); expect( classify(['model Post {', ' externalUser supabase:auth.|', '}'].join('\n')), ).toMatchObject({ - kind: 'modelFieldType', + kind: 'namespaceMember', fieldName: 'externalUser', namespace: 'auth', }); expect(classify(['model Post {', ' owner supabase:auth.U|', '}'].join('\n'))).toMatchObject({ - kind: 'modelFieldType', + kind: 'namespaceMember', fieldName: 'owner', namespace: 'auth', }); }); - it('classifies a contract-space-qualified prefix without a namespace segment', () => { + 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: 'modelFieldType', + kind: 'spaceMember', fieldName: 'external', - namespace: undefined, + space: 'supabase', }); }); it('resolves the namespace when the cursor sits mid-name', () => { expect(classify(['model Post {', ' owner auth.Use|r', '}'].join('\n'))).toMatchObject({ - kind: 'modelFieldType', + kind: 'namespaceMember', fieldName: 'owner', namespace: 'auth', }); @@ -159,38 +157,41 @@ describe('classifyPslCompletionContext', () => { expectUnsupported(['model Post {', ' authorId Int @relation(fields: [|])', '}'].join('\n')); }); - it('classifies blank generic block parameter positions', () => { + it('classifies blank generic block key positions', () => { expect(classify(['policy UserAccess {', ' |', '}'].join('\n'))).toMatchObject({ - kind: 'genericBlockParameter', + kind: 'genericBlockKey', blockKeyword: 'policy', existingParameterNames: [], }); }); - it('classifies generic block parameter prefixes and records sibling keys', () => { + it('classifies generic block key prefixes and records sibling keys', () => { expect(classify(['policy UserAccess {', ' on = User', ' wh|', '}'].join('\n'))).toMatchObject( { - kind: 'genericBlockParameter', + kind: 'genericBlockKey', blockKeyword: 'policy', existingParameterNames: ['on'], }, ); }); - it('returns unsupported inside generic block parameter values', () => { - expectUnsupported(['datasource db {', ' provider = |', '}'].join('\n')); + it('classifies generic block value positions after the equals sign', () => { + expect(classify(['datasource db {', ' provider = |', '}'].join('\n'))).toMatchObject({ + kind: 'genericBlockValue', + blockKeyword: 'datasource', + }); }); - it('classifies the gap before = as a generic block parameter with an empty source range', () => { + 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: 'genericBlockParameter', + 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 === 'genericBlockParameter') { + if (context.kind === 'genericBlockKey') { expect(context.replacementStartOffset).toBe(context.offset); } }); 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 index 7a1ebd4a3a..24f046291b 100644 --- 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 @@ -351,6 +351,18 @@ describe('providePslCompletionItems', () => { }); }); + 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')); From b8923f2ba6d4f47baa72b43f91f3850b9285d961 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 29 Jun 2026 17:17:26 +0000 Subject: [PATCH 27/54] feat(psl-parser): add endOffset to SyntaxToken Mirrors SyntaxNode.endOffset (offset + textLength) so consumers stop hand-rolling offset + text.length. Signed-off-by: Serhii Tatarintsev --- .../2-authoring/psl-parser/src/syntax/red.ts | 4 ++++ .../2-authoring/psl-parser/test/syntax/red.test.ts | 11 +++++++++++ 2 files changed, 15 insertions(+) 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 8831b44806..7cd47585a2 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 @@ -28,6 +28,10 @@ export class SyntaxToken implements Token { return this.text.length; } + get endOffset(): number { + return this.offset + this.textLength; + } + /** Whether `offset` falls within this token, using the zero-width containment rule. */ isInside(offset: number): boolean { const start = this.offset; 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 b8f6bb2d86..254904f194 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 @@ -209,6 +209,17 @@ describe('SyntaxNode.endOffset', () => { }); }); +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()); From f98cd0f45168f3cb104eed269233fc0e6c10eda4 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 29 Jun 2026 17:17:32 +0000 Subject: [PATCH 28/54] refactor(language-server): address completion-context review batch - simplify classifyTypePosition to read the separator-positional accessors directly (malformed leading-separator types now resolve to modelType) - inline the position factories and sourceRangeStart; use token.endOffset - relocate the attribute guard from the top-level classifier into the generic-block classifier (the only place it is load-bearing: an @@ attribute inside a generic block would otherwise misclassify as genericBlockKey) - offer type completion for composite-type fields, not just model fields - treat the next line after a field name as a valid blank-type position, since the parser skips newlines as trivia and accepts type-on-newline Signed-off-by: Serhii Tatarintsev --- .../language-server/src/completion-context.ts | 146 +++++++----------- .../test/completion-context.test.ts | 28 +++- 2 files changed, 81 insertions(+), 93 deletions(-) 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 index c4350dec5d..556c8640e3 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-context.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-context.ts @@ -102,13 +102,10 @@ export function classifyPslCompletionContext( const anchorNode = at.leftBiased()?.parent; const previousSignificantNode = previousSignificantToken(at, offset)?.parent; const contextNode = nodeForContext(anchorNode, previousSignificantNode); - if (hasUnsupportedAncestor(contextNode)) { - return unsupported(offset); - } // The edit replaces the identifier under the cursor, or is empty when the // cursor sits in trivia. - const replacementStartOffset = sourceRangeStart(at, offset); + const replacementStartOffset = cursorIdentifier(at, offset)?.offset ?? offset; const declarationKeywordContext = classifyDeclarationKeyword({ node: contextNode, @@ -134,7 +131,10 @@ export function classifyPslCompletionContext( if (field === undefined) { return unsupported(offset); } - if (closestAst(field.syntax, ModelDeclarationAst.cast) === undefined) { + const inModelOrComposite = + closestAst(field.syntax, ModelDeclarationAst.cast) !== undefined || + closestAst(field.syntax, CompositeTypeDeclarationAst.cast) !== undefined; + if (!inModelOrComposite) { return unsupported(offset); } @@ -173,14 +173,13 @@ function classifyModelFieldType(input: { const typeStart = typeAnnotation.syntax.offset; if (typeAnnotation.syntax.textLength === 0) { - const fieldNameToken = fieldName.syntax.lastToken; - if ( - fieldNameToken !== undefined && - input.offset > fieldNameEnd && - input.offset <= typeStart && - onlyWhitespaceBetween(fieldNameToken, input.offset) - ) { - return modelType(input.offset, fieldNameText, input.offset); + if (input.offset > fieldNameEnd && input.offset <= typeStart) { + return { + kind: 'modelType', + offset: input.offset, + fieldName: fieldNameText, + replacementStartOffset: input.offset, + }; } return unsupported(input.offset); } @@ -206,22 +205,31 @@ function classifyModelFieldType(input: { } const position = classifyTypePosition(name); - if (position === undefined) { - return unsupported(input.offset); - } switch (position.kind) { case 'modelType': - return modelType(input.offset, fieldNameText, input.replacementStartOffset); + return { + kind: 'modelType', + offset: input.offset, + fieldName: fieldNameText, + replacementStartOffset: input.replacementStartOffset, + }; case 'spaceMember': - return spaceMember(input.offset, fieldNameText, input.replacementStartOffset, position.space); + return { + kind: 'spaceMember', + offset: input.offset, + fieldName: fieldNameText, + replacementStartOffset: input.replacementStartOffset, + space: position.space, + }; case 'namespaceMember': - return namespaceMember( - input.offset, - fieldNameText, - input.replacementStartOffset, - position.namespace, - ); + return { + kind: 'namespaceMember', + offset: input.offset, + fieldName: fieldNameText, + replacementStartOffset: input.replacementStartOffset, + namespace: position.namespace, + }; } } @@ -231,58 +239,29 @@ type TypePosition = | { readonly kind: 'namespaceMember'; readonly namespace: string }; /** - * Resolves which type-completion position a qualified name sits in, gating on - * the same separator validity as the name itself: a `:` requires a contract- - * space segment and a `.` requires a namespace segment. A failed gate yields - * `undefined` (the caller maps it to `unsupported`). + * Resolves which type-completion position a qualified name sits in. 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 now a `spaceMember` position rather than falling through to - * bare model-type completions. + * `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): TypePosition | undefined { - if (name.colon() !== undefined) { - const space = name.space()?.name(); - if (space === undefined || space.length === 0) return undefined; - if (name.dot() === undefined) return { kind: 'spaceMember', space }; - const namespace = name.namespace()?.name(); - if (namespace === undefined || namespace.length === 0) return undefined; +function classifyTypePosition(name: QualifiedNameAst): TypePosition { + const namespace = name.namespace()?.name(); + if (namespace !== undefined && namespace.length > 0) { return { kind: 'namespaceMember', namespace }; } - if (name.dot() !== undefined) { - const namespace = name.namespace()?.name(); - if (namespace === undefined || namespace.length === 0) return undefined; - return { kind: 'namespaceMember', namespace }; + const space = name.space()?.name(); + if (space !== undefined && space.length > 0) { + return { kind: 'spaceMember', space }; } return { kind: 'modelType' }; } -function modelType( - offset: number, - fieldName: string, - replacementStartOffset: number, -): ModelTypeCompletionContext { - return { kind: 'modelType', offset, fieldName, replacementStartOffset }; -} - -function spaceMember( - offset: number, - fieldName: string, - replacementStartOffset: number, - space: string, -): SpaceMemberCompletionContext { - return { kind: 'spaceMember', offset, fieldName, replacementStartOffset, space }; -} - -function namespaceMember( - offset: number, - fieldName: string, - replacementStartOffset: number, - namespace: string, -): NamespaceMemberCompletionContext { - return { kind: 'namespaceMember', offset, fieldName, replacementStartOffset, namespace }; -} - function classifyDeclarationKeyword(input: { readonly node: SyntaxNode | undefined; readonly offset: number; @@ -361,7 +340,7 @@ function declarationBodyContainsOffset( if (lbrace === undefined) { return false; } - const bodyStart = lbrace.offset + lbrace.text.length; + const bodyStart = lbrace.endOffset; const rbrace = declaration.rbrace(); const bodyEnd = rbrace?.offset ?? declaration.syntax.endOffset; return offset >= bodyStart && offset <= bodyEnd; @@ -378,7 +357,7 @@ function namespaceBodyContainsOffset( if (lbrace === undefined) { return false; } - const bodyStart = lbrace.offset + lbrace.text.length; + const bodyStart = lbrace.endOffset; const rbrace = namespace.rbrace(); const bodyEnd = rbrace?.offset ?? namespace.syntax.endOffset; return offset >= bodyStart && offset <= bodyEnd; @@ -395,6 +374,10 @@ function classifyGenericBlockParameter(input: { return undefined; } + if (hasUnsupportedAncestor(input.node)) { + return unsupported(input.offset); + } + const lbrace = block.lbrace(); if (lbrace === undefined || input.offset < lbrace.offset + lbrace.text.length) { return unsupported(input.offset); @@ -523,26 +506,22 @@ function previousSignificantToken(at: TokenAtOffset, offset: number): SyntaxToke if (left === undefined) { return undefined; } - return tokenEndOffset(left) <= offset && !isTrivia(left) ? left : previousNonTriviaToken(left); + return left.endOffset <= offset && !isTrivia(left) ? left : previousNonTriviaToken(left); } /** 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 < tokenEndOffset(right)) { + if (right?.kind === 'Ident' && offset < right.endOffset) { return right; } const left = at.leftBiased(); - if (left?.kind === 'Ident' && tokenEndOffset(left) === offset) { + if (left?.kind === 'Ident' && left.endOffset === offset) { return left; } return undefined; } -function sourceRangeStart(at: TokenAtOffset, offset: number): number { - return cursorIdentifier(at, offset)?.offset ?? offset; -} - /** Whether a newline trivia token separates `from` from `toOffset`. */ function newlineBetween(from: SyntaxToken, toOffset: number): boolean { for (let token = from.nextToken; token !== undefined && token.offset < toOffset; ) { @@ -553,18 +532,3 @@ function newlineBetween(from: SyntaxToken, toOffset: number): boolean { } return false; } - -/** Whether only whitespace trivia tokens lie between `from` and `toOffset`. */ -function onlyWhitespaceBetween(from: SyntaxToken, toOffset: number): boolean { - for (let token = from.nextToken; token !== undefined && token.offset < toOffset; ) { - if (token.kind !== 'Whitespace') { - return false; - } - token = token.nextToken; - } - return true; -} - -function tokenEndOffset(token: SyntaxToken): number { - return token.offset + token.text.length; -} 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 index cdf0f58190..89ead0c266 100644 --- 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 @@ -73,8 +73,11 @@ describe('classifyPslCompletionContext', () => { }); }); - it('does not treat the next indented line as a blank model field type position', () => { - expectUnsupported(['model Post {', ' author', ' |', '}'].join('\n')); + 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', () => { @@ -157,6 +160,27 @@ describe('classifyPslCompletionContext', () => { 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', () => { expect(classify(['policy UserAccess {', ' |', '}'].join('\n'))).toMatchObject({ kind: 'genericBlockKey', From 332fee57d6cc87ac14529f2840a3e9eaa801e6bc Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 29 Jun 2026 17:31:19 +0000 Subject: [PATCH 29/54] refactor(language-server): collapse anchorNode/contextNode into one nodeForContext only redirected when the anchor was a Document or ModelDeclaration, and in both cases the classification is identical with or without the redirect (a bare model body has nothing to complete; top-level resolves to a document-scope keyword either way). Field positions already anchor on the FieldDeclaration since the parser attaches a field trailing trivia to the field. Drop the dead nodeForContext and the anchor/context distinction. Signed-off-by: Serhii Tatarintsev --- .../language-server/src/completion-context.ts | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) 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 index 556c8640e3..4f0cf75937 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-context.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-context.ts @@ -99,9 +99,7 @@ export function classifyPslCompletionContext( // Anchor on the token left of the cursor and navigate outward via // `token.parent` rather than scanning the whole tree. - const anchorNode = at.leftBiased()?.parent; - const previousSignificantNode = previousSignificantToken(at, offset)?.parent; - const contextNode = nodeForContext(anchorNode, previousSignificantNode); + const contextNode = at.leftBiased()?.parent; // The edit replaces the identifier under the cursor, or is empty when the // cursor sits in trivia. @@ -472,16 +470,6 @@ function hasUnsupportedAncestor(node: SyntaxNode | undefined): boolean { ); } -function nodeForContext( - node: SyntaxNode | undefined, - previousNode: SyntaxNode | undefined, -): SyntaxNode | undefined { - if (node === undefined || node.kind === 'Document' || node.kind === 'ModelDeclaration') { - return previousNode ?? node; - } - return node; -} - function closestAst( node: SyntaxNode | undefined, cast: (node: SyntaxNode) => T | undefined, From 73c9985a119296561def230742a8b219134ecf9c Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 29 Jun 2026 17:49:11 +0000 Subject: [PATCH 30/54] feat(psl-parser): add BracedBlock interface for braced declarations Model/composite/namespace/types/generic block AST nodes share lbrace()/rbrace(); a BracedBlock interface lets consumers write one brace-body helper instead of duplicating it per node kind. Signed-off-by: Serhii Tatarintsev --- .../2-authoring/psl-parser/src/exports/syntax.ts | 2 +- .../2-authoring/psl-parser/src/syntax/ast-helpers.ts | 5 +++++ .../psl-parser/src/syntax/ast/declarations.ts | 12 ++++++------ 3 files changed, 12 insertions(+), 7 deletions(-) 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 98595fce3f..839644b446 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,7 +41,7 @@ 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 type { AstNode, BracedBlock } from '../syntax/ast-helpers'; export { filterChildren, findChildToken, findFirstChild, printSyntax } from '../syntax/ast-helpers'; export type { GreenElement, GreenNode, GreenToken } from '../syntax/green'; export { greenNode, greenToken } from '../syntax/green'; 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..51a2925d94 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) { 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) { From 0d135d800e87bcedcda8149fc759f8371507a77c Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 29 Jun 2026 17:49:17 +0000 Subject: [PATCH 31/54] refactor(language-server): address completion-context review batch - closestAst takes variadic casts and walks the ancestor path once (model-or- composite check no longer traverses twice) - classifyTypePosition builds and returns the context directly, dropping the intermediate TypePosition type and the re-mapping switch - declaration keywords may be completed after a closing brace regardless of newlines (model A {} model B {} is valid one-line PSL); keyed on RBrace, not newlineBetween - merge declarationBodyContainsOffset/namespaceBodyContainsOffset into a single blockBodyContainsOffset over BracedBlock, used by the namespace-scope, non- declaration-body, and generic-block checks alike Signed-off-by: Serhii Tatarintsev --- .../language-server/src/completion-context.ts | 151 +++++------------- .../test/completion-context.test.ts | 7 +- 2 files changed, 46 insertions(+), 112 deletions(-) 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 index 4f0cf75937..cdf6b43a62 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-context.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-context.ts @@ -1,5 +1,6 @@ import { AttributeArgListAst, + type BracedBlock, CompositeTypeDeclarationAst, type DocumentAst, FieldAttributeAst, @@ -129,10 +130,10 @@ export function classifyPslCompletionContext( if (field === undefined) { return unsupported(offset); } - const inModelOrComposite = - closestAst(field.syntax, ModelDeclarationAst.cast) !== undefined || - closestAst(field.syntax, CompositeTypeDeclarationAst.cast) !== undefined; - if (!inModelOrComposite) { + if ( + closestAst(field.syntax, ModelDeclarationAst.cast, CompositeTypeDeclarationAst.cast) === + undefined + ) { return unsupported(offset); } @@ -202,43 +203,12 @@ function classifyModelFieldType(input: { return unsupported(input.offset); } - const position = classifyTypePosition(name); - - switch (position.kind) { - case 'modelType': - return { - kind: 'modelType', - offset: input.offset, - fieldName: fieldNameText, - replacementStartOffset: input.replacementStartOffset, - }; - case 'spaceMember': - return { - kind: 'spaceMember', - offset: input.offset, - fieldName: fieldNameText, - replacementStartOffset: input.replacementStartOffset, - space: position.space, - }; - case 'namespaceMember': - return { - kind: 'namespaceMember', - offset: input.offset, - fieldName: fieldNameText, - replacementStartOffset: input.replacementStartOffset, - namespace: position.namespace, - }; - } + return classifyTypePosition(name, input.offset, fieldNameText, input.replacementStartOffset); } -type TypePosition = - | { readonly kind: 'modelType' } - | { readonly kind: 'spaceMember'; readonly space: string } - | { readonly kind: 'namespaceMember'; readonly namespace: string }; - /** - * Resolves which type-completion position a qualified name sits in. Roles are - * read straight off the separator-positional accessors: a populated namespace + * 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. * @@ -248,16 +218,21 @@ type TypePosition = * carries no populated segment and resolves to `modelType` rather than * `unsupported`. */ -function classifyTypePosition(name: QualifiedNameAst): TypePosition { +function classifyTypePosition( + name: QualifiedNameAst, + offset: number, + fieldName: string, + replacementStartOffset: number, +): ModelTypeCompletionContext | SpaceMemberCompletionContext | NamespaceMemberCompletionContext { const namespace = name.namespace()?.name(); if (namespace !== undefined && namespace.length > 0) { - return { kind: 'namespaceMember', namespace }; + return { kind: 'namespaceMember', offset, fieldName, replacementStartOffset, namespace }; } const space = name.space()?.name(); if (space !== undefined && space.length > 0) { - return { kind: 'spaceMember', space }; + return { kind: 'spaceMember', offset, fieldName, replacementStartOffset, space }; } - return { kind: 'modelType' }; + return { kind: 'modelType', offset, fieldName, replacementStartOffset }; } function classifyDeclarationKeyword(input: { @@ -271,7 +246,7 @@ function classifyDeclarationKeyword(input: { } const namespace = closestAst(input.node, NamespaceDeclarationAst.cast); - const scope = namespaceBodyContainsOffset(namespace, input.offset) ? 'namespace' : 'document'; + const scope = blockBodyContainsOffset(namespace, input.offset) ? 'namespace' : 'document'; const prefixToken = cursorIdentifier(input.at, input.offset); if (!declarationKeywordAllowed(prefixToken, namespace, input)) { @@ -287,9 +262,11 @@ function classifyDeclarationKeyword(input: { } /** - * A declaration keyword may be completed only when nothing but whitespace - * precedes the cursor on its line — i.e. the previous significant token is on an - * earlier line, is absent, or is the enclosing namespace's opening brace. + * A declaration keyword may be completed where a new declaration can begin: at + * the start of the document or namespace body, immediately after a previous + * declaration's closing `}`, or right after the enclosing namespace's opening + * brace. Newlines are trivia and play no role — `model A {} model B {}` is valid + * PSL on a single line. */ function declarationKeywordAllowed( prefixToken: SyntaxToken | undefined, @@ -303,61 +280,32 @@ function declarationKeywordAllowed( if (previous === undefined) { return true; } - - const start = prefixToken?.offset ?? input.offset; - if (newlineBetween(previous, start)) { + if (previous.kind === 'RBrace') { return true; } - const lbrace = namespace?.lbrace(); return lbrace !== undefined && lbrace.offset === previous.offset; } function isInsideNonDeclarationKeywordBody(node: SyntaxNode | undefined, offset: number): boolean { return ( - declarationBodyContainsOffset(closestAst(node, ModelDeclarationAst.cast), offset) || - declarationBodyContainsOffset(closestAst(node, CompositeTypeDeclarationAst.cast), offset) || - declarationBodyContainsOffset(closestAst(node, TypesBlockAst.cast), offset) || - declarationBodyContainsOffset(closestAst(node, GenericBlockDeclarationAst.cast), offset) + blockBodyContainsOffset(closestAst(node, ModelDeclarationAst.cast), offset) || + blockBodyContainsOffset(closestAst(node, CompositeTypeDeclarationAst.cast), offset) || + blockBodyContainsOffset(closestAst(node, TypesBlockAst.cast), offset) || + blockBodyContainsOffset(closestAst(node, GenericBlockDeclarationAst.cast), offset) ); } -function declarationBodyContainsOffset( - declaration: - | CompositeTypeDeclarationAst - | GenericBlockDeclarationAst - | ModelDeclarationAst - | TypesBlockAst - | undefined, - offset: number, -): boolean { - if (declaration === undefined) { - return false; - } - const lbrace = declaration.lbrace(); - if (lbrace === undefined) { - return false; - } - const bodyStart = lbrace.endOffset; - const rbrace = declaration.rbrace(); - const bodyEnd = rbrace?.offset ?? declaration.syntax.endOffset; - return offset >= bodyStart && offset <= bodyEnd; -} - -function namespaceBodyContainsOffset( - namespace: NamespaceDeclarationAst | undefined, - offset: number, -): boolean { - if (namespace === undefined) { +function blockBodyContainsOffset(block: BracedBlock | undefined, offset: number): boolean { + if (block === undefined) { return false; } - const lbrace = namespace.lbrace(); + const lbrace = block.lbrace(); if (lbrace === undefined) { return false; } const bodyStart = lbrace.endOffset; - const rbrace = namespace.rbrace(); - const bodyEnd = rbrace?.offset ?? namespace.syntax.endOffset; + const bodyEnd = block.rbrace()?.offset ?? block.syntax.endOffset; return offset >= bodyStart && offset <= bodyEnd; } @@ -376,13 +324,7 @@ function classifyGenericBlockParameter(input: { return unsupported(input.offset); } - const lbrace = block.lbrace(); - if (lbrace === undefined || input.offset < lbrace.offset + lbrace.text.length) { - return unsupported(input.offset); - } - - const rbrace = block.rbrace(); - if (rbrace !== undefined && input.offset > rbrace.offset) { + if (!blockBodyContainsOffset(block, input.offset)) { return unsupported(input.offset); } @@ -472,18 +414,18 @@ function hasUnsupportedAncestor(node: SyntaxNode | undefined): boolean { function closestAst( node: SyntaxNode | undefined, - cast: (node: SyntaxNode) => T | undefined, + ...casts: ReadonlyArray<(node: SyntaxNode) => T | undefined> ): T | undefined { if (node === undefined) { return undefined; } - const current = cast(node); - if (current !== undefined) { - return current; - } - for (const ancestor of node.ancestors()) { - const result = cast(ancestor); - if (result !== undefined) return result; + for (const candidate of [node, ...node.ancestors()]) { + for (const cast of casts) { + const result = cast(candidate); + if (result !== undefined) { + return result; + } + } } return undefined; } @@ -509,14 +451,3 @@ function cursorIdentifier(at: TokenAtOffset, offset: number): SyntaxToken | unde } return undefined; } - -/** Whether a newline trivia token separates `from` from `toOffset`. */ -function newlineBetween(from: SyntaxToken, toOffset: number): boolean { - for (let token = from.nextToken; token !== undefined && token.offset < toOffset; ) { - if (token.kind === 'Newline') { - return true; - } - token = token.nextToken; - } - return false; -} 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 index 89ead0c266..c5a2164ac3 100644 --- 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 @@ -42,8 +42,11 @@ describe('classifyPslCompletionContext', () => { }); }); - it('does not classify declaration keyword prefixes after other tokens on the same line', () => { - expectUnsupported('model User {} mo|'); + it('offers a declaration keyword after a closing brace on the same line', () => { + expect(classify('model User {} mo|')).toMatchObject({ + kind: 'declarationKeyword', + scope: 'document', + }); }); it('classifies blank namespace-body declaration keyword positions', () => { From efac40ee4338e910cb0877851031d4843dbbf4e2 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 29 Jun 2026 17:55:54 +0000 Subject: [PATCH 32/54] refactor(language-server): compose multi-kind closestAst via an any() combinator Reverts closestAst to its single-cast form and adds a small any() cast-combinator, so the model-or-composite lookup reads closestAst(node, any(a, b)) instead of overloading closestAst with variadic casts. Single ancestor walk, identical behaviour. Signed-off-by: Serhii Tatarintsev --- .../language-server/src/completion-context.ts | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) 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 index cdf6b43a62..3b54a7ec52 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-context.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-context.ts @@ -131,7 +131,7 @@ export function classifyPslCompletionContext( return unsupported(offset); } if ( - closestAst(field.syntax, ModelDeclarationAst.cast, CompositeTypeDeclarationAst.cast) === + closestAst(field.syntax, any(ModelDeclarationAst.cast, CompositeTypeDeclarationAst.cast)) === undefined ) { return unsupported(offset); @@ -414,20 +414,36 @@ function hasUnsupportedAncestor(node: SyntaxNode | undefined): boolean { function closestAst( node: SyntaxNode | undefined, - ...casts: ReadonlyArray<(node: SyntaxNode) => T | undefined> + cast: (node: SyntaxNode) => T | undefined, ): T | undefined { if (node === undefined) { return undefined; } - for (const candidate of [node, ...node.ancestors()]) { + const current = cast(node); + if (current !== undefined) { + return current; + } + for (const ancestor of node.ancestors()) { + const result = cast(ancestor); + if (result !== undefined) { + return result; + } + } + return undefined; +} + +function any( + ...casts: ReadonlyArray<(node: SyntaxNode) => T | undefined> +): (node: SyntaxNode) => T | undefined { + return (node) => { for (const cast of casts) { - const result = cast(candidate); + const result = cast(node); if (result !== undefined) { return result; } } - } - return undefined; + return undefined; + }; } /** The nearest non-trivia token ending at or before the cursor. */ From 147369e51911f00b00d2a30f834f58d48e3be77b Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 29 Jun 2026 19:27:18 +0000 Subject: [PATCH 33/54] refactor(language-server): fold chained closestAst walks through any() hasUnsupportedAncestor (3 walks) and isInsideNonDeclarationKeywordBody (4 walks) each collapse to a single closestAst over an any() of their casts. The block kinds never nest within one another, so the nearest ancestor of any kind equals the OR of each kind. Explicit type args (BracedBlock / AstNode) pin inference to the shared interfaces. Signed-off-by: Serhii Tatarintsev --- .../language-server/src/completion-context.ts | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) 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 index 3b54a7ec52..46a1ff0959 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-context.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-context.ts @@ -1,4 +1,5 @@ import { + type AstNode, AttributeArgListAst, type BracedBlock, CompositeTypeDeclarationAst, @@ -288,11 +289,17 @@ function declarationKeywordAllowed( } function isInsideNonDeclarationKeywordBody(node: SyntaxNode | undefined, offset: number): boolean { - return ( - blockBodyContainsOffset(closestAst(node, ModelDeclarationAst.cast), offset) || - blockBodyContainsOffset(closestAst(node, CompositeTypeDeclarationAst.cast), offset) || - blockBodyContainsOffset(closestAst(node, TypesBlockAst.cast), offset) || - blockBodyContainsOffset(closestAst(node, GenericBlockDeclarationAst.cast), offset) + return blockBodyContainsOffset( + closestAst( + node, + any( + ModelDeclarationAst.cast, + CompositeTypeDeclarationAst.cast, + TypesBlockAst.cast, + GenericBlockDeclarationAst.cast, + ), + ), + offset, ); } @@ -406,9 +413,10 @@ function unsupported(offset: number): UnsupportedPslCompletionContext { function hasUnsupportedAncestor(node: SyntaxNode | undefined): boolean { return ( - closestAst(node, AttributeArgListAst.cast) !== undefined || - closestAst(node, FieldAttributeAst.cast) !== undefined || - closestAst(node, ModelAttributeAst.cast) !== undefined + closestAst( + node, + any(AttributeArgListAst.cast, FieldAttributeAst.cast, ModelAttributeAst.cast), + ) !== undefined ); } From c8a49389a103f61fc18f598a418028d4b8458e6a Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 29 Jun 2026 19:35:22 +0000 Subject: [PATCH 34/54] refactor(language-server): infer any() union via overload pair Replace the single-T any() signature with an overload pair: a precise generic public signature that distributes CastTarget over the cast tuple, plus a loose impl signature whose body type-checks without a cast. Call sites drop their explicit any / any type arguments and infer the union automatically. A single-signature const-generic form does not work: const only affects call-site tuple inference, not the body, where cast(node) stays opaque and is unassignable to the generic return type. Signed-off-by: Serhii Tatarintsev --- .../language-server/src/completion-context.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) 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 index 46a1ff0959..f52e34c73c 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-context.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-context.ts @@ -1,5 +1,4 @@ import { - type AstNode, AttributeArgListAst, type BracedBlock, CompositeTypeDeclarationAst, @@ -292,7 +291,7 @@ function isInsideNonDeclarationKeywordBody(node: SyntaxNode | undefined, offset: return blockBodyContainsOffset( closestAst( node, - any( + any( ModelDeclarationAst.cast, CompositeTypeDeclarationAst.cast, TypesBlockAst.cast, @@ -415,7 +414,7 @@ function hasUnsupportedAncestor(node: SyntaxNode | undefined): boolean { return ( closestAst( node, - any(AttributeArgListAst.cast, FieldAttributeAst.cast, ModelAttributeAst.cast), + any(AttributeArgListAst.cast, FieldAttributeAst.cast, ModelAttributeAst.cast), ) !== undefined ); } @@ -440,9 +439,14 @@ function closestAst( return undefined; } -function any( - ...casts: ReadonlyArray<(node: SyntaxNode) => T | undefined> -): (node: SyntaxNode) => T | undefined { +type CastTarget = C extends (node: SyntaxNode) => infer R ? Exclude : never; + +function any unknown)[]>( + ...casts: Casts +): (node: SyntaxNode) => CastTarget | undefined; +function any( + ...casts: ReadonlyArray<(node: SyntaxNode) => unknown> +): (node: SyntaxNode) => unknown { return (node) => { for (const cast of casts) { const result = cast(node); From dc43f259d686da181256c5ae01e0a1773eeb8c8a Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 29 Jun 2026 19:49:51 +0000 Subject: [PATCH 35/54] refactor(psl-parser): move closestAst + any into the parser package Relocate two completion-context helpers into @prisma-next/psl-parser so they sit with the AST navigation surface they belong to: - closestAst becomes SyntaxNode.findClosestParent, a self-inclusive generic method (tests the node itself before walking ancestors), mirroring rust-analyzer ancestors().find_map. - the any cast-combinator moves to ast-helpers next to findFirstChild / filterChildren and is exported from the syntax barrel. completion-context drops both local definitions, imports any, and rewires its eight call sites to node?.findClosestParent(...). Signed-off-by: Serhii Tatarintsev --- .../psl-parser/src/exports/syntax.ts | 8 ++- .../psl-parser/src/syntax/ast-helpers.ts | 19 ++++++ .../2-authoring/psl-parser/src/syntax/red.ts | 15 +++++ .../psl-parser/test/syntax/ast.test.ts | 34 +++++++++++ .../psl-parser/test/syntax/red.test.ts | 23 +++++++ .../language-server/src/completion-context.ts | 61 ++++--------------- 6 files changed, 109 insertions(+), 51 deletions(-) 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 839644b446..ebee9ba6a5 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 @@ -42,7 +42,13 @@ export { IdentifierAst } from '../syntax/ast/identifier'; export { QualifiedNameAst } from '../syntax/ast/qualified-name'; export { TypeAnnotationAst } from '../syntax/ast/type-annotation'; export type { AstNode, BracedBlock } from '../syntax/ast-helpers'; -export { filterChildren, findChildToken, findFirstChild, printSyntax } 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'; 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 51a2925d94..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 @@ -40,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/red.ts b/packages/1-framework/2-authoring/psl-parser/src/syntax/red.ts index 7cd47585a2..d6cbadc853 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 @@ -231,6 +231,21 @@ export class SyntaxNode { } } + /** The nearest match, testing this node itself before walking its ancestors. */ + findClosestParent(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()) { 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 7d1fb82aec..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(); 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 254904f194..1dbc0519ec 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,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { FieldDeclarationAst, ModelDeclarationAst } from '../../src/syntax/ast/declarations'; import { GreenNodeBuilder } from '../../src/syntax/green-builder'; import { createSyntaxTree, SyntaxNode, SyntaxToken, TokenAtOffset } from '../../src/syntax/red'; import type { SyntaxKind } from '../../src/syntax/syntax-kind'; @@ -288,6 +289,28 @@ describe('SyntaxNode.ancestors', () => { }); }); +describe('SyntaxNode.findClosestParent', () => { + it('returns the node itself when it satisfies the cast', () => { + const root = createSyntaxTree(buildSampleTree()); + const model = firstNodeOfKind(root, 'ModelDeclaration'); + const found = model.findClosestParent(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.findClosestParent(FieldDeclarationAst.cast); + expect(found?.syntax).toBe(field); + }); + + it('returns undefined when neither the node nor its ancestors match', () => { + const root = createSyntaxTree(buildSampleTree()); + expect(root.findClosestParent(FieldDeclarationAst.cast)).toBeUndefined(); + }); +}); + describe('SyntaxToken navigation', () => { it('exposes the parent node', () => { const root = createSyntaxTree(buildSampleTree()); 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 index f52e34c73c..99d0a85d9e 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-context.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-context.ts @@ -1,5 +1,6 @@ import { AttributeArgListAst, + any, type BracedBlock, CompositeTypeDeclarationAst, type DocumentAst, @@ -126,13 +127,14 @@ export function classifyPslCompletionContext( return genericBlockContext; } - const field = closestAst(contextNode, FieldDeclarationAst.cast); + const field = contextNode?.findClosestParent(FieldDeclarationAst.cast); if (field === undefined) { return unsupported(offset); } if ( - closestAst(field.syntax, any(ModelDeclarationAst.cast, CompositeTypeDeclarationAst.cast)) === - undefined + field.syntax.findClosestParent( + any(ModelDeclarationAst.cast, CompositeTypeDeclarationAst.cast), + ) === undefined ) { return unsupported(offset); } @@ -245,7 +247,7 @@ function classifyDeclarationKeyword(input: { return undefined; } - const namespace = closestAst(input.node, NamespaceDeclarationAst.cast); + const namespace = input.node?.findClosestParent(NamespaceDeclarationAst.cast); const scope = blockBodyContainsOffset(namespace, input.offset) ? 'namespace' : 'document'; const prefixToken = cursorIdentifier(input.at, input.offset); @@ -289,8 +291,7 @@ function declarationKeywordAllowed( function isInsideNonDeclarationKeywordBody(node: SyntaxNode | undefined, offset: number): boolean { return blockBodyContainsOffset( - closestAst( - node, + node?.findClosestParent( any( ModelDeclarationAst.cast, CompositeTypeDeclarationAst.cast, @@ -321,7 +322,7 @@ function classifyGenericBlockParameter(input: { readonly at: TokenAtOffset; readonly replacementStartOffset: number; }): PslCompletionContext | undefined { - const block = closestAst(input.node, GenericBlockDeclarationAst.cast); + const block = input.node?.findClosestParent(GenericBlockDeclarationAst.cast); if (block === undefined) { return undefined; } @@ -334,7 +335,7 @@ function classifyGenericBlockParameter(input: { return unsupported(input.offset); } - const field = closestAst(input.node, FieldDeclarationAst.cast); + const field = input.node?.findClosestParent(FieldDeclarationAst.cast); if (field?.syntax.isInside(input.offset)) { return unsupported(input.offset); } @@ -373,7 +374,7 @@ function activeKeyValuePair( node: SyntaxNode | undefined, offset: number, ): KeyValuePairAst | undefined { - const pair = closestAst(node, KeyValuePairAst.cast); + const pair = node?.findClosestParent(KeyValuePairAst.cast); if (pair === undefined || pair.syntax.isOutside(offset)) { return undefined; } @@ -412,52 +413,12 @@ function unsupported(offset: number): UnsupportedPslCompletionContext { function hasUnsupportedAncestor(node: SyntaxNode | undefined): boolean { return ( - closestAst( - node, + node?.findClosestParent( any(AttributeArgListAst.cast, FieldAttributeAst.cast, ModelAttributeAst.cast), ) !== undefined ); } -function closestAst( - node: SyntaxNode | undefined, - cast: (node: SyntaxNode) => T | undefined, -): T | undefined { - if (node === undefined) { - return undefined; - } - const current = cast(node); - if (current !== undefined) { - return current; - } - for (const ancestor of node.ancestors()) { - const result = cast(ancestor); - if (result !== undefined) { - return result; - } - } - return undefined; -} - -type CastTarget = C extends (node: SyntaxNode) => infer R ? Exclude : never; - -function any unknown)[]>( - ...casts: Casts -): (node: SyntaxNode) => CastTarget | undefined; -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; - }; -} - /** The nearest non-trivia token ending at or before the cursor. */ function previousSignificantToken(at: TokenAtOffset, offset: number): SyntaxToken | undefined { const left = at.leftBiased(); From fa890747fe60340cd3d0bf0074ea60e4e7000cce Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 29 Jun 2026 20:07:08 +0000 Subject: [PATCH 36/54] fix(language-server): only suppress completion inside comments, not before them The comment guard checked both leftBiased and rightBiased tokens. The rightBiased clause fires when the cursor sits in a real type position that merely precedes a trailing comment (model Post { author |// note }), wrongly returning unsupported instead of a model-type completion. Drop the rightBiased clause: a cursor inside a comment is detected by the leftBiased token alone. Add a regression test pinning that completion is still offered in a type position before a trailing comment. Signed-off-by: Serhii Tatarintsev --- .../3-tooling/language-server/src/completion-context.ts | 4 +++- .../language-server/test/completion-context.test.ts | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) 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 index 99d0a85d9e..cd194fd609 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-context.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-context.ts @@ -95,7 +95,9 @@ export function classifyPslCompletionContext( const root = input.document.syntax; const offset = input.sourceFile.offsetAt(input.position); const at = root.tokenAtOffset(offset); - if (at.rightBiased()?.kind === 'Comment' || at.leftBiased()?.kind === 'Comment') { + + // Completion is never offered when the cursor sits inside a comment. + if (at.leftBiased()?.kind === 'Comment') { return unsupported(offset); } 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 index c5a2164ac3..2822e4871e 100644 --- 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 @@ -87,6 +87,13 @@ describe('classifyPslCompletionContext', () => { 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('classifies a partial bare model field type prefix', () => { const context = classify(['model Post {', ' reviewer U|', '}'].join('\n')); From 49dfe4dab9a577fbb7e855e181aca13bf66058d6 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 29 Jun 2026 20:18:26 +0000 Subject: [PATCH 37/54] refactor(psl-parser): rename findClosestParent to findAncestor Signed-off-by: Serhii Tatarintsev --- .../2-authoring/psl-parser/src/syntax/red.ts | 2 +- .../psl-parser/test/syntax/red.test.ts | 8 ++++---- .../language-server/src/completion-context.ts | 19 +++++++++---------- 3 files changed, 14 insertions(+), 15 deletions(-) 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 d6cbadc853..59c6e9d279 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 @@ -232,7 +232,7 @@ export class SyntaxNode { } /** The nearest match, testing this node itself before walking its ancestors. */ - findClosestParent(cast: (node: SyntaxNode) => T | undefined): T | undefined { + findAncestor(cast: (node: SyntaxNode) => T | undefined): T | undefined { const self = cast(this); if (self !== undefined) { return self; 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 1dbc0519ec..947cd6d99b 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 @@ -289,11 +289,11 @@ describe('SyntaxNode.ancestors', () => { }); }); -describe('SyntaxNode.findClosestParent', () => { +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.findClosestParent(ModelDeclarationAst.cast); + const found = model.findAncestor(ModelDeclarationAst.cast); expect(found?.syntax).toBe(model); }); @@ -301,13 +301,13 @@ describe('SyntaxNode.findClosestParent', () => { const root = createSyntaxTree(buildSampleTree()); const field = firstNodeOfKind(root, 'FieldDeclaration'); const identifier = firstNodeOfKind(field, 'Identifier'); - const found = identifier.findClosestParent(FieldDeclarationAst.cast); + 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.findClosestParent(FieldDeclarationAst.cast)).toBeUndefined(); + expect(root.findAncestor(FieldDeclarationAst.cast)).toBeUndefined(); }); }); 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 index cd194fd609..891e0af12d 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-context.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-context.ts @@ -129,14 +129,13 @@ export function classifyPslCompletionContext( return genericBlockContext; } - const field = contextNode?.findClosestParent(FieldDeclarationAst.cast); + const field = contextNode?.findAncestor(FieldDeclarationAst.cast); if (field === undefined) { return unsupported(offset); } if ( - field.syntax.findClosestParent( - any(ModelDeclarationAst.cast, CompositeTypeDeclarationAst.cast), - ) === undefined + field.syntax.findAncestor(any(ModelDeclarationAst.cast, CompositeTypeDeclarationAst.cast)) === + undefined ) { return unsupported(offset); } @@ -249,7 +248,7 @@ function classifyDeclarationKeyword(input: { return undefined; } - const namespace = input.node?.findClosestParent(NamespaceDeclarationAst.cast); + const namespace = input.node?.findAncestor(NamespaceDeclarationAst.cast); const scope = blockBodyContainsOffset(namespace, input.offset) ? 'namespace' : 'document'; const prefixToken = cursorIdentifier(input.at, input.offset); @@ -293,7 +292,7 @@ function declarationKeywordAllowed( function isInsideNonDeclarationKeywordBody(node: SyntaxNode | undefined, offset: number): boolean { return blockBodyContainsOffset( - node?.findClosestParent( + node?.findAncestor( any( ModelDeclarationAst.cast, CompositeTypeDeclarationAst.cast, @@ -324,7 +323,7 @@ function classifyGenericBlockParameter(input: { readonly at: TokenAtOffset; readonly replacementStartOffset: number; }): PslCompletionContext | undefined { - const block = input.node?.findClosestParent(GenericBlockDeclarationAst.cast); + const block = input.node?.findAncestor(GenericBlockDeclarationAst.cast); if (block === undefined) { return undefined; } @@ -337,7 +336,7 @@ function classifyGenericBlockParameter(input: { return unsupported(input.offset); } - const field = input.node?.findClosestParent(FieldDeclarationAst.cast); + const field = input.node?.findAncestor(FieldDeclarationAst.cast); if (field?.syntax.isInside(input.offset)) { return unsupported(input.offset); } @@ -376,7 +375,7 @@ function activeKeyValuePair( node: SyntaxNode | undefined, offset: number, ): KeyValuePairAst | undefined { - const pair = node?.findClosestParent(KeyValuePairAst.cast); + const pair = node?.findAncestor(KeyValuePairAst.cast); if (pair === undefined || pair.syntax.isOutside(offset)) { return undefined; } @@ -415,7 +414,7 @@ function unsupported(offset: number): UnsupportedPslCompletionContext { function hasUnsupportedAncestor(node: SyntaxNode | undefined): boolean { return ( - node?.findClosestParent( + node?.findAncestor( any(AttributeArgListAst.cast, FieldAttributeAst.cast, ModelAttributeAst.cast), ) !== undefined ); From 45ef2a6895a4e7d284947ec1e8c15586c7de02be Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 29 Jun 2026 20:22:11 +0000 Subject: [PATCH 38/54] refactor(language-server): drop dead offset from unsupported context The offset field on UnsupportedPslCompletionContext was never read: the provider short-circuits the unsupported case to an empty list, and no test asserts it. Remove the field, delete the unsupported(offset) helper that threaded it, and return a single UNSUPPORTED sentinel from every non-completable position. Signed-off-by: Serhii Tatarintsev --- .../language-server/src/completion-context.ts | 43 +++++++++---------- 1 file changed, 20 insertions(+), 23 deletions(-) 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 index 891e0af12d..53cd9721c3 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-context.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-context.ts @@ -77,7 +77,6 @@ export interface DeclarationKeywordCompletionContext { export interface UnsupportedPslCompletionContext { readonly kind: 'unsupported'; - readonly offset: number; } export type PslCompletionContext = @@ -89,6 +88,8 @@ export type PslCompletionContext = | SpaceMemberCompletionContext | UnsupportedPslCompletionContext; +const UNSUPPORTED: UnsupportedPslCompletionContext = { kind: 'unsupported' }; + export function classifyPslCompletionContext( input: ClassifyPslCompletionContextInput, ): PslCompletionContext { @@ -98,7 +99,7 @@ export function classifyPslCompletionContext( // Completion is never offered when the cursor sits inside a comment. if (at.leftBiased()?.kind === 'Comment') { - return unsupported(offset); + return UNSUPPORTED; } // Anchor on the token left of the cursor and navigate outward via @@ -131,13 +132,13 @@ export function classifyPslCompletionContext( const field = contextNode?.findAncestor(FieldDeclarationAst.cast); if (field === undefined) { - return unsupported(offset); + return UNSUPPORTED; } if ( field.syntax.findAncestor(any(ModelDeclarationAst.cast, CompositeTypeDeclarationAst.cast)) === undefined ) { - return unsupported(offset); + return UNSUPPORTED; } return classifyModelFieldType({ @@ -154,22 +155,22 @@ function classifyModelFieldType(input: { }): PslCompletionContext { const fieldName = input.field.name(); if (fieldName === undefined) { - return unsupported(input.offset); + return UNSUPPORTED; } const fieldNameText = fieldName.name(); if (fieldNameText === undefined) { - return unsupported(input.offset); + return UNSUPPORTED; } const fieldNameEnd = fieldName.syntax.endOffset; if (fieldName.syntax.isInside(input.offset)) { - return unsupported(input.offset); + return UNSUPPORTED; } const typeAnnotation = input.field.typeAnnotation(); if (typeAnnotation === undefined) { - return unsupported(input.offset); + return UNSUPPORTED; } const typeStart = typeAnnotation.syntax.offset; @@ -183,27 +184,27 @@ function classifyModelFieldType(input: { replacementStartOffset: input.offset, }; } - return unsupported(input.offset); + return UNSUPPORTED; } if (typeAnnotation.syntax.isOutside(input.offset)) { - return unsupported(input.offset); + return UNSUPPORTED; } const constructorArgList = typeAnnotation.argList(); if (constructorArgList?.syntax.isInside(input.offset)) { - return unsupported(input.offset); + return UNSUPPORTED; } const name = typeAnnotation.name(); if (name === undefined) { - return unsupported(input.offset); + return UNSUPPORTED; } if (name.syntax.isOutside(input.offset)) { - return unsupported(input.offset); + return UNSUPPORTED; } if (name.isOverQualified()) { - return unsupported(input.offset); + return UNSUPPORTED; } return classifyTypePosition(name, input.offset, fieldNameText, input.replacementStartOffset); @@ -329,21 +330,21 @@ function classifyGenericBlockParameter(input: { } if (hasUnsupportedAncestor(input.node)) { - return unsupported(input.offset); + return UNSUPPORTED; } if (!blockBodyContainsOffset(block, input.offset)) { - return unsupported(input.offset); + return UNSUPPORTED; } const field = input.node?.findAncestor(FieldDeclarationAst.cast); if (field?.syntax.isInside(input.offset)) { - return unsupported(input.offset); + return UNSUPPORTED; } const keyword = block.keyword()?.text; if (keyword === undefined || keyword.length === 0) { - return unsupported(input.offset); + return UNSUPPORTED; } // Value position: the cursor follows a `=`. The position is now classified @@ -359,7 +360,7 @@ function classifyGenericBlockParameter(input: { const activePair = activeKeyValuePair(input.node, input.offset); if (activePair !== undefined && isAfterEquals(activePair, input.offset)) { - return unsupported(input.offset); + return UNSUPPORTED; } return { @@ -408,10 +409,6 @@ function sameSpan(left: SyntaxNode, right: SyntaxNode): boolean { return left.offset === right.offset && left.textLength === right.textLength; } -function unsupported(offset: number): UnsupportedPslCompletionContext { - return { kind: 'unsupported', offset }; -} - function hasUnsupportedAncestor(node: SyntaxNode | undefined): boolean { return ( node?.findAncestor( From 4cca99dfacdb4f5ad496f4344bd650d5d96ffdd9 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 08:56:52 +0000 Subject: [PATCH 39/54] refactor(psl-parser): navigate siblings via stored child index Red nodes now carry their index within the parent, set once at construction. Sibling navigation becomes childAt(parent, index +/- 1), matching the rust-analyzer red-tree idiom, and the bespoke siblingAfter / siblingBefore green-layer walkers are deleted. Navigation no longer touches the green layer or guesses node identity from (green, offset); the index also disambiguates a zero-width child from an offset-colliding neighbour, which an offset-only scheme cannot. Signed-off-by: Serhii Tatarintsev --- .../2-authoring/psl-parser/src/syntax/red.ts | 76 +++++++------------ .../psl-parser/test/syntax/red.test.ts | 51 +++++++++++++ 2 files changed, 78 insertions(+), 49 deletions(-) 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 59c6e9d279..a63699d37b 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 @@ -15,13 +15,16 @@ export class SyntaxToken implements Token { 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) { + 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 { @@ -45,12 +48,12 @@ export class SyntaxToken implements Token { /** The sibling element immediately after this token within its parent. */ get nextSiblingOrToken(): SyntaxElement | undefined { - return siblingAfter(this.parent, this.green, this.offset); + return childAt(this.parent, this.index + 1); } /** The sibling element immediately before this token within its parent. */ get prevSiblingOrToken(): SyntaxElement | undefined { - return siblingBefore(this.parent, this.green, this.offset); + return childAt(this.parent, this.index - 1); } /** The next token in document order, crossing node boundaries. */ @@ -140,11 +143,14 @@ 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 { @@ -180,13 +186,11 @@ export class SyntaxNode { } get nextSibling(): SyntaxElement | undefined { - if (!this.parent) return undefined; - return siblingAfter(this.parent, this.green, this.offset); + return this.parent === undefined ? undefined : childAt(this.parent, this.index + 1); } get prevSibling(): SyntaxElement | undefined { - if (!this.parent) return undefined; - return siblingBefore(this.parent, this.green, this.offset); + return this.parent === undefined ? undefined : childAt(this.parent, this.index - 1); } /** The sibling element immediately after this node within its parent. */ @@ -211,9 +215,11 @@ export class SyntaxNode { *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++; } } @@ -379,45 +385,12 @@ function lastToken(el: SyntaxElement): SyntaxToken | undefined { return undefined; } -function siblingAfter( - parent: SyntaxNode, - green: GreenElement, - offset: number, -): SyntaxElement | undefined { - let cursor = parent.offset; - let found = false; - for (const child of parent.green.children) { - if (found) return wrapElement(child, cursor, parent); - if (child === green && cursor === offset) found = true; - cursor += elementTextLength(child); - } - return undefined; -} - -function siblingBefore( - parent: SyntaxNode, - green: GreenElement, - offset: number, -): SyntaxElement | undefined { - let cursor = parent.offset; - let prev: { green: GreenElement; offset: number } | undefined; - for (const child of parent.green.children) { - if (child === green && cursor === offset) { - if (prev === undefined) return undefined; - return wrapElement(prev.green, prev.offset, parent); - } - prev = { green: child, offset: cursor }; - cursor += elementTextLength(child); - } - return undefined; -} - function climbingNext(el: SyntaxElement): SyntaxElement | undefined { let current: SyntaxElement = el; for (;;) { const parent = current.parent; if (parent === undefined) return undefined; - const sibling = siblingAfter(parent, current.green, current.offset); + const sibling = childAt(parent, current.index + 1); if (sibling !== undefined) return sibling; current = parent; } @@ -428,17 +401,22 @@ function climbingPrev(el: SyntaxElement): SyntaxElement | undefined { for (;;) { const parent = current.parent; if (parent === undefined) return undefined; - const sibling = siblingBefore(parent, current.green, current.offset); + const sibling = childAt(parent, current.index - 1); if (sibling !== undefined) return sibling; current = parent; } } -function wrapElement(green: GreenElement, offset: number, parent: SyntaxNode): SyntaxElement { +function wrapElement( + green: GreenElement, + offset: number, + parent: SyntaxNode, + index: number, +): SyntaxElement { if (green.type === 'token') { - return new SyntaxToken(green, offset, parent); + 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 { @@ -452,9 +430,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/syntax/red.test.ts b/packages/1-framework/2-authoring/psl-parser/test/syntax/red.test.ts index 947cd6d99b..70476f087a 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 @@ -193,6 +193,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}'; From 0cd3ef0c690ed5d6e51d4ce4fc7a08a37faf64d0 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 09:03:55 +0000 Subject: [PATCH 40/54] refactor(language-server): read decorator @ prefix from the token, not source text The decorator highlighter walked the raw source string backwards over @ characters to extend the first segment range. The @ / @@ prefix is a real token (At / DoubleAt), so read its offset from the attribute node via findChildToken instead. Drops the character scan and the sourceText threading through collectDecoratorName / rangeForDecoratorIdentifier. Signed-off-by: Serhii Tatarintsev --- .../language-server/src/semantic-tokens.ts | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) 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, From 1d2866cebe89fae1aae6f969072cd957de60163b Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 09:08:38 +0000 Subject: [PATCH 41/54] refactor(psl-parser): compose previousNonTriviaToken from skipTriviaToken previousNonTriviaToken hand-rolled the trivia-skipping loop that skipTriviaToken already encapsulates. Step strictly backwards once, then delegate to skipTriviaToken, matching the rust-analyzer composition (prev_token then skip_trivia_token) instead of duplicating it. Signed-off-by: Serhii Tatarintsev --- .../2-authoring/psl-parser/src/syntax/navigation.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) 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 index ccd6f25908..a438c97ec3 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/syntax/navigation.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/syntax/navigation.ts @@ -56,11 +56,8 @@ export function nonTriviaSibling( */ export function previousNonTriviaToken(element: SyntaxElement): SyntaxToken | undefined { const start = element instanceof SyntaxToken ? element : element.firstToken; - if (start === undefined) return undefined; - for (let prev = start.prevToken; prev !== undefined; prev = prev.prevToken) { - if (!isTrivia(prev)) return prev; - } - return undefined; + const prev = start?.prevToken; + return prev === undefined ? undefined : skipTriviaToken(prev, 'prev'); } function step(element: SyntaxElement, direction: Direction): SyntaxElement | undefined { From 0997d59cfa9d36e8878ee9f402c0159f50b32fff Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 12:21:33 +0000 Subject: [PATCH 42/54] refactor(language-server): classify declaration-keyword position structurally Replace the token-scan declaration-keyword logic with a structural rule: a keyword is offered unless the cursor sits inside an established declaration. The nearest declaration ancestor permits a keyword only when it is still nascent (its sole significant child is the keyword), when the cursor is past its closing brace, or when it is a namespace body offering a fresh nested slot. This fixes the cursor wedged right after a complete declaration (model A {}|, previously misread as inside the model) while keeping a mid-header position (model A|) correctly rejected. Deletes the isInsideNonDeclarationKeywordBody and declarationKeywordAllowed helpers; previousSignificantToken stays for the generic-block value position. Signed-off-by: Serhii Tatarintsev --- .../language-server/src/completion-context.ts | 84 +++++++++---------- .../test/completion-context.test.ts | 11 +++ 2 files changed, 49 insertions(+), 46 deletions(-) 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 index 53cd9721c3..4c5cb3561e 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-context.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-context.ts @@ -17,7 +17,7 @@ import { type QualifiedNameAst, type SourceFile, type SyntaxNode, - type SyntaxToken, + SyntaxToken, type TokenAtOffset, TypesBlockAst, } from '@prisma-next/psl-parser/syntax'; @@ -113,7 +113,6 @@ export function classifyPslCompletionContext( const declarationKeywordContext = classifyDeclarationKeyword({ node: contextNode, offset, - at, replacementStartOffset, }); if (declarationKeywordContext !== undefined) { @@ -239,70 +238,63 @@ function classifyTypePosition( 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 at: TokenAtOffset; readonly replacementStartOffset: number; }): DeclarationKeywordCompletionContext | undefined { - if (isInsideNonDeclarationKeywordBody(input.node, input.offset)) { - return undefined; - } - + const declaration = input.node?.findAncestor(declarationCast); const namespace = input.node?.findAncestor(NamespaceDeclarationAst.cast); - const scope = blockBodyContainsOffset(namespace, input.offset) ? 'namespace' : 'document'; + const inNamespaceBody = blockBodyContainsOffset(namespace, input.offset); - const prefixToken = cursorIdentifier(input.at, input.offset); - if (!declarationKeywordAllowed(prefixToken, namespace, input)) { + if ( + declaration !== undefined && + !canBeginDeclaration(declaration, input.offset, inNamespaceBody) + ) { return undefined; } return { kind: 'declarationKeyword', offset: input.offset, - scope, + scope: inNamespaceBody ? 'namespace' : 'document', replacementStartOffset: input.replacementStartOffset, }; } /** - * A declaration keyword may be completed where a new declaration can begin: at - * the start of the document or namespace body, immediately after a previous - * declaration's closing `}`, or right after the enclosing namespace's opening - * brace. Newlines are trivia and play no role — `model A {} model B {}` is valid - * PSL on a single line. + * A declaration keyword may be completed where a new declaration can begin. With + * the cursor inside an established declaration that is only allowed when the + * declaration is still nascent (its sole significant child is the keyword), the + * cursor sits past its closing `}`, or it is a namespace body offering a fresh + * slot for a nested declaration. */ -function declarationKeywordAllowed( - prefixToken: SyntaxToken | undefined, - namespace: NamespaceDeclarationAst | undefined, - input: { readonly offset: number; readonly at: TokenAtOffset }, +function canBeginDeclaration( + declaration: DeclarationAst, + offset: number, + inNamespaceBody: boolean, ): boolean { - const previous = - prefixToken !== undefined - ? previousNonTriviaToken(prefixToken) - : previousSignificantToken(input.at, input.offset); - if (previous === undefined) { - return true; - } - if (previous.kind === 'RBrace') { - return true; - } - const lbrace = namespace?.lbrace(); - return lbrace !== undefined && lbrace.offset === previous.offset; -} - -function isInsideNonDeclarationKeywordBody(node: SyntaxNode | undefined, offset: number): boolean { - return blockBodyContainsOffset( - node?.findAncestor( - any( - ModelDeclarationAst.cast, - CompositeTypeDeclarationAst.cast, - TypesBlockAst.cast, - GenericBlockDeclarationAst.cast, - ), - ), - offset, - ); + let significantChildren = 0; + for (const child of declaration.syntax.children()) { + if (!(child instanceof SyntaxToken) || !isTrivia(child)) { + significantChildren++; + } + } + const keywordOnly = significantChildren === 1; + const rbrace = declaration.rbrace(); + const pastRbrace = rbrace !== undefined && offset >= rbrace.endOffset; + const freshNamespaceSlot = declaration instanceof NamespaceDeclarationAst && inNamespaceBody; + return keywordOnly || pastRbrace || freshNamespaceSlot; } function blockBodyContainsOffset(block: BracedBlock | undefined, offset: number): boolean { 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 index 2822e4871e..fdb27a73e5 100644 --- 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 @@ -49,6 +49,17 @@ describe('classifyPslCompletionContext', () => { }); }); + 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('classifies blank namespace-body declaration keyword positions', () => { const context = classify(['namespace auth {', ' |', '}'].join('\n')); From bbc60d8435d0ece9ae71c5e22ae927685ac0c431 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 12:51:41 +0000 Subject: [PATCH 43/54] refactor(language-server): detect keyword-only declarations via accessors canBeginDeclaration counted non-trivia children to decide whether a declaration was still nascent. Read it off the AST instead: a nascent declaration has no lbrace and no name. TypesBlockAst is anonymous (no name accessor), so discriminate it with instanceof, which also narrows the union so name() resolves on the rest. The namespace case is now an explicit branch (a namespace permits a keyword only in its body) rather than an opaque combined flag. Signed-off-by: Serhii Tatarintsev --- .../language-server/src/completion-context.ts | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) 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 index 4c5cb3561e..f7c60081b4 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-context.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-context.ts @@ -17,7 +17,7 @@ import { type QualifiedNameAst, type SourceFile, type SyntaxNode, - SyntaxToken, + type SyntaxToken, type TokenAtOffset, TypesBlockAst, } from '@prisma-next/psl-parser/syntax'; @@ -273,28 +273,27 @@ function classifyDeclarationKeyword(input: { } /** - * A declaration keyword may be completed where a new declaration can begin. With - * the cursor inside an established declaration that is only allowed when the - * declaration is still nascent (its sole significant child is the keyword), the - * cursor sits past its closing `}`, or it is a namespace body offering a fresh - * slot for a nested declaration. + * 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 canBeginDeclaration( declaration: DeclarationAst, offset: number, inNamespaceBody: boolean, ): boolean { - let significantChildren = 0; - for (const child of declaration.syntax.children()) { - if (!(child instanceof SyntaxToken) || !isTrivia(child)) { - significantChildren++; - } + const keywordOnly = + declaration.lbrace() === undefined && + (declaration instanceof TypesBlockAst || declaration.name() === undefined); + if (keywordOnly) { + return true; + } + if (declaration instanceof NamespaceDeclarationAst) { + return inNamespaceBody; } - const keywordOnly = significantChildren === 1; const rbrace = declaration.rbrace(); - const pastRbrace = rbrace !== undefined && offset >= rbrace.endOffset; - const freshNamespaceSlot = declaration instanceof NamespaceDeclarationAst && inNamespaceBody; - return keywordOnly || pastRbrace || freshNamespaceSlot; + return rbrace !== undefined && offset >= rbrace.endOffset; } function blockBodyContainsOffset(block: BracedBlock | undefined, offset: number): boolean { From 63e92d3068702541a85911f7b9910e4069242a63 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 13:36:55 +0000 Subject: [PATCH 44/54] refactor(psl-parser): stop emitting the empty TypeAnnotation placeholder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A field with no type emitted a zero-width TypeAnnotation node — a deviation from the rust-analyzer rule that missing constructs are absent, not materialized empty. parseTypeAnnotation now emits a node only when type content is present. Field-type completion relied on that placeholder to anchor the empty type slot. Without it, a typeless field has no TypeAnnotation and its trailing trivia attaches to the enclosing block, so the slot is found positionally: fieldForTypeSlot resolves the field via the nearest significant token to the left, and emptyTypeSlotEnd bounds the slot at the first attribute or next sibling. The textLength === 0 branch is deleted (no zero-width node can exist for it to match). Signed-off-by: Serhii Tatarintsev --- .../2-authoring/psl-parser/src/parse.ts | 14 ++++-- .../psl-parser/test/parse-leaf.test.ts | 41 ++++++++++----- .../language-server/src/completion-context.ts | 50 ++++++++++++++++--- .../test/completion-context.test.ts | 11 ++++ 4 files changed, 92 insertions(+), 24 deletions(-) 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..9a4b3aada9 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/parse.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/parse.ts @@ -435,8 +435,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 +461,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/test/parse-leaf.test.ts b/packages/1-framework/2-authoring/psl-parser/test/parse-leaf.test.ts index 297d89ca7d..11067a310c 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,21 @@ 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 }; +} + describe('parseAttribute well-formed', () => { it('parses a simple field attribute', () => { const source = '@id'; @@ -311,7 +326,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 +340,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 +357,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 +378,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 +395,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 +415,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 +438,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 +466,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 +484,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 +501,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 +518,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 +529,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); 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 index f7c60081b4..0d8c9735bc 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-context.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-context.ts @@ -12,12 +12,14 @@ import { ModelAttributeAst, ModelDeclarationAst, NamespaceDeclarationAst, + nonTriviaSibling, type Position, previousNonTriviaToken, type QualifiedNameAst, type SourceFile, type SyntaxNode, type SyntaxToken, + skipTriviaToken, type TokenAtOffset, TypesBlockAst, } from '@prisma-next/psl-parser/syntax'; @@ -129,7 +131,7 @@ export function classifyPslCompletionContext( return genericBlockContext; } - const field = contextNode?.findAncestor(FieldDeclarationAst.cast); + const field = fieldForTypeSlot(contextNode, at); if (field === undefined) { return UNSUPPORTED; } @@ -147,6 +149,43 @@ export function classifyPslCompletionContext( }); } +/** + * Locates the field whose type position the cursor occupies. A present type or + * attribute keeps the cursor anchored inside the field, so the node ancestry + * resolves it directly. A typeless field emits no `TypeAnnotation`, so its + * trailing trivia belongs to the enclosing block instead; there the field is + * the owner of the nearest significant token to the left. + */ +function fieldForTypeSlot( + contextNode: SyntaxNode | undefined, + at: TokenAtOffset, +): FieldDeclarationAst | undefined { + const direct = contextNode?.findAncestor(FieldDeclarationAst.cast); + if (direct !== undefined) { + return direct; + } + const left = at.leftBiased(); + if (left === undefined) { + return undefined; + } + const significant = isTrivia(left) ? skipTriviaToken(left, 'prev') : left; + return significant?.parent.findAncestor(FieldDeclarationAst.cast); +} + +/** + * The upper bound of a typeless field's empty type slot: the first attribute + * when present, otherwise the next significant token after the field (the + * following member or the block's closing brace), since a typeless field's + * trailing trivia lives in the enclosing block. + */ +function emptyTypeSlotEnd(field: FieldDeclarationAst): number { + for (const attribute of field.attributes()) { + return attribute.syntax.offset; + } + const nextSignificant = nonTriviaSibling(field.syntax, 'next'); + return nextSignificant?.offset ?? field.syntax.endOffset; +} + function classifyModelFieldType(input: { readonly field: FieldDeclarationAst; readonly offset: number; @@ -169,13 +208,8 @@ function classifyModelFieldType(input: { const typeAnnotation = input.field.typeAnnotation(); if (typeAnnotation === undefined) { - return UNSUPPORTED; - } - - const typeStart = typeAnnotation.syntax.offset; - - if (typeAnnotation.syntax.textLength === 0) { - if (input.offset > fieldNameEnd && input.offset <= typeStart) { + const slotEnd = emptyTypeSlotEnd(input.field); + if (input.offset > fieldNameEnd && input.offset <= slotEnd) { return { kind: 'modelType', offset: input.offset, 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 index fdb27a73e5..a4ea89eae4 100644 --- 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 @@ -105,6 +105,17 @@ describe('classifyPslCompletionContext', () => { }); }); + 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('classifies a partial bare model field type prefix', () => { const context = classify(['model Post {', ' reviewer U|', '}'].join('\n')); From 00e29a3897408231957c92e3d3d38f6c22ac7109 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 14:00:03 +0000 Subject: [PATCH 45/54] refactor(psl-parser): drop empty AttributeArg placeholder and zero-width navigation handling parseAttributeArg, like parseTypeAnnotation before it, emitted a zero-width AttributeArg node when no named-arg or value-expression was present (e.g. @default(,)). Guard on the expression FIRST-set so a node is emitted only when the arg has content. With both placeholder emits gone, no non-root node can be zero-width, so the red-tree navigation no longer needs to special-case it: isInside / containsOffset / containsRange collapse to the plain inclusive span, and the zero-width child skip in tokenAtOffset is removed. A parametrized test pins the precondition across malformed inputs; the obsolete synthetic zero-width-seam test is removed. Signed-off-by: Serhii Tatarintsev --- .../2-authoring/psl-parser/src/parse.ts | 14 ++++- .../2-authoring/psl-parser/src/syntax/red.ts | 25 +++------ .../psl-parser/test/parse-leaf.test.ts | 35 ++++++++---- .../psl-parser/test/syntax/red.test.ts | 53 ++++++++++++------- 4 files changed, 79 insertions(+), 48 deletions(-) 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 9a4b3aada9..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 { 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 a63699d37b..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 @@ -35,11 +35,9 @@ export class SyntaxToken implements Token { return this.offset + this.textLength; } - /** Whether `offset` falls within this token, using the zero-width containment rule. */ + /** Whether `offset` falls within this token, inclusive of both ends. */ isInside(offset: number): boolean { - const start = this.offset; - const len = this.text.length; - return len === 0 ? offset === start : offset >= start && offset <= start + len; + return offset >= this.offset && offset <= this.endOffset; } isOutside(offset: number): boolean { @@ -165,10 +163,9 @@ export class SyntaxNode { return this.offset + this.textLength; } - /** Whether `offset` falls within this node, using the zero-width containment rule. */ + /** Whether `offset` falls within this node, inclusive of both ends. */ isInside(offset: number): boolean { - const len = this.textLength; - return len === 0 ? offset === this.offset : offset >= this.offset && offset <= this.endOffset; + return offset >= this.offset && offset <= this.endOffset; } isOutside(offset: number): boolean { @@ -279,8 +276,6 @@ export class SyntaxNode { /** * 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. - * Consistent with the zero-width containment rule used by {@link containsOffset}: - * empty children are never the token at an offset. */ tokenAtOffset(offset: number): TokenAtOffset { return tokenAtOffsetOf(this, offset); @@ -289,8 +284,7 @@ export class SyntaxNode { /** * 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 zero-width rule (`textLength === 0` ⇒ contains only - * the empty range at `start`). + * {@link containsOffset}'s inclusive span. */ coveringElement(start: number, end: number): SyntaxElement { let result: SyntaxElement = this; @@ -318,20 +312,18 @@ function elementLength(el: SyntaxElement): number { } /** - * Whether `el` contains `offset`. A zero-width element contains only the empty - * position at its start (`textLength === 0` ⇒ `offset === start`); otherwise the - * span is inclusive on both ends so a seam offset touches both neighbours. + * 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 len === 0 ? offset === start : offset >= start && offset <= start + len; + return offset >= start && offset <= start + len; } function containsRange(el: SyntaxElement, start: number, end: number): boolean { const elStart = el.offset; const len = elementLength(el); - if (len === 0) return start === elStart && end === elStart; return elStart <= start && end <= elStart + len; } @@ -342,7 +334,6 @@ function tokenAtOffsetOf(el: SyntaxElement, offset: number): TokenAtOffset { let left: SyntaxElement | undefined; let right: SyntaxElement | undefined; for (const child of el.children()) { - if (elementLength(child) === 0) continue; if (!containsOffset(child, offset)) continue; if (left === undefined) { left = child; 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 11067a310c..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 @@ -119,6 +119,21 @@ function parseTypeAnnotationTree(source: string) { 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'; @@ -197,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 @@ -210,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 @@ -614,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); @@ -634,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); @@ -648,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); @@ -661,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'); @@ -673,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'); @@ -682,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'); @@ -697,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([]); @@ -712,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/red.test.ts b/packages/1-framework/2-authoring/psl-parser/test/syntax/red.test.ts index 70476f087a..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,4 +1,5 @@ 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, SyntaxToken, TokenAtOffset } from '../../src/syntax/red'; @@ -460,25 +461,6 @@ describe('SyntaxNode.tokenAtOffset', () => { expect(at.rightBiased()).toBeUndefined(); }); - it('skips a zero-width node sitting on the seam', () => { - 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 at = root.tokenAtOffset(1); - expect(at.isBetween).toBe(true); - expect(at.leftBiased()?.text).toBe('A'); - expect(at.rightBiased()?.text).toBe('B'); - }); - it('returns none for an empty document', () => { const b = new GreenNodeBuilder(); b.startNode('Document'); @@ -596,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); + } + } + }); + } +}); From 7b8c073aed8fdcb1e99ec63505fc0290f2af6cc0 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 15:24:52 +0000 Subject: [PATCH 46/54] refactor(language-server): unify gap recovery onto a single contextToken primitive The completion classifier recovered "cursor in a gap with no node to anchor on" three different ways: a raw leftBiased().parent anchor, a trivia-branching fallback inside fieldForTypeSlot, and a bespoke previousSignificantToken for the generic-block value check. Collapse all three onto one cursor-relative look-left primitive, contextToken, which skips the in-progress edit identifier the way tsserver adjusts previousToken to contextToken. The generic-block structural checks (which block, which pair, in-field) stay anchored on the cursor own node, since whether the cursor sits in a key/value/ attribute slot is a node-structural question, not a look-left one; only the value-gap check moves to contextToken. canBeginDeclaration generalizes its past-rbrace clause to namespaces so a cursor past a closed namespace closing brace can still begin a document-level declaration, consistent with contextToken anchoring on the closing brace. previousSignificantToken is deleted; previousNonTriviaToken is removed from this file imports (its parser-side removal follows). Signed-off-by: Serhii Tatarintsev --- .../language-server/src/completion-context.ts | 69 ++++++++----------- .../test/completion-context.test.ts | 28 ++++++++ 2 files changed, 56 insertions(+), 41 deletions(-) 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 index 0d8c9735bc..28f540b837 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-context.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-context.ts @@ -7,14 +7,12 @@ import { FieldAttributeAst, FieldDeclarationAst, GenericBlockDeclarationAst, - isTrivia, KeyValuePairAst, ModelAttributeAst, ModelDeclarationAst, NamespaceDeclarationAst, nonTriviaSibling, type Position, - previousNonTriviaToken, type QualifiedNameAst, type SourceFile, type SyntaxNode, @@ -104,9 +102,9 @@ export function classifyPslCompletionContext( return UNSUPPORTED; } - // Anchor on the token left of the cursor and navigate outward via - // `token.parent` rather than scanning the whole tree. - const contextNode = at.leftBiased()?.parent; + // Anchor on the significant token that gives the cursor its context and + // navigate outward via `token.parent` rather than scanning the whole tree. + const contextNode = contextToken(at, offset)?.parent; // The edit replaces the identifier under the cursor, or is empty when the // cursor sits in trivia. @@ -122,7 +120,6 @@ export function classifyPslCompletionContext( } const genericBlockContext = classifyGenericBlockParameter({ - node: contextNode, offset, at, replacementStartOffset, @@ -131,7 +128,7 @@ export function classifyPslCompletionContext( return genericBlockContext; } - const field = fieldForTypeSlot(contextNode, at); + const field = fieldForTypeSlot(contextNode); if (field === undefined) { return UNSUPPORTED; } @@ -150,26 +147,14 @@ export function classifyPslCompletionContext( } /** - * Locates the field whose type position the cursor occupies. A present type or - * attribute keeps the cursor anchored inside the field, so the node ancestry - * resolves it directly. A typeless field emits no `TypeAnnotation`, so its - * trailing trivia belongs to the enclosing block instead; there the field is - * the owner of the nearest significant token to the left. + * Locates the field whose type position the cursor occupies. The context 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( - contextNode: SyntaxNode | undefined, - at: TokenAtOffset, -): FieldDeclarationAst | undefined { - const direct = contextNode?.findAncestor(FieldDeclarationAst.cast); - if (direct !== undefined) { - return direct; - } - const left = at.leftBiased(); - if (left === undefined) { - return undefined; - } - const significant = isTrivia(left) ? skipTriviaToken(left, 'prev') : left; - return significant?.parent.findAncestor(FieldDeclarationAst.cast); +function fieldForTypeSlot(contextNode: SyntaxNode | undefined): FieldDeclarationAst | undefined { + return contextNode?.findAncestor(FieldDeclarationAst.cast); } /** @@ -323,8 +308,8 @@ function canBeginDeclaration( if (keywordOnly) { return true; } - if (declaration instanceof NamespaceDeclarationAst) { - return inNamespaceBody; + if (declaration instanceof NamespaceDeclarationAst && inNamespaceBody) { + return true; } const rbrace = declaration.rbrace(); return rbrace !== undefined && offset >= rbrace.endOffset; @@ -344,17 +329,20 @@ function blockBodyContainsOffset(block: BracedBlock | undefined, offset: number) } function classifyGenericBlockParameter(input: { - readonly node: SyntaxNode | undefined; readonly offset: number; readonly at: TokenAtOffset; readonly replacementStartOffset: number; }): PslCompletionContext | undefined { - const block = input.node?.findAncestor(GenericBlockDeclarationAst.cast); + // 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 `contextToken` used for gaps. + const node = input.at.leftBiased()?.parent; + const block = node?.findAncestor(GenericBlockDeclarationAst.cast); if (block === undefined) { return undefined; } - if (hasUnsupportedAncestor(input.node)) { + if (hasUnsupportedAncestor(node)) { return UNSUPPORTED; } @@ -362,7 +350,7 @@ function classifyGenericBlockParameter(input: { return UNSUPPORTED; } - const field = input.node?.findAncestor(FieldDeclarationAst.cast); + const field = node?.findAncestor(FieldDeclarationAst.cast); if (field?.syntax.isInside(input.offset)) { return UNSUPPORTED; } @@ -374,7 +362,7 @@ function classifyGenericBlockParameter(input: { // Value position: the cursor follows a `=`. The position is now classified // distinctly from keys; populating value candidates is the provider's concern. - if (previousSignificantToken(input.at, input.offset)?.kind === 'Equals') { + if (contextToken(input.at, input.offset)?.kind === 'Equals') { return { kind: 'genericBlockValue', offset: input.offset, @@ -383,7 +371,7 @@ function classifyGenericBlockParameter(input: { }; } - const activePair = activeKeyValuePair(input.node, input.offset); + const activePair = activeKeyValuePair(node, input.offset); if (activePair !== undefined && isAfterEquals(activePair, input.offset)) { return UNSUPPORTED; } @@ -442,13 +430,12 @@ function hasUnsupportedAncestor(node: SyntaxNode | undefined): boolean { ); } -/** The nearest non-trivia token ending at or before the cursor. */ -function previousSignificantToken(at: TokenAtOffset, offset: number): SyntaxToken | undefined { - const left = at.leftBiased(); - if (left === undefined) { - return undefined; - } - return left.endOffset <= offset && !isTrivia(left) ? left : previousNonTriviaToken(left); +/** The significant token preceding the cursor — the in-progress edit identifier + * is skipped, so the result is the token that gives the cursor its context. */ +function contextToken(at: TokenAtOffset, offset: number): SyntaxToken | undefined { + const edit = cursorIdentifier(at, offset); + const start = edit !== undefined ? edit.prevToken : at.leftBiased(); + return start === undefined ? undefined : skipTriviaToken(start, 'prev'); } /** The identifier token the cursor is editing, if any. */ 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 index a4ea89eae4..79e3951b66 100644 --- 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 @@ -60,6 +60,13 @@ describe('classifyPslCompletionContext', () => { 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')); @@ -125,6 +132,13 @@ describe('classifyPslCompletionContext', () => { }); }); + 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', @@ -238,6 +252,20 @@ describe('classifyPslCompletionContext', () => { }); }); + 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')); From 5e84a10d1de2247f8e7dd722ec020edfa7815347 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 15:27:36 +0000 Subject: [PATCH 47/54] refactor(psl-parser): remove orphaned previousNonTriviaToken navigation helper previousNonTriviaToken existed solely to back the language server previousSignificantToken, which the contextToken unification deleted. Its only production consumer gone, the wrapper is dead: contextToken reaches the same token via skipTriviaToken directly. Remove the function, its re-export, and its test block. skipTriviaToken, nonTriviaSibling, and isTrivia keep their other live consumers and are untouched. Signed-off-by: Serhii Tatarintsev --- .../psl-parser/src/exports/syntax.ts | 1 - .../psl-parser/src/syntax/navigation.ts | 10 -------- .../psl-parser/test/syntax/navigation.test.ts | 25 ------------------- 3 files changed, 36 deletions(-) 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 ebee9ba6a5..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 @@ -58,7 +58,6 @@ export { isTrivia, isTriviaKind, nonTriviaSibling, - previousNonTriviaToken, skipTriviaToken, } from '../syntax/navigation'; // Red layer 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 index a438c97ec3..e5ec705cd0 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/syntax/navigation.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/syntax/navigation.ts @@ -50,16 +50,6 @@ export function nonTriviaSibling( return undefined; } -/** - * The nearest significant token strictly before `element` in document order, - * crossing node boundaries. - */ -export function previousNonTriviaToken(element: SyntaxElement): SyntaxToken | undefined { - const start = element instanceof SyntaxToken ? element : element.firstToken; - const prev = start?.prevToken; - return prev === undefined ? undefined : skipTriviaToken(prev, 'prev'); -} - 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/test/syntax/navigation.test.ts b/packages/1-framework/2-authoring/psl-parser/test/syntax/navigation.test.ts index ff23a6b5a1..2f3ad79c73 100644 --- 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 @@ -4,7 +4,6 @@ import { isTrivia, isTriviaKind, nonTriviaSibling, - previousNonTriviaToken, skipTriviaToken, } from '../../src/syntax/navigation'; import { createSyntaxTree, SyntaxNode, type SyntaxToken } from '../../src/syntax/red'; @@ -146,27 +145,3 @@ describe('nonTriviaSibling', () => { } }); }); - -describe('previousNonTriviaToken', () => { - it('finds the significant token before a token, crossing nodes', () => { - const intToken = sampleTokens(createSyntaxTree(buildSampleTree()))[9]; - expect(intToken.text).toBe('Int'); - expect(previousNonTriviaToken(intToken)?.text).toBe('id'); // the field name - }); - - it('accepts a node and starts from its first token', () => { - const root = createSyntaxTree(buildSampleTree()); - const attr = firstNodeOfKind(root, 'FieldAttribute'); - // FieldAttribute's first token is `@`; the previous significant token is `Int`. - expect(previousNonTriviaToken(attr)?.text).toBe('Int'); - }); - - it('returns undefined at the start of the document', () => { - const root = createSyntaxTree(buildSampleTree()); - const first = root.firstToken; - expect(first?.text).toBe('model'); - if (first !== undefined) { - expect(previousNonTriviaToken(first)).toBeUndefined(); - } - }); -}); From 6aa1fa04b542681bc026ac25fd86166c1b474489 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 16:46:58 +0000 Subject: [PATCH 48/54] refactor(language-server): compute cursorIdentifier and contextToken once per request contextToken folded the look-left over cursorIdentifier, so the classifier recomputed the edit identifier three times: once for replacementStartOffset and once inside each contextToken call. Compute the edit and the context token once at the top of classifyPslCompletionContext and thread the results down - contextToken now takes the precomputed edit, and the generic-block value check reads the passed-in token instead of recomputing. Pure de-duplication, no behaviour change. Signed-off-by: Serhii Tatarintsev --- .../language-server/src/completion-context.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) 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 index 28f540b837..fc7125c523 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-context.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-context.ts @@ -102,13 +102,15 @@ export function classifyPslCompletionContext( return UNSUPPORTED; } - // Anchor on the significant token that gives the cursor its context and - // navigate outward via `token.parent` rather than scanning the whole tree. - const contextNode = contextToken(at, offset)?.parent; - // The edit replaces the identifier under the cursor, or is empty when the // cursor sits in trivia. - const replacementStartOffset = cursorIdentifier(at, offset)?.offset ?? offset; + const edit = cursorIdentifier(at, offset); + + // Anchor on the significant token that gives the cursor its context and + // navigate outward via `token.parent` rather than scanning the whole tree. + const context = contextToken(at, edit); + const contextNode = context?.parent; + const replacementStartOffset = edit?.offset ?? offset; const declarationKeywordContext = classifyDeclarationKeyword({ node: contextNode, @@ -122,6 +124,7 @@ export function classifyPslCompletionContext( const genericBlockContext = classifyGenericBlockParameter({ offset, at, + contextToken: context, replacementStartOffset, }); if (genericBlockContext !== undefined) { @@ -331,6 +334,7 @@ function blockBodyContainsOffset(block: BracedBlock | undefined, offset: number) function classifyGenericBlockParameter(input: { readonly offset: number; readonly at: TokenAtOffset; + readonly contextToken: SyntaxToken | undefined; readonly replacementStartOffset: number; }): PslCompletionContext | undefined { // Whether the cursor sits in a key, value, or attribute slot is a structural @@ -362,7 +366,7 @@ function classifyGenericBlockParameter(input: { // Value position: the cursor follows a `=`. The position is now classified // distinctly from keys; populating value candidates is the provider's concern. - if (contextToken(input.at, input.offset)?.kind === 'Equals') { + if (input.contextToken?.kind === 'Equals') { return { kind: 'genericBlockValue', offset: input.offset, @@ -432,8 +436,7 @@ function hasUnsupportedAncestor(node: SyntaxNode | undefined): boolean { /** The significant token preceding the cursor — the in-progress edit identifier * is skipped, so the result is the token that gives the cursor its context. */ -function contextToken(at: TokenAtOffset, offset: number): SyntaxToken | undefined { - const edit = cursorIdentifier(at, offset); +function contextToken(at: TokenAtOffset, edit: SyntaxToken | undefined): SyntaxToken | undefined { const start = edit !== undefined ? edit.prevToken : at.leftBiased(); return start === undefined ? undefined : skipTriviaToken(start, 'prev'); } From 3972b02b604a02d9a86c45f1737149decc383f4d Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 16:50:30 +0000 Subject: [PATCH 49/54] refactor(language-server): rename canBeginDeclaration to canCompleteDeclaration Rename the declaration-keyword gate and its declaration argument to name what they check: whether a declaration keyword can be completed given the preceding declaration the cursor sits against. Signed-off-by: Serhii Tatarintsev --- .../language-server/src/completion-context.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) 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 index fc7125c523..9721f24db1 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-context.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-context.ts @@ -275,13 +275,13 @@ function classifyDeclarationKeyword(input: { readonly offset: number; readonly replacementStartOffset: number; }): DeclarationKeywordCompletionContext | undefined { - const declaration = input.node?.findAncestor(declarationCast); + const precedingDeclaration = input.node?.findAncestor(declarationCast); const namespace = input.node?.findAncestor(NamespaceDeclarationAst.cast); const inNamespaceBody = blockBodyContainsOffset(namespace, input.offset); if ( - declaration !== undefined && - !canBeginDeclaration(declaration, input.offset, inNamespaceBody) + precedingDeclaration !== undefined && + !canCompleteDeclaration(precedingDeclaration, input.offset, inNamespaceBody) ) { return undefined; } @@ -300,21 +300,21 @@ function classifyDeclarationKeyword(input: { * 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 canBeginDeclaration( - declaration: DeclarationAst, +function canCompleteDeclaration( + precedingDeclaration: DeclarationAst, offset: number, inNamespaceBody: boolean, ): boolean { const keywordOnly = - declaration.lbrace() === undefined && - (declaration instanceof TypesBlockAst || declaration.name() === undefined); + precedingDeclaration.lbrace() === undefined && + (precedingDeclaration instanceof TypesBlockAst || precedingDeclaration.name() === undefined); if (keywordOnly) { return true; } - if (declaration instanceof NamespaceDeclarationAst && inNamespaceBody) { + if (precedingDeclaration instanceof NamespaceDeclarationAst && inNamespaceBody) { return true; } - const rbrace = declaration.rbrace(); + const rbrace = precedingDeclaration.rbrace(); return rbrace !== undefined && offset >= rbrace.endOffset; } From 5886883d0da5c2ccf7b18ddf8279825d4d586c30 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 17:03:04 +0000 Subject: [PATCH 50/54] refactor(language-server): detect the empty type slot via the context token The empty-type-slot check was the last completion site recovering a gap with numeric offset bounds (emptyTypeSlotEnd + offset > fieldNameEnd && offset <= slotEnd) instead of the unified look-left mechanism. Since a typeless field has no node spanning its type slot, the slot is valid exactly when the context token belongs to the field own name - nothing significant sits between the name and the cursor; an attribute token (or anything else) means the cursor has left the slot. Thread the context token into classifyModelFieldType and test it with fieldName.syntax.isInside(context.offset); identity comparison is unreliable because the red layer recreates wrapper objects on traversal. emptyTypeSlotEnd is deleted along with its lone nonTriviaSibling consumer in this file. Signed-off-by: Serhii Tatarintsev --- .../language-server/src/completion-context.ts | 22 +++---------------- .../test/completion-context.test.ts | 8 +++++++ 2 files changed, 11 insertions(+), 19 deletions(-) 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 index 9721f24db1..0c093adfd4 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-context.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-context.ts @@ -11,7 +11,6 @@ import { ModelAttributeAst, ModelDeclarationAst, NamespaceDeclarationAst, - nonTriviaSibling, type Position, type QualifiedNameAst, type SourceFile, @@ -146,6 +145,7 @@ export function classifyPslCompletionContext( field, offset, replacementStartOffset, + context, }); } @@ -160,24 +160,11 @@ function fieldForTypeSlot(contextNode: SyntaxNode | undefined): FieldDeclaration return contextNode?.findAncestor(FieldDeclarationAst.cast); } -/** - * The upper bound of a typeless field's empty type slot: the first attribute - * when present, otherwise the next significant token after the field (the - * following member or the block's closing brace), since a typeless field's - * trailing trivia lives in the enclosing block. - */ -function emptyTypeSlotEnd(field: FieldDeclarationAst): number { - for (const attribute of field.attributes()) { - return attribute.syntax.offset; - } - const nextSignificant = nonTriviaSibling(field.syntax, 'next'); - return nextSignificant?.offset ?? field.syntax.endOffset; -} - function classifyModelFieldType(input: { readonly field: FieldDeclarationAst; readonly offset: number; readonly replacementStartOffset: number; + readonly context: SyntaxToken | undefined; }): PslCompletionContext { const fieldName = input.field.name(); if (fieldName === undefined) { @@ -188,16 +175,13 @@ function classifyModelFieldType(input: { return UNSUPPORTED; } - const fieldNameEnd = fieldName.syntax.endOffset; - if (fieldName.syntax.isInside(input.offset)) { return UNSUPPORTED; } const typeAnnotation = input.field.typeAnnotation(); if (typeAnnotation === undefined) { - const slotEnd = emptyTypeSlotEnd(input.field); - if (input.offset > fieldNameEnd && input.offset <= slotEnd) { + if (input.context !== undefined && fieldName.syntax.isInside(input.context.offset)) { return { kind: 'modelType', offset: input.offset, 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 index 79e3951b66..2c8ab074e5 100644 --- 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 @@ -123,6 +123,14 @@ describe('classifyPslCompletionContext', () => { 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')); From f9691a69516449f553b84425e35c40725c438f9b Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 17:10:45 +0000 Subject: [PATCH 51/54] refactor(language-server): rename contextToken to precedingToken "context" said nothing about which token the helper returns. It is the significant token preceding the cursor (skipping the in-progress edit identifier and trivia) - the token the classifier anchors on. Rename the helper to precedingToken and the derived locals/fields to match (preceding, precedingNode, the precedingToken fields threaded into the generic-block and model-field classifiers), harmonising with the precedingDeclaration naming. Signed-off-by: Serhii Tatarintsev --- .../language-server/src/completion-context.ts | 39 ++++++++++--------- 1 file changed, 21 insertions(+), 18 deletions(-) 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 index 0c093adfd4..94c8687337 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-context.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-context.ts @@ -105,14 +105,14 @@ export function classifyPslCompletionContext( // cursor sits in trivia. const edit = cursorIdentifier(at, offset); - // Anchor on the significant token that gives the cursor its context and - // navigate outward via `token.parent` rather than scanning the whole tree. - const context = contextToken(at, edit); - const contextNode = context?.parent; + // 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: contextNode, + node: precedingNode, offset, replacementStartOffset, }); @@ -123,14 +123,14 @@ export function classifyPslCompletionContext( const genericBlockContext = classifyGenericBlockParameter({ offset, at, - contextToken: context, + precedingToken: preceding, replacementStartOffset, }); if (genericBlockContext !== undefined) { return genericBlockContext; } - const field = fieldForTypeSlot(contextNode); + const field = fieldForTypeSlot(precedingNode); if (field === undefined) { return UNSUPPORTED; } @@ -145,26 +145,26 @@ export function classifyPslCompletionContext( field, offset, replacementStartOffset, - context, + precedingToken: preceding, }); } /** - * Locates the field whose type position the cursor occupies. The context token + * 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(contextNode: SyntaxNode | undefined): FieldDeclarationAst | undefined { - return contextNode?.findAncestor(FieldDeclarationAst.cast); +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 context: SyntaxToken | undefined; + readonly precedingToken: SyntaxToken | undefined; }): PslCompletionContext { const fieldName = input.field.name(); if (fieldName === undefined) { @@ -181,7 +181,10 @@ function classifyModelFieldType(input: { const typeAnnotation = input.field.typeAnnotation(); if (typeAnnotation === undefined) { - if (input.context !== undefined && fieldName.syntax.isInside(input.context.offset)) { + if ( + input.precedingToken !== undefined && + fieldName.syntax.isInside(input.precedingToken.offset) + ) { return { kind: 'modelType', offset: input.offset, @@ -318,12 +321,12 @@ function blockBodyContainsOffset(block: BracedBlock | undefined, offset: number) function classifyGenericBlockParameter(input: { readonly offset: number; readonly at: TokenAtOffset; - readonly contextToken: SyntaxToken | undefined; + 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 `contextToken` used for gaps. + // 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) { @@ -350,7 +353,7 @@ function classifyGenericBlockParameter(input: { // Value position: the cursor follows a `=`. The position is now classified // distinctly from keys; populating value candidates is the provider's concern. - if (input.contextToken?.kind === 'Equals') { + if (input.precedingToken?.kind === 'Equals') { return { kind: 'genericBlockValue', offset: input.offset, @@ -419,8 +422,8 @@ function hasUnsupportedAncestor(node: SyntaxNode | undefined): boolean { } /** The significant token preceding the cursor — the in-progress edit identifier - * is skipped, so the result is the token that gives the cursor its context. */ -function contextToken(at: TokenAtOffset, edit: SyntaxToken | undefined): SyntaxToken | undefined { + * 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'); } From 82b8f4ed1cd6b248f8ca417bbf7fd50a462c7126 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 17:15:01 +0000 Subject: [PATCH 52/54] refactor(language-server): carry the block AST on the context, gather param names in the provider existingParameterNames was candidate-filtering data precomputed on the completion context, but the context should describe the cursor position, not gather provider data. Replace GenericBlockKeyCompletionContext.existingParameterNames with the enclosing block AST node and compute the existing-parameter set in the provider from block.entries(), excluding the in-progress pair the cursor sits inside. Behaviour is preserved: the old sameSpan-against-active-pair exclusion and the new isOutside(cursorOffset) check select the same entry. Signed-off-by: Serhii Tatarintsev --- .../language-server/src/completion-context.ts | 25 ++----------------- .../src/completion-provider.ts | 21 ++++++++++++++-- .../test/completion-context.test.ts | 23 +++++++++-------- .../test/completion-provider.test.ts | 6 +++++ 4 files changed, 40 insertions(+), 35 deletions(-) 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 index 94c8687337..d139ef461d 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-context.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-context.ts @@ -55,7 +55,7 @@ export interface GenericBlockKeyCompletionContext { readonly offset: number; readonly blockKeyword: string; readonly replacementStartOffset: number; - readonly existingParameterNames: readonly string[]; + readonly block: GenericBlockDeclarationAst; } export interface GenericBlockValueCompletionContext { @@ -372,7 +372,7 @@ function classifyGenericBlockParameter(input: { offset: input.offset, blockKeyword: keyword, replacementStartOffset: input.replacementStartOffset, - existingParameterNames: existingParameterNames(block, activePair), + block, }; } @@ -392,27 +392,6 @@ function isAfterEquals(pair: KeyValuePairAst, offset: number): boolean { return equals !== undefined && offset > equals.offset; } -function existingParameterNames( - block: GenericBlockDeclarationAst, - activePair: KeyValuePairAst | undefined, -): readonly string[] { - const names: string[] = []; - for (const entry of block.entries()) { - if (activePair !== undefined && sameSpan(entry.syntax, activePair.syntax)) { - continue; - } - const name = entry.key()?.name(); - if (name !== undefined) { - names.push(name); - } - } - return names; -} - -function sameSpan(left: SyntaxNode, right: SyntaxNode): boolean { - return left.offset === right.offset && left.textLength === right.textLength; -} - function hasUnsupportedAncestor(node: SyntaxNode | undefined): boolean { return ( node?.findAncestor( 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 index 9899a446f0..577f1169f0 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-provider.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-provider.ts @@ -7,7 +7,7 @@ import { type NamespaceSymbol, type SymbolTable, } from '@prisma-next/psl-parser'; -import type { SourceFile } from '@prisma-next/psl-parser/syntax'; +import type { GenericBlockDeclarationAst, SourceFile } from '@prisma-next/psl-parser/syntax'; import { type CompletionItem, CompletionItemKind, InsertTextFormat } from 'vscode-languageserver'; import type { DeclarationKeywordCompletionContext, @@ -229,7 +229,7 @@ function provideGenericBlockKeyCompletionItems( return []; } - const existing = new Set(context.existingParameterNames); + const existing = existingGenericBlockParameterNames(context.block, context.offset); const replacementRange = { start: sourceFile.positionAt(context.replacementStartOffset), end: sourceFile.positionAt(context.offset), @@ -250,6 +250,23 @@ function provideGenericBlockKeyCompletionItems( })); } +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, 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 index 2c8ab074e5..af9af28033 100644 --- 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 @@ -236,21 +236,24 @@ describe('classifyPslCompletionContext', () => { }); it('classifies blank generic block key positions', () => { - expect(classify(['policy UserAccess {', ' |', '}'].join('\n'))).toMatchObject({ + const context = classify(['policy UserAccess {', ' |', '}'].join('\n')); + expect(context).toMatchObject({ kind: 'genericBlockKey', blockKeyword: 'policy', - existingParameterNames: [], }); + if (context.kind !== 'genericBlockKey') throw new Error('expected genericBlockKey'); + expect(context.block.keyword()?.text).toBe('policy'); }); - it('classifies generic block key prefixes and records sibling keys', () => { - expect(classify(['policy UserAccess {', ' on = User', ' wh|', '}'].join('\n'))).toMatchObject( - { - kind: 'genericBlockKey', - blockKeyword: 'policy', - existingParameterNames: ['on'], - }, - ); + 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', () => { 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 index 24f046291b..6496559dc1 100644 --- 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 @@ -400,6 +400,12 @@ describe('providePslCompletionItems', () => { }); }); + 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')); From 642719d9df692f9a649b3b3c2bc4a935b81b7fc9 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Fri, 3 Jul 2026 10:36:21 +0000 Subject: [PATCH 53/54] fix(language-server): do not serve local namespace members for a foreign contract-space A field type like supabase:auth.User is a foreign contract-space reference, which the language server does not resolve (external contract-space discovery is unsupported). But classifyTypePosition checked the namespace segment before the space segment, so supabase:auth. classified as a namespaceMember that dropped the space and served the LOCAL auth namespace members. Carry the space on NamespaceMemberCompletionContext and return no completions when it is present; local auth. references are unchanged. Signed-off-by: Serhii Tatarintsev --- .../language-server/src/completion-context.ts | 13 ++++++++++++- .../src/completion-provider.ts | 5 +++++ .../test/completion-context.test.ts | 13 +++++++++++++ .../test/completion-provider.test.ts | 19 +++---------------- 4 files changed, 33 insertions(+), 17 deletions(-) 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 index d139ef461d..449ac6ee37 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-context.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-context.ts @@ -48,6 +48,7 @@ export interface NamespaceMemberCompletionContext { readonly fieldName: string; readonly replacementStartOffset: number; readonly namespace: string; + readonly space?: string; } export interface GenericBlockKeyCompletionContext { @@ -238,7 +239,17 @@ function classifyTypePosition( ): ModelTypeCompletionContext | SpaceMemberCompletionContext | NamespaceMemberCompletionContext { const namespace = name.namespace()?.name(); if (namespace !== undefined && namespace.length > 0) { - return { kind: 'namespaceMember', offset, fieldName, replacementStartOffset, namespace }; + 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) { 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 index 577f1169f0..fbc5bb37f1 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-provider.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-provider.ts @@ -284,6 +284,11 @@ function provideNamespaceMemberCompletionItems( 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, 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 index af9af28033..d553d8bb4c 100644 --- 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 @@ -174,13 +174,26 @@ describe('classifyPslCompletionContext', () => { 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', () => { 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 index 6496559dc1..a0d1fe8cce 100644 --- 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 @@ -332,23 +332,10 @@ describe('providePslCompletionItems', () => { }); }); - it('returns the full contract-space-qualified namespace member set from visible namespace data', () => { - const { items, sourceFile, cursorOffset } = complete( - ['model Post {', ' owner supabase:auth.P|', '}'].join('\n'), - ); + 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.map((item) => item.label)).toEqual(['Account', 'User', 'Profile']); - expect(items.find((item) => item.label === 'Profile')).toMatchObject({ - filterText: 'Profile', - detail: 'Composite type in namespace auth', - textEdit: { - range: { - start: sourceFile.positionAt(cursorOffset - 'P'.length), - end: sourceFile.positionAt(cursorOffset), - }, - newText: 'Profile', - }, - }); + expect(items).toEqual([]); }); it('returns no completions for a contract-space-qualified position', () => { From 9bab9406a42b11c38e1eb28be901f4534b37482f Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Fri, 3 Jul 2026 11:07:40 +0000 Subject: [PATCH 54/54] fix(language-server): stop sending on a disposed connection An in-flight publish (which awaits the async project-load chain) could settle after the server connection was disposed, then call connection.sendDiagnostics or connection.console.error on the dead connection. console.error fires an un-awaited sendNotification that rejects with "Connection is disposed" as an unhandled rejection, failing the whole test job even though every test passes. Make the server disposal-aware: a disposed flag, set before connection.dispose(), gates every fire-and-forget outbound send (sendDiagnostics helper, the publishSafely console.error, the watched-files console.warn). A deterministic test drives an in-flight publish to settle (resolve and reject) after dispose and asserts no unhandled rejection; it fails without the guard. Signed-off-by: Serhii Tatarintsev --- .../3-tooling/language-server/src/server.ts | 30 +++++++-- .../language-server/test/server.test.ts | 65 ++++++++++++++++++- 2 files changed, 88 insertions(+), 7 deletions(-) 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 946e7477dc..916fc04bda 100644 --- a/packages/1-framework/3-tooling/language-server/src/server.ts +++ b/packages/1-framework/3-tooling/language-server/src/server.ts @@ -13,6 +13,7 @@ import { type InitializeParams, type InitializeResult, type Position, + type PublishDiagnosticsParams, type Range, RegistrationRequest, type SemanticTokens, @@ -68,6 +69,21 @@ export function createServer(connection: Connection): LanguageServer { 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); @@ -86,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) => ({ @@ -96,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 { @@ -196,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: [] }); } } } @@ -224,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)); }); } @@ -389,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.', ); } @@ -450,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); @@ -463,6 +482,7 @@ export function createServer(connection: Connection): LanguageServer { return { dispose: () => { + disposed = true; connection.dispose(); }, getDocumentAst: (uri) => artifactsForDocument(uri)?.getDocument(uri), 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 6e20ee5864..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 @@ -461,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; @@ -1623,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')), + ); + }); +});