From e9d0e0ac6492711ebbec424791be9b963c251396 Mon Sep 17 00:00:00 2001 From: Johannes Kirchner Date: Thu, 3 Sep 2026 17:40:55 +0200 Subject: [PATCH] fix(language-server): improve schema handling on Windows --- .../src/__test__/format.test.ts | 19 ++++++ .../src/__test__/schema.test.ts | 64 +++++++++++++++++++ packages/language-server/src/lib/Schema.ts | 54 ++++++++++++++-- .../src/lib/prisma-schema-wasm/format.ts | 14 +++- packages/language-server/src/lib/types.ts | 5 ++ packages/language-server/src/server.ts | 31 ++++++--- 6 files changed, 171 insertions(+), 16 deletions(-) create mode 100644 packages/language-server/src/__test__/schema.test.ts diff --git a/packages/language-server/src/__test__/format.test.ts b/packages/language-server/src/__test__/format.test.ts index cbdabb05d6..2bfa683baf 100644 --- a/packages/language-server/src/__test__/format.test.ts +++ b/packages/language-server/src/__test__/format.test.ts @@ -3,6 +3,7 @@ import { TextEdit, DocumentFormattingParams } from 'vscode-languageserver' import { describe, test, expect } from 'vitest' import { getTextDocument } from './helper' import { PrismaSchema } from '../lib/Schema' +import { isSameFileUri } from '../lib/prisma-schema-wasm/format' function assertFormat(fixturePath: string): void { const textDocument = getTextDocument(fixturePath) @@ -26,3 +27,21 @@ describe('Format', () => { assertFormat(fixturePath) }) }) + +describe('Format URI matching', () => { + test('matches equivalent Windows file URIs', () => { + expect( + isSameFileUri( + 'file:///z%3A/kevin_new/prisma-database-kevin/Doku.prisma', + 'file:///Z:/kevin_new/prisma-database-kevin/Doku.prisma', + 'win32', + ), + ).toBe(true) + }) + + test('keeps Unix paths case-sensitive', () => { + expect(isSameFileUri('file:///tmp/schema.prisma', 'file:///tmp/schema.prisma', 'linux')).toBe(true) + + expect(isSameFileUri('file:///tmp/schema.prisma', 'file:///tmp/Schema.prisma', 'linux')).toBe(false) + }) +}) diff --git a/packages/language-server/src/__test__/schema.test.ts b/packages/language-server/src/__test__/schema.test.ts new file mode 100644 index 0000000000..bf7c5a9428 --- /dev/null +++ b/packages/language-server/src/__test__/schema.test.ts @@ -0,0 +1,64 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, test } from 'vitest' +import { TextDocument } from 'vscode-languageserver-textdocument' +import { URI } from 'vscode-uri' +import { PrismaSchema } from '../lib/Schema' + +const tempDirectories: string[] = [] + +afterEach(async () => { + await Promise.all(tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))) +}) + +describe('PrismaSchema', () => { + test('excludes schema files from configured directories', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'prisma-language-server-')) + tempDirectories.push(root) + + const activeDirectory = path.join(root, 'active') + const excludedDirectory = path.join(root, 'excluded') + + await mkdir(activeDirectory) + await mkdir(excludedDirectory) + + const activeSchemaPath = path.join(activeDirectory, 'schema.prisma') + const excludedSchemaPath = path.join(excludedDirectory, 'schema.prisma') + + const activeContent = ` +model Active { + id Int @id +} +` + + const excludedContent = ` +model Excluded { + id Int @id +} +` + + await writeFile(activeSchemaPath, activeContent) + await writeFile(excludedSchemaPath, excludedContent) + + const activeDocument = TextDocument.create(URI.file(activeSchemaPath).toString(), 'prisma', 1, activeContent) + + const excludedDocument = TextDocument.create(URI.file(excludedSchemaPath).toString(), 'prisma', 1, excludedContent) + + const schema = await PrismaSchema.load( + { + currentDocument: activeDocument, + allDocuments: [activeDocument, excludedDocument], + }, + root, + { + excludedSchemaDirectories: ['excluded'], + }, + ) + + expect(schema.documents).toHaveLength(1) + expect(schema.documents[0].uri).toBe(activeDocument.uri) + expect(schema.documents[0].content).toContain('model Active') + expect(schema.documents[0].content).not.toContain('model Excluded') + }) +}) diff --git a/packages/language-server/src/lib/Schema.ts b/packages/language-server/src/lib/Schema.ts index 31d3539244..8c5b9cc7fb 100644 --- a/packages/language-server/src/lib/Schema.ts +++ b/packages/language-server/src/lib/Schema.ts @@ -11,6 +11,7 @@ import { Position } from 'vscode-languageserver' import { TextDocument } from 'vscode-languageserver-textdocument' import { URI } from 'vscode-uri' import { getCurrentLine } from './ast' +import path from 'node:path' export type Line = { readonly document: SchemaDocument @@ -81,12 +82,41 @@ export async function loadConfig(configRoot?: string): Promise { - // `loadRelatedSchemaFiles` locates and returns either a single schema files, or a set of related schema files. - const schemaFiles = await loadRelatedSchemaFiles(fsPath, createFilesResolver(allDocuments)) - const documents = schemaFiles.map(([filePath, content]) => { - return new SchemaDocument(TextDocument.create(URI.file(filePath).toString(), 'prisma', 1, content)) +type SchemaLoadOptions = { + excludedSchemaDirectories?: string[] +} + +function isExcludedPath(filePath: string, excludedDirectories: string[]): boolean { + const normalizedPath = path.resolve(filePath) + const comparablePath = process.platform === 'win32' ? normalizedPath.toLowerCase() : normalizedPath + + return excludedDirectories.some((directory) => { + const normalizedDirectory = path.resolve(directory) + const comparableDirectory = process.platform === 'win32' ? normalizedDirectory.toLowerCase() : normalizedDirectory + + return comparablePath === comparableDirectory || comparablePath.startsWith(`${comparableDirectory}${path.sep}`) }) +} + +async function loadSchemaDocumentsFromPath( + fsPath: string, + allDocuments: TextDocument[], + options: SchemaLoadOptions = {}, +): Promise { + const excludedDirectories = options.excludedSchemaDirectories ?? [] + + const filteredDocuments = allDocuments.filter((document) => { + const filePath = URI.parse(document.uri).fsPath + return !isExcludedPath(filePath, excludedDirectories) + }) + + // `loadRelatedSchemaFiles` locates and returns either a single schema files, or a set of related schema files. + const schemaFiles = await loadRelatedSchemaFiles(fsPath, createFilesResolver(filteredDocuments)) + const documents = schemaFiles + .filter(([filePath]) => !isExcludedPath(filePath, excludedDirectories)) + .map(([filePath, content]) => { + return new SchemaDocument(TextDocument.create(URI.file(filePath).toString(), 'prisma', 1, content)) + }) return documents } @@ -98,7 +128,11 @@ export class PrismaSchema { return new PrismaSchema([new SchemaDocument(textDocument)]) } - static async load(input: PrismaSchemaInput, configRoot?: string): Promise { + static async load( + input: PrismaSchemaInput, + configRoot?: string, + options: SchemaLoadOptions = {}, + ): Promise { let config: PrismaConfigInternal | undefined try { config = await loadConfig(configRoot) @@ -107,12 +141,18 @@ export class PrismaSchema { console.log('Continuing without Prisma config file') } + const resolvedExcludedDirectories = (options.excludedSchemaDirectories ?? []).map((directory) => + path.resolve(configRoot ?? process.cwd(), directory), + ) + let schemaDocs: SchemaDocument[] if (Array.isArray(input)) { schemaDocs = input } else { const fsPath = config?.schema ?? URI.parse(input.currentDocument.uri).fsPath - schemaDocs = await loadSchemaDocumentsFromPath(fsPath, input.allDocuments) + schemaDocs = await loadSchemaDocumentsFromPath(fsPath, input.allDocuments, { + excludedSchemaDirectories: resolvedExcludedDirectories, + }) } return new PrismaSchema(schemaDocs, config) } diff --git a/packages/language-server/src/lib/prisma-schema-wasm/format.ts b/packages/language-server/src/lib/prisma-schema-wasm/format.ts index 3566a1b7b5..4b438a7a53 100644 --- a/packages/language-server/src/lib/prisma-schema-wasm/format.ts +++ b/packages/language-server/src/lib/prisma-schema-wasm/format.ts @@ -3,6 +3,14 @@ import { prismaSchemaWasm } from '.' import { handleFormatPanic, handleWasmError } from './internals' import { PrismaSchema } from '../Schema' import { TextDocument } from 'vscode-languageserver-textdocument' +import { URI } from 'vscode-uri' + +export function isSameFileUri(leftUri: string, rightUri: string, platform = process.platform): boolean { + const leftPath = URI.parse(leftUri).fsPath + const rightPath = URI.parse(rightUri).fsPath + + return platform === 'win32' ? leftPath.toLocaleLowerCase() === rightPath.toLocaleLowerCase() : leftPath === rightPath +} export default function format( schema: PrismaSchema, @@ -21,12 +29,16 @@ export default function format( } const result = prismaSchemaWasm.format(JSON.stringify(schema), JSON.stringify(options)) + // tuples of [filePath, content] const formattedFiles = JSON.parse(result) as Array<[string, string]> - const formatResult = formattedFiles.find(([uri]) => uri === initiatingDocument.uri) + + const formatResult = formattedFiles.find(([uri]) => isSameFileUri(uri, initiatingDocument.uri)) + if (!formatResult) { return initiatingDocument.getText() } + return formatResult[1] } catch (e) { const err = e as Error diff --git a/packages/language-server/src/lib/types.ts b/packages/language-server/src/lib/types.ts index 9dcfff47c7..dfe1515e0d 100644 --- a/packages/language-server/src/lib/types.ts +++ b/packages/language-server/src/lib/types.ts @@ -23,4 +23,9 @@ export interface LSSettings { * Whether to show diagnostics */ enableDiagnostics?: boolean + + /** + * Directories that should be ignored when resolving Prisma schema files. + */ + excludedSchemaDirectories?: string[] } diff --git a/packages/language-server/src/server.ts b/packages/language-server/src/server.ts index 842be6de78..16e78563be 100644 --- a/packages/language-server/src/server.ts +++ b/packages/language-server/src/server.ts @@ -147,6 +147,21 @@ export function startServer(options?: LSOptions): void { return result } + async function loadSchema(textDocument: TextDocument): Promise { + const settings = await getDocumentSettings(textDocument.uri) + + return PrismaSchema.load( + { + currentDocument: textDocument, + allDocuments: allRegularDocuments(), + }, + undefined, + { + excludedSchemaDirectories: settings.excludedSchemaDirectories, + }, + ) + } + // Note: VS Code strips newline characters from the message function showErrorToast(errorMessage: string): void { connection.window.showErrorMessage(errorMessage) @@ -165,7 +180,7 @@ export function startServer(options?: LSOptions): void { return } - const schema = await PrismaSchema.load({ currentDocument: textDocument, allDocuments: allRegularDocuments() }) + const schema = await loadSchema(textDocument) const diagnostics = MessageHandler.handleDiagnosticsRequest(schema, showErrorToast) for (const [uri, fileDiagnostics] of diagnostics.entries()) { await connection.sendDiagnostics({ uri, diagnostics: fileDiagnostics }) @@ -191,7 +206,7 @@ export function startServer(options?: LSOptions): void { connection.onDefinition(async (params: DeclarationParams) => { const doc = getDocument(params.textDocument.uri) if (doc) { - const schema = await PrismaSchema.load({ currentDocument: doc, allDocuments: allRegularDocuments() }) + const schema = await loadSchema(doc) return MessageHandler.handleDefinitionRequest(schema, doc, params) } }) @@ -199,7 +214,7 @@ export function startServer(options?: LSOptions): void { connection.onCompletion(async (params: CompletionParams) => { const doc = getDocument(params.textDocument.uri) if (doc) { - const schema = await PrismaSchema.load({ currentDocument: doc, allDocuments: allRegularDocuments() }) + const schema = await loadSchema(doc) return MessageHandler.handleCompletionRequest(schema, doc, params, showErrorToast) } }) @@ -208,7 +223,7 @@ export function startServer(options?: LSOptions): void { const doc = getDocument(params.textDocument.uri) if (doc) { - const schema = await PrismaSchema.load({ currentDocument: doc, allDocuments: allRegularDocuments() }) + const schema = await loadSchema(doc) return MessageHandler.handleReferencesRequest(schema, params, showErrorToast) } @@ -231,7 +246,7 @@ export function startServer(options?: LSOptions): void { connection.onHover(async (params: HoverParams) => { const doc = getDocument(params.textDocument.uri) if (doc) { - const schema = await PrismaSchema.load({ currentDocument: doc, allDocuments: allRegularDocuments() }) + const schema = await loadSchema(doc) return MessageHandler.handleHoverRequest(schema, doc, params, showErrorToast) } }) @@ -239,7 +254,7 @@ export function startServer(options?: LSOptions): void { connection.onDocumentFormatting(async (params: DocumentFormattingParams) => { const doc = getDocument(params.textDocument.uri) if (doc) { - const schema = await PrismaSchema.load({ currentDocument: doc, allDocuments: allRegularDocuments() }) + const schema = await loadSchema(doc) return MessageHandler.handleDocumentFormatting(schema, doc, params, showErrorToast) } }) @@ -247,7 +262,7 @@ export function startServer(options?: LSOptions): void { connection.onCodeAction(async (params: CodeActionParams) => { const doc = getDocument(params.textDocument.uri) if (doc) { - const schema = await PrismaSchema.load({ currentDocument: doc, allDocuments: allRegularDocuments() }) + const schema = await loadSchema(doc) return MessageHandler.handleCodeActions(schema, doc, params, showErrorToast) } }) @@ -255,7 +270,7 @@ export function startServer(options?: LSOptions): void { connection.onRenameRequest(async (params: RenameParams) => { const doc = getDocument(params.textDocument.uri) if (doc) { - const schema = await PrismaSchema.load({ currentDocument: doc, allDocuments: allRegularDocuments() }) + const schema = await loadSchema(doc) return MessageHandler.handleRenameRequest(schema, doc, params) } })