diff --git a/.claude/settings.json b/.claude/settings.json index c78b41f..c8a50a6 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -10,7 +10,13 @@ "Bash(gh issue:*)", "Bash(bun run tsc:*)", "Bash(bun test:*)", - "Bash(bun install:*)" + "Bash(bun install:*)", + "WebFetch(domain:github.com)", + "Bash(git checkout:*)", + "Bash(git pull:*)", + "Bash(git cherry-pick:*)", + "Bash(bun run test:*)", + "SlashCommand(/please:review-pr:*)" ], "deny": [], "ask": [] diff --git a/.please/memory/tasklist.json b/.please/memory/tasklist.json index b6dd510..9b74ac6 100644 --- a/.please/memory/tasklist.json +++ b/.please/memory/tasklist.json @@ -1,168 +1,42 @@ { - "session_id": "20251218-rename-symbol-lsp", - "feature_name": "Add Rename Symbol support to @pleaseai/code-lsp", - "created_at": "2025-12-18T10:00:00Z", - "updated_at": "2025-12-18T07:43:59.732Z", - "status": "completed", - "current_phase": 8, - "issue_number": 18, - "branch": "18-featlsp-add-rename-symbol-support", + "session_id": "20251218-prisma-lsp", + "feature_name": "Add Prisma Language Server support to @pleaseai/code-lsp", + "created_at": "2025-12-18T12:00:00Z", + "updated_at": "2025-12-18T12:30:00Z", + "status": "in_progress", + "current_phase": 6, + "issue_number": 22, + "branch": "22-featlsp-add-prisma-language-server-support", "spec": { - "path": "specs/001-rename-symbol/spec.md", - "number": 1, - "short_name": "rename-symbol", - "created_at": "2025-12-18T10:25:00Z", - "clarified_at": "2025-12-18T10:30:00Z" + "path": "specs/002-prisma-lsp/spec.md", + "number": 2, + "short_name": "prisma-lsp", + "created_at": "2025-12-18T12:15:00Z" }, "checkpoint_config": { "mode": "auto", "push_strategy": "batch", "validation_required": true }, - "ralph_config": { - "enabled": false, - "max_iterations": 50, - "completion_promise": "ALL_PHASE6_TASKS_COMPLETED", - "current_iteration": 0, - "started_at": "2025-12-18T10:50:00Z" - }, "phases": [ - { - "number": 1, - "name": "Discovery", - "status": "completed", - "started_at": "2025-12-18T10:00:00Z", - "completed_at": "2025-12-18T10:05:00Z" - }, - { - "number": 2, - "name": "Codebase Exploration", - "status": "completed", - "started_at": "2025-12-18T10:05:00Z", - "completed_at": "2025-12-18T10:20:00Z" - }, - { - "number": 3, - "name": "Specification & Clarification", - "status": "completed", - "started_at": "2025-12-18T10:20:00Z", - "completed_at": "2025-12-18T10:30:00Z" - }, - { - "number": 4, - "name": "Architecture Design", - "status": "completed", - "started_at": "2025-12-18T10:30:00Z", - "completed_at": "2025-12-18T10:45:00Z" - }, - { - "number": 5, - "name": "GitHub Issue & PR", - "status": "completed", - "started_at": "2025-12-18T10:45:00Z", - "completed_at": "2025-12-18T10:50:00Z" - }, - { - "number": 6, - "name": "Implementation", - "status": "completed", - "started_at": "2025-12-18T10:50:00Z", - "completed_at": "2025-12-18T11:30:00Z" - }, - { - "number": 7, - "name": "Quality Review", - "status": "completed", - "started_at": "2025-12-18T11:30:00Z", - "completed_at": "2025-12-18T11:45:00Z" - }, - { - "number": 8, - "name": "PR Finalization", - "status": "completed", - "started_at": "2025-12-18T11:45:00Z", - "completed_at": "2025-12-18T11:50:00Z" - } + { "number": 1, "name": "Discovery", "status": "completed", "started_at": "2025-12-18T12:00:00Z", "completed_at": "2025-12-18T12:05:00Z" }, + { "number": 2, "name": "Codebase Exploration", "status": "completed", "started_at": "2025-12-18T12:05:00Z", "completed_at": "2025-12-18T12:15:00Z" }, + { "number": 3, "name": "Specification & Clarification", "status": "completed", "started_at": "2025-12-18T12:15:00Z", "completed_at": "2025-12-18T12:20:00Z" }, + { "number": 4, "name": "Architecture Design", "status": "completed", "started_at": "2025-12-18T12:20:00Z", "completed_at": "2025-12-18T12:25:00Z" }, + { "number": 5, "name": "GitHub Issue & PR", "status": "completed", "started_at": "2025-12-18T12:25:00Z", "completed_at": "2025-12-18T12:30:00Z" }, + { "number": 6, "name": "Implementation", "status": "completed", "started_at": "2025-12-18T12:30:00Z", "completed_at": "2025-12-18T12:45:00Z" }, + { "number": 7, "name": "Quality Review", "status": "completed", "started_at": "2025-12-18T12:45:00Z", "completed_at": "2025-12-18T12:50:00Z" }, + { "number": 8, "name": "PR Finalization", "status": "completed", "started_at": "2025-12-18T12:50:00Z", "completed_at": "2025-12-18T12:55:00Z" } ], "tasks": [ - { - "id": "T001", - "title": "Add type schemas (TextEdit, WorkspaceEdit, PrepareRenameResult)", - "phase": 6, - "status": "completed", - "parallel": true, - "dependencies": [] - }, - { - "id": "T002", - "title": "Add rename capability to client initialization", - "phase": 6, - "status": "completed", - "parallel": true, - "dependencies": [] - }, - { - "id": "T003", - "title": "Add normalization methods", - "phase": 6, - "status": "completed", - "parallel": false, - "dependencies": [ - "T001" - ] - }, - { - "id": "T004", - "title": "Add prepareRename() method", - "phase": 6, - "status": "completed", - "parallel": false, - "dependencies": [ - "T001", - "T003" - ] - }, - { - "id": "T005", - "title": "Add rename() method", - "phase": 6, - "status": "completed", - "parallel": false, - "dependencies": [ - "T001", - "T003" - ] - }, - { - "id": "T006", - "title": "Update CLAUDE.md documentation", - "phase": 6, - "status": "completed", - "parallel": false, - "dependencies": [ - "T004", - "T005" - ] - }, - { - "id": "T007", - "title": "Add unit tests for normalization", - "phase": 6, - "status": "completed", - "parallel": true, - "dependencies": [] - }, - { - "id": "T008", - "title": "Add integration tests", - "phase": 6, - "status": "completed", - "parallel": false, - "dependencies": [ - "T004", - "T005" - ] - } + { "id": "T001", "title": "Add .prisma language mapping to language.ts", "phase": 6, "status": "completed", "parallel": true, "dependencies": [] }, + { "id": "T002", "title": "Add PRISMA_RUNTIME_DEPS configuration", "phase": 6, "status": "completed", "parallel": true, "dependencies": [] }, + { "id": "T003", "title": "Add setupPrismaDependencies() function", "phase": 6, "status": "completed", "parallel": false, "dependencies": ["T002"] }, + { "id": "T004", "title": "Add PrismaServer definition", "phase": 6, "status": "completed", "parallel": false, "dependencies": ["T002", "T003"] }, + { "id": "T005", "title": "Add PrismaServer to LSP_SERVERS array", "phase": 6, "status": "completed", "parallel": false, "dependencies": ["T004"] }, + { "id": "T006", "title": "Export PrismaServer from index.ts", "phase": 6, "status": "completed", "parallel": false, "dependencies": ["T004"] }, + { "id": "T007", "title": "Add unit tests for PrismaServer", "phase": 6, "status": "completed", "parallel": false, "dependencies": ["T004"] }, + { "id": "T008", "title": "Update CLAUDE.md documentation", "phase": 6, "status": "completed", "parallel": false, "dependencies": ["T004"] } ], "checkpoints": [] -} \ No newline at end of file +} diff --git a/CLAUDE.md b/CLAUDE.md index 3c88175..fa93567 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,10 +126,12 @@ Claude/MCP Client <-> StdioTransport <-> McpServer <-> Providers | Rust | rust-analyzer | Cargo.toml | | Kotlin | JetBrains Kotlin LSP (auto-download) | build.gradle.kts, build.gradle, pom.xml | | Dart | dart language-server (auto-download) | pubspec.yaml, pubspec.lock | +| Prisma | @prisma/language-server (auto-download) | schema.prisma, prisma/schema.prisma | +| Vue | @vue/language-server (auto-download) | package.json (with vue) | ### Built-in Formatters -biome, prettier, gofmt, mix (Elixir), zig fmt, clang-format, ktlint (Kotlin), ruff, air (R), uv format, rubocop, standardrb, htmlbeautifier, dart, ocamlformat, terraform, latexindent, gleam +biome, prettier, gofmt, mix (Elixir), zig fmt, clang-format, ktlint (Kotlin), ruff, air (R), uv format, rubocop, standardrb, htmlbeautifier, dart, ocamlformat, terraform, latexindent, gleam, prisma ### Name Path Convention (JetBrains) diff --git a/README.md b/README.md index 4aa5398..44da8d6 100644 --- a/README.md +++ b/README.md @@ -102,10 +102,13 @@ Create `dora.json` or `opencode.json` in your project root: | Go | gopls | go.mod | | Rust | rust-analyzer | Cargo.toml | | Kotlin | JetBrains Kotlin LSP | build.gradle.kts, pom.xml | +| Dart | dart language-server | pubspec.yaml | +| Prisma | @prisma/language-server | schema.prisma | +| Vue | @vue/language-server | package.json (with vue) | ### Formatters -biome, prettier, gofmt, mix, zig fmt, clang-format, ktlint, ruff, air (R), uv format, rubocop, standardrb, htmlbeautifier, dart, ocamlformat, terraform, latexindent, gleam +biome, prettier, gofmt, mix, zig fmt, clang-format, ktlint, ruff, air (R), uv format, rubocop, standardrb, htmlbeautifier, dart, ocamlformat, terraform, latexindent, gleam, prisma ## MCP Server diff --git a/packages/format/README.md b/packages/format/README.md new file mode 100644 index 0000000..15154ed --- /dev/null +++ b/packages/format/README.md @@ -0,0 +1,117 @@ +# @pleaseai/code-format + +Auto-format hooks for AI coding tools. + +## Installation + +```bash +bun add @pleaseai/code-format +``` + +## Features + +- Multi-formatter support with automatic detection +- Configuration via `dora.json`, `opencode.json`, or `.please/config.yml` +- Custom formatter support with command templates + +## Quick Start + +```typescript +import { Format } from '@pleaseai/code-format' + +// Initialize from project configuration +await Format.initFromProject('/path/to/project') + +// Format a file +await Format.formatFile('src/index.ts') + +// Get formatter status +const status = await Format.status() +``` + +## Supported Formatters + +| Formatter | Extensions | Auto-detected by | +|-----------|------------|------------------| +| biome | .ts, .tsx, .js, .jsx, .json, .css, .md, etc. | biome.json | +| prettier | .ts, .tsx, .js, .jsx, .json, .css, .md, etc. | package.json (prettier dep) | +| gofmt | .go | gofmt in PATH | +| mix | .ex, .exs, .eex, .heex | mix in PATH | +| zig | .zig, .zon | zig in PATH | +| clang-format | .c, .cpp, .h, .hpp | .clang-format | +| ktlint | .kt, .kts | ktlint in PATH | +| ruff | .py, .pyi | ruff.toml, pyproject.toml | +| uv format | .py, .pyi | uv in PATH (fallback if no ruff) | +| air | .R | air in PATH | +| rubocop | .rb, .rake, .gemspec | rubocop in PATH | +| standardrb | .rb, .rake, .gemspec | standardrb in PATH | +| htmlbeautifier | .erb, .html.erb | htmlbeautifier in PATH | +| dart | .dart | dart in PATH | +| ocamlformat | .ml, .mli | .ocamlformat | +| terraform | .tf, .tfvars | terraform in PATH | +| latexindent | .tex | latexindent in PATH | +| gleam | .gleam | gleam in PATH | +| prisma | .prisma | schema.prisma | + +## Configuration + +Create `dora.json` or `opencode.json` in your project root: + +```json +{ + "formatter": { + "biome": { + "extensions": [".ts", ".tsx", ".js", ".jsx", ".json"] + }, + "prettier": { + "disabled": true + }, + "custom": { + "command": ["my-formatter", "$FILE"], + "extensions": [".xyz"] + } + } +} +``` + +### Disable all formatters + +```json +{ + "formatter": false +} +``` + +### Configuration options + +| Option | Type | Description | +|--------|------|-------------| +| `disabled` | boolean | Disable this formatter | +| `command` | string[] | Command template (`$FILE` = file path) | +| `extensions` | string[] | File extensions to handle | +| `environment` | object | Environment variables | + +## API Reference + +### Format + +| Method | Description | +|--------|-------------| +| `initFromProject(projectDir)` | Initialize from project config files | +| `init(config)` | Initialize with explicit config | +| `formatFile(file)` | Format a file | +| `status()` | Get all formatter statuses | + +### Types + +```typescript +import type { + FormatConfig, + FormatStatus, + FormatterConfig +} from '@pleaseai/code-format' +``` + +## License + +MIT diff --git a/packages/format/src/formatter.ts b/packages/format/src/formatter.ts index 2e76b9f..adc8fbe 100644 --- a/packages/format/src/formatter.ts +++ b/packages/format/src/formatter.ts @@ -331,3 +331,23 @@ export const gleam: Info = { return Bun.which('gleam') !== null }, } + +export const prisma: Info = { + name: 'prisma', + command: [bunExecutable(), 'x', 'prisma', 'format', '--schema', '$FILE'], + environment: { + BUN_BE_BUN: '1', + }, + extensions: ['.prisma'], + async enabled(projectDir: string) { + // Check for schema.prisma or prisma/schema.prisma + const schemaFiles = ['schema.prisma', 'prisma/schema.prisma'] + for (const schema of schemaFiles) { + const found = await findUp(schema, projectDir) + if (found.length > 0) { + return true + } + } + return false + }, +} diff --git a/packages/lsp/CLAUDE.md b/packages/lsp/CLAUDE.md index 835b691..50f5894 100644 --- a/packages/lsp/CLAUDE.md +++ b/packages/lsp/CLAUDE.md @@ -29,6 +29,7 @@ src/ | Rust Analyzer | `rust-analyzer` | .rs | Cargo.toml, Cargo.lock | | Kotlin | `kotlin` | .kt, .kts | build.gradle.kts, build.gradle, settings.gradle.kts, settings.gradle, pom.xml | | Dart | `dart` | .dart | pubspec.yaml, pubspec.lock | +| Prisma | `prisma` | .prisma | schema.prisma, prisma/schema.prisma | ## Adding a New Server diff --git a/packages/lsp/README.md b/packages/lsp/README.md index 8dbde36..5b10233 100644 --- a/packages/lsp/README.md +++ b/packages/lsp/README.md @@ -84,6 +84,7 @@ await manager.shutdown() | Rust | rust-analyzer | No | | Kotlin | JetBrains Kotlin LSP | Yes | | Dart | dart language-server | Yes | +| Prisma | @prisma/language-server | Yes | | Linting | oxlint | No | ## API Reference diff --git a/packages/lsp/src/index.ts b/packages/lsp/src/index.ts index ea037b9..1f8ba5f 100644 --- a/packages/lsp/src/index.ts +++ b/packages/lsp/src/index.ts @@ -900,6 +900,7 @@ export { type LSPServerHandle, type LSPServerInfo, OxlintServer, + PrismaServer, PyrightServer, RustAnalyzerServer, TypescriptServer, diff --git a/packages/lsp/src/language.ts b/packages/lsp/src/language.ts index a2966c7..d8211f4 100644 --- a/packages/lsp/src/language.ts +++ b/packages/lsp/src/language.ts @@ -116,6 +116,7 @@ export const LANGUAGE_EXTENSIONS: Record = { '.hcl': 'hcl', '.kt': 'kotlin', '.kts': 'kotlin', + '.prisma': 'prisma', } as const /** diff --git a/packages/lsp/src/server.ts b/packages/lsp/src/server.ts index 265fcfb..efa789a 100644 --- a/packages/lsp/src/server.ts +++ b/packages/lsp/src/server.ts @@ -180,8 +180,14 @@ function nearestRoot( await fs.access(target) return undefined // Excluded } - catch { - // Not found, continue + catch (err) { + // Only continue searching if file not found + const isNotFound = err instanceof Error + && 'code' in err + && (err as NodeJS.ErrnoException).code === 'ENOENT' + if (!isNotFound) { + console.warn(`[lsp] Unexpected error accessing ${target}:`, err instanceof Error ? err.message : err) + } } } const parent = path.dirname(checkDir) @@ -199,8 +205,14 @@ function nearestRoot( await fs.access(target) return current } - catch { - // Not found, continue + catch (err) { + // Only continue searching if file not found + const isNotFound = err instanceof Error + && 'code' in err + && (err as NodeJS.ErrnoException).code === 'ENOENT' + if (!isNotFound) { + console.warn(`[lsp] Unexpected error accessing ${target}:`, err instanceof Error ? err.message : err) + } } } const parent = path.dirname(current) @@ -233,17 +245,25 @@ export const TypescriptServer: LSPServerInfo = { const tsserver = Bun.which('typescript-language-server') if (!tsserver) { // Try via bunx - const proc = spawn('bunx', ['typescript-language-server', '--stdio'], { - cwd: root, - env: { ...process.env, BUN_BE_BUN: '1' }, - }) - return { process: proc } + try { + const proc = spawn('bunx', ['typescript-language-server', '--stdio'], { + cwd: root, + env: { ...process.env, BUN_BE_BUN: '1' }, + }) + attachLSPProcessHandlers(proc, 'typescript') + return { process: proc } + } + catch (err) { + console.error('[typescript] Failed to spawn via bunx. Install typescript-language-server globally or ensure bunx is available:', err) + return undefined + } } const proc = spawn(tsserver, ['--stdio'], { cwd: root, env: { ...process.env, BUN_BE_BUN: '1' }, }) + attachLSPProcessHandlers(proc, 'typescript') return { process: proc } }, } @@ -263,8 +283,14 @@ export const DenoServer: LSPServerInfo = { await fs.access(target) return current } - catch { - // Not found + catch (err) { + // Only continue searching if file not found + const isNotFound = err instanceof Error + && 'code' in err + && (err as NodeJS.ErrnoException).code === 'ENOENT' + if (!isNotFound) { + console.warn(`[deno] Unexpected error accessing ${target}:`, err instanceof Error ? err.message : err) + } } } const parent = path.dirname(current) @@ -282,6 +308,7 @@ export const DenoServer: LSPServerInfo = { const proc = spawn(deno, ['lsp'], { cwd: root, }) + attachLSPProcessHandlers(proc, 'deno') return { process: proc } }, } @@ -302,15 +329,23 @@ export const PyrightServer: LSPServerInfo = { const pyright = Bun.which('pyright-langserver') if (!pyright) { // Try via bunx/npx - const proc = spawn('bunx', ['pyright-langserver', '--stdio'], { - cwd: root, - }) - return { process: proc } + try { + const proc = spawn('bunx', ['pyright-langserver', '--stdio'], { + cwd: root, + }) + attachLSPProcessHandlers(proc, 'pyright') + return { process: proc } + } + catch (err) { + console.error('[pyright] Failed to spawn via bunx. Install pyright-langserver globally or ensure bunx is available:', err) + return undefined + } } const proc = spawn(pyright, ['--stdio'], { cwd: root, }) + attachLSPProcessHandlers(proc, 'pyright') return { process: proc } }, } @@ -336,6 +371,7 @@ export const GoplsServer: LSPServerInfo = { const proc = spawn(gopls, ['serve'], { cwd: root, }) + attachLSPProcessHandlers(proc, 'gopls') return { process: proc } }, } @@ -355,6 +391,7 @@ export const RustAnalyzerServer: LSPServerInfo = { const proc = spawn(rustAnalyzer, [], { cwd: root, }) + attachLSPProcessHandlers(proc, 'rust-analyzer') return { process: proc } }, } @@ -435,11 +472,11 @@ export const OxlintServer: LSPServerInfo = { else { const help = await new Response(proc.stdout).text() if (help.includes('--lsp')) { - return { - process: spawn(lintBin, ['--lsp'], { - cwd: root, - }), - } + const lspProc = spawn(lintBin, ['--lsp'], { + cwd: root, + }) + attachLSPProcessHandlers(lspProc, 'oxlint') + return { process: lspProc } } } } @@ -447,11 +484,11 @@ export const OxlintServer: LSPServerInfo = { // Fallback to oxc_language_server const serverBin = await resolveBin('oxc_language_server') if (serverBin) { - return { - process: spawn(serverBin, [], { - cwd: root, - }), - } + const serverProc = spawn(serverBin, [], { + cwd: root, + }) + attachLSPProcessHandlers(serverProc, 'oxlint') + return { process: serverProc } } // Neither found - log diagnostic @@ -1104,6 +1141,182 @@ export const VueServer: LSPServerInfo = { }, } +// ============================================================================= +// Prisma Language Server +// ============================================================================= + +/** + * Prisma Language Server runtime dependency configuration + * Uses @prisma/language-server npm package with auto-download + */ +const PRISMA_RUNTIME_DEPS = { + prismaLanguageServer: { + package: '@prisma/language-server', + version: '31.1.35', + }, +} + +/** + * Get the Prisma LSP resources directory + */ +function getPrismaResourcesDir(): string { + return path.join(os.homedir(), '.cache', 'dora', 'prisma-lsp') +} + +/** + * Setup Prisma runtime dependencies using npm + * Installs to ~/.cache/dora/prisma-lsp/ if not already present or version mismatch + * + * @returns Path to prisma-language-server binary, or undefined on failure + */ +async function setupPrismaDependencies(): Promise { + const resourcesDir = getPrismaResourcesDir() + const isWindows = process.platform === 'win32' + const ext = isWindows ? '.cmd' : '' + + const prismaServerPath = path.join(resourcesDir, 'node_modules', '.bin', `prisma-language-server${ext}`) + const versionFile = path.join(resourcesDir, '.installed_version') + const expectedVersion = PRISMA_RUNTIME_DEPS.prismaLanguageServer.version + + // Check if installation is needed + let needsInstall = false + + try { + await fs.access(prismaServerPath) + + try { + const installedVersion = await fs.readFile(versionFile, 'utf-8') + if (installedVersion.trim() !== expectedVersion) { + console.warn(`[prisma] Version mismatch: installed=${installedVersion.trim()}, expected=${expectedVersion}`) + needsInstall = true + } + } + catch (err) { + const isNotFound = err instanceof Error + && 'code' in err + && (err as NodeJS.ErrnoException).code === 'ENOENT' + + if (isNotFound) { + needsInstall = true + } + else { + console.warn(`[prisma] Unexpected error reading version file:`, err instanceof Error ? err.message : err) + needsInstall = true + } + } + } + catch (err) { + const isNotFound = err instanceof Error + && 'code' in err + && (err as NodeJS.ErrnoException).code === 'ENOENT' + + if (isNotFound) { + needsInstall = true + } + else { + console.error(`[prisma] Cannot access Prisma LSP executable:`, err instanceof Error ? err.message : err) + return undefined + } + } + + if (needsInstall) { + console.warn('[prisma] Installing Prisma Language Server...') + + try { + await fs.mkdir(resourcesDir, { recursive: true }) + + const packageSpec = `${PRISMA_RUNTIME_DEPS.prismaLanguageServer.package}@${PRISMA_RUNTIME_DEPS.prismaLanguageServer.version}` + + const proc = Bun.spawn(['npm', 'install', '--prefix', resourcesDir, packageSpec], { + cwd: resourcesDir, + stdout: 'pipe', + stderr: 'pipe', + }) + + const exitCode = await proc.exited + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text() + console.error(`[prisma] npm install failed with exit code ${exitCode}: ${stderr}`) + return undefined + } + + // Write version marker (non-fatal if this fails) + try { + await fs.writeFile(versionFile, expectedVersion) + } + catch (writeErr) { + console.warn(`[prisma] Failed to write version marker file:`, writeErr instanceof Error ? writeErr.message : writeErr, '- Dependencies will be reinstalled on next run') + } + console.warn('[prisma] Prisma Language Server installed successfully') + } + catch (err) { + console.error('[prisma] Failed to install dependencies:', err) + return undefined + } + } + + // Verify binary exists + try { + await fs.access(prismaServerPath) + return prismaServerPath + } + catch (err) { + console.error(`[prisma] Prisma LSP binary not found after installation: ${prismaServerPath}:`, err instanceof Error ? err.message : err) + return undefined + } +} + +/** + * Prisma Language Server + * Uses @prisma/language-server npm package with auto-download + * + * Features: + * - Diagnostics (real-time error highlighting) + * - Code completions + * - Hover information + * - Go-to-definition + * - Document formatting + * - Code actions + * - Rename symbol + * - Document symbols + */ +export const PrismaServer: LSPServerInfo = { + id: 'prisma', + extensions: ['.prisma'], + root: nearestRoot(['schema.prisma', 'prisma/schema.prisma']), + async spawn(root) { + // Check for node/npm availability + const node = Bun.which('node') + const npm = Bun.which('npm') + + if (!node || !npm) { + console.warn('[prisma] Node.js and npm are required for Prisma Language Server') + return undefined + } + + // Setup dependencies (npm install if needed) + const prismaServerPath = await setupPrismaDependencies() + if (!prismaServerPath) { + console.warn('[prisma] Failed to setup Prisma LSP dependencies. Check previous logs for details.') + return undefined + } + + try { + const proc = spawn(prismaServerPath, ['--stdio'], { + cwd: root, + }) + + attachLSPProcessHandlers(proc, 'prisma') + + return { process: proc } + } + catch (err) { + console.error('[prisma] Failed to spawn Prisma Language Server:', err) + return undefined + } + }, +} + /** * All available LSP servers */ @@ -1117,6 +1330,7 @@ export const LSP_SERVERS: LSPServerInfo[] = [ RustAnalyzerServer, KotlinServer, DartServer, + PrismaServer, ] /** diff --git a/packages/lsp/test/unit/server.test.ts b/packages/lsp/test/unit/server.test.ts index 8943c1c..827b892 100644 --- a/packages/lsp/test/unit/server.test.ts +++ b/packages/lsp/test/unit/server.test.ts @@ -11,6 +11,7 @@ import { KotlinServer, LSP_SERVERS, OxlintServer, + PrismaServer, PyrightServer, RustAnalyzerServer, TypescriptServer, @@ -31,6 +32,7 @@ describe('LSP_SERVERS', () => { expect(serverIds).toContain('kotlin') expect(serverIds).toContain('dart') expect(serverIds).toContain('vue') + expect(serverIds).toContain('prisma') }) }) @@ -451,6 +453,119 @@ describe('VueServer', () => { }) }) +describe('PrismaServer', () => { + test('has correct id', () => { + expect(PrismaServer.id).toBe('prisma') + }) + + test('supports Prisma extension', () => { + expect(PrismaServer.extensions).toContain('.prisma') + }) + + test('has root function', () => { + expect(typeof PrismaServer.root).toBe('function') + }) + + test('has spawn function', () => { + expect(typeof PrismaServer.spawn).toBe('function') + }) + + test('root function detects schema.prisma', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'prisma-test-')) + try { + // Create schema.prisma at root + await fs.writeFile(path.join(tempDir, 'schema.prisma'), 'datasource db {\n provider = "sqlite"\n url = "file:./dev.db"\n}\n') + + const prismaFile = path.join(tempDir, 'schema.prisma') + const root = await PrismaServer.root(prismaFile, tempDir) + expect(root).toBe(tempDir) + } + finally { + await fs.rm(tempDir, { recursive: true, force: true }) + } + }) + + test('root function detects prisma/schema.prisma from project root', async () => { + // Test that prisma/schema.prisma pattern works from project root + // When calling root() with a file outside prisma/, it should find the project root + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'prisma-test-')) + try { + // Create prisma/schema.prisma (conventional location) + const prismaDir = path.join(tempDir, 'prisma') + await fs.mkdir(prismaDir) + await fs.writeFile(path.join(prismaDir, 'schema.prisma'), 'datasource db {\n provider = "sqlite"\n url = "file:./dev.db"\n}\n') + + // Create a source file at project root level + const srcDir = path.join(tempDir, 'src') + await fs.mkdir(srcDir) + const srcFile = path.join(srcDir, 'index.ts') + await fs.writeFile(srcFile, '// test') + + // When root() is called with src/index.ts, it should find tempDir via prisma/schema.prisma + const root = await PrismaServer.root(srcFile, tempDir) + expect(root).toBe(tempDir) + } + finally { + await fs.rm(tempDir, { recursive: true, force: true }) + } + }) + + test('root function detects nested monorepo package', async () => { + // Simulates a monorepo with nested Prisma schema + // root/ + // packages/ + // backend/ + // prisma/ + // schema.prisma + // src/ + // index.ts + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'prisma-monorepo-')) + try { + // Create nested package structure + const backendDir = path.join(tempDir, 'packages', 'backend') + const prismaDir = path.join(backendDir, 'prisma') + await fs.mkdir(prismaDir, { recursive: true }) + await fs.writeFile(path.join(prismaDir, 'schema.prisma'), 'datasource db {\n provider = "sqlite"\n url = "file:./dev.db"\n}\n') + + // Create a source file in backend/src + const srcDir = path.join(backendDir, 'src') + await fs.mkdir(srcDir) + const srcFile = path.join(srcDir, 'index.ts') + await fs.writeFile(srcFile, '// test') + + // When root() is called with src/index.ts, it should find backendDir via prisma/schema.prisma + const root = await PrismaServer.root(srcFile, tempDir) + expect(root).toBe(backendDir) + } + finally { + await fs.rm(tempDir, { recursive: true, force: true }) + } + }) + + test('root function detects schema.prisma at project root', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'prisma-root-schema-')) + try { + // schema.prisma at project root (not in prisma/ subdirectory) + const prismaFile = path.join(tempDir, 'schema.prisma') + await fs.writeFile(prismaFile, '// test') + + const root = await PrismaServer.root(prismaFile, tempDir) + expect(root).toBe(tempDir) + } + finally { + await fs.rm(tempDir, { recursive: true, force: true }) + } + }) + + test('spawn function returns promise', () => { + // Verify spawn returns a promise (don't actually call it to avoid downloads) + const spawnFn = PrismaServer.spawn + expect(typeof spawnFn).toBe('function') + // Verify it's an async function by checking the constructor name + expect(spawnFn.constructor.name).toBe('AsyncFunction') + }) +}) + describe('getServerById', () => { test('returns typescript server', () => { const server = getServerById('typescript') @@ -521,6 +636,14 @@ describe('getServersForExtension', () => { expect(serverIds).toContain('vue') }) + test('returns servers for .prisma extension', () => { + const servers = getServersForExtension('.prisma') + expect(servers.length).toBeGreaterThan(0) + + const serverIds = servers.map(s => s.id) + expect(serverIds).toContain('prisma') + }) + test('returns empty array for unknown extension', () => { const servers = getServersForExtension('.unknown') expect(servers).toEqual([]) diff --git a/specs/002-prisma-lsp/spec.md b/specs/002-prisma-lsp/spec.md new file mode 100644 index 0000000..7023fae --- /dev/null +++ b/specs/002-prisma-lsp/spec.md @@ -0,0 +1,94 @@ +# Spec 002: Add Prisma Language Server Support + +## Overview + +Add support for the Prisma Language Server (`@prisma/language-server`) to `@pleaseai/code-lsp` package, enabling LSP features for `.prisma` schema files. + +## Problem Statement + +Prisma is a popular ORM for Node.js and TypeScript. Developers working with Prisma schema files (`.prisma`) need language intelligence features like diagnostics, completions, hover information, go-to-definition, and formatting. The `@pleaseai/code-lsp` package currently doesn't support Prisma files. + +## Requirements + +### Functional Requirements + +1. **Auto-Download**: The Prisma Language Server must be automatically downloaded and installed when needed (similar to Vue LS pattern) + - Store in `~/.cache/dora/prisma-lsp/` + - Use version marker file for tracking installed version + - Install via `npm install --prefix` + +2. **Root Detection**: Detect Prisma projects by looking for: + - `schema.prisma` in project root + - `prisma/schema.prisma` (conventional location) + - Should find nearest match, supporting monorepo structures + +3. **File Extension Support**: Support `.prisma` file extension + +4. **LSP Features**: Enable standard LSP features provided by Prisma LS: + - Diagnostics (real-time error highlighting) + - Code completions + - Hover information + - Go-to-definition + - Document formatting + - Code actions + - Rename symbol + - Document symbols + +### Non-Functional Requirements + +1. **Version Pinning**: Pin to a specific stable version of `@prisma/language-server` for reproducibility +2. **Error Handling**: Graceful degradation if npm/node not available or installation fails +3. **Logging**: Consistent logging with `[prisma]` prefix for debugging + +## Technical Design + +### Server Definition + +```typescript +export const PrismaServer: LSPServerInfo = { + id: 'prisma', + extensions: ['.prisma'], + root: nearestRoot(['schema.prisma', 'prisma/schema.prisma']), + spawn: async (root) => { ... } +} +``` + +### Auto-Download Pattern + +Follow Vue LS pattern: +1. Check if `prisma-language-server` binary exists in cache directory +2. Compare version marker file with expected version +3. If mismatch or not found, run `npm install --prefix ~/.cache/dora/prisma-lsp @prisma/language-server@` +4. Write version marker after successful install +5. Return path to binary + +### Language ID Mapping + +Add to `language.ts`: +```typescript +'.prisma': 'prisma' +``` + +## Test Plan + +1. **Unit Tests**: + - Test server ID and extensions + - Test root detection with schema.prisma + - Test root detection with prisma/schema.prisma + - Test monorepo nested package detection + - Verify spawn function signature + +2. **Integration Tests** (optional, requires npm): + - Test auto-download mechanism + - Test actual LSP communication + +## Out of Scope + +- Prisma Client generation integration +- Database migration features +- Multi-schema support (Prisma 5.0+ feature) + +## References + +- [Prisma Language Server](https://github.com/prisma/language-tools/tree/main/packages/language-server) +- [npm: @prisma/language-server](https://www.npmjs.com/package/@prisma/language-server)