Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/lsp/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ src/
| Deno | `deno` | .ts, .tsx, .js, .jsx, .mjs | deno.json, deno.jsonc |
| Vue | `vue` | .vue | package.json, package-lock.json, bun.lockb, bun.lock, pnpm-lock.yaml, yarn.lock |
| Oxlint | `oxlint` | .ts, .tsx, .js, .jsx, .mjs, .cjs, .mts, .cts, .vue, .astro, .svelte | .oxlintrc.json, package-lock.json, bun.lockb, bun.lock, pnpm-lock.yaml, yarn.lock, package.json |
| ESLint | `eslint` | .ts, .tsx, .js, .jsx, .mjs, .cjs, .mts, .cts, .vue | package-lock.json, bun.lockb, bun.lock, pnpm-lock.yaml, yarn.lock |
| Pyright | `pyright` | .py, .pyi | pyproject.toml, setup.py, requirements.txt, pyrightconfig.json |
| Gopls | `gopls` | .go | go.work, go.mod, go.sum |
| Rust Analyzer | `rust-analyzer` | .rs | Cargo.toml, Cargo.lock |
Expand Down
1 change: 1 addition & 0 deletions packages/lsp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -925,6 +925,7 @@ export { getLanguageId, LANGUAGE_EXTENSIONS } from './language'
export {
DartServer,
DenoServer,
EslintServer,
getServerById,
getServersForExtension,
GoplsServer,
Expand Down
196 changes: 196 additions & 0 deletions packages/lsp/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1317,6 +1317,201 @@ export const PrismaServer: LSPServerInfo = {
},
}

// =============================================================================
// ESLint Language Server
// =============================================================================

/**
* Get the ESLint LSP resources directory
*/
function getEslintResourcesDir(): string {
return path.join(os.homedir(), '.cache', 'dora', 'eslint-lsp')
}

/**
* Setup ESLint runtime dependencies
* Downloads vscode-eslint from GitHub and builds it
*
* @returns Path to eslint server JS file, or undefined on failure
*/
async function setupEslintDependencies(): Promise<string | undefined> {
const resourcesDir = getEslintResourcesDir()
const vscodeEslintDir = path.join(resourcesDir, 'vscode-eslint')
const serverPath = path.join(vscodeEslintDir, 'server', 'out', 'eslintServer.js')

// Check if server already exists
try {
await fs.access(serverPath)
return serverPath
}
catch (err) {
const isNotFound = err instanceof Error
&& 'code' in err
&& (err as NodeJS.ErrnoException).code === 'ENOENT'

if (!isNotFound) {
console.error(`[eslint] Cannot access ESLint server at ${serverPath}:`, err instanceof Error ? err.message : err)
return undefined
}
}

// Server not found, need to download and build
console.warn('[eslint] Downloading and building VS Code ESLint server...')

try {
await fs.mkdir(resourcesDir, { recursive: true })

// Download vscode-eslint from GitHub
const response = await fetch('https://github.com/microsoft/vscode-eslint/archive/refs/heads/main.zip')
if (!response.ok) {
console.error(`[eslint] Failed to download vscode-eslint: ${response.status} ${response.statusText}`)
return undefined
}

const zipPath = path.join(resourcesDir, 'vscode-eslint.zip')
const body = response.body
if (!body) {
console.error('[eslint] No response body for vscode-eslint download')
return undefined
}

// Write zip file
const writeStream = createWriteStream(zipPath)
await pipeline(body as unknown as NodeJS.ReadableStream, writeStream)

// Extract zip
console.warn('[eslint] Extracting vscode-eslint...')
await fs.mkdir(resourcesDir, { recursive: true })
const extractProc = Bun.spawn(['unzip', '-o', '-q', zipPath, '-d', resourcesDir], {
stdout: 'pipe',
stderr: 'pipe',
})
const extractExitCode = await extractProc.exited
if (extractExitCode !== 0) {
const stderr = await new Response(extractProc.stderr).text()
console.error(`[eslint] Failed to extract vscode-eslint: ${stderr}`)
return undefined
}

// Cleanup zip file
await fs.rm(zipPath, { force: true })

// Rename extracted directory (vscode-eslint-main -> vscode-eslint)
const extractedPath = path.join(resourcesDir, 'vscode-eslint-main')
try {
await fs.access(vscodeEslintDir)
console.warn('[eslint] Removing old vscode-eslint installation...')
await fs.rm(vscodeEslintDir, { force: true, recursive: true })
}
catch {
// Directory doesn't exist, that's fine
}
await fs.rename(extractedPath, vscodeEslintDir)

// Run npm install
console.warn('[eslint] Running npm install...')
const npmInstallProc = Bun.spawn(['npm', 'install'], {
cwd: vscodeEslintDir,
stdout: 'pipe',
stderr: 'pipe',
})
const npmInstallExitCode = await npmInstallProc.exited
if (npmInstallExitCode !== 0) {
const stderr = await new Response(npmInstallProc.stderr).text()
console.error(`[eslint] npm install failed: ${stderr}`)
return undefined
}

// Run npm run compile
console.warn('[eslint] Building vscode-eslint (npm run compile)...')
const npmCompileProc = Bun.spawn(['npm', 'run', 'compile'], {
cwd: vscodeEslintDir,
stdout: 'pipe',
stderr: 'pipe',
})
const npmCompileExitCode = await npmCompileProc.exited
if (npmCompileExitCode !== 0) {
const stderr = await new Response(npmCompileProc.stderr).text()
console.error(`[eslint] npm run compile failed: ${stderr}`)
return undefined
}

console.warn('[eslint] VS Code ESLint server installed successfully')
}
catch (err) {
console.error('[eslint] Failed to setup ESLint dependencies:', err)
return undefined
}

// Verify server exists
try {
await fs.access(serverPath)
return serverPath
}
catch {
console.error(`[eslint] ESLint server not found after installation: ${serverPath}`)
return undefined
}
}

/**
* ESLint Language Server
* Downloads and builds vscode-eslint from GitHub
* Requires eslint package in project (prerequisite check)
*/
export const EslintServer: LSPServerInfo = {
id: 'eslint',
extensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts', '.vue'],
root: nearestRoot([
'package-lock.json',
'bun.lockb',
'bun.lock',
'pnpm-lock.yaml',
'yarn.lock',
]),
async spawn(root) {
// Prerequisite: Check eslint exists in project
const isWindows = process.platform === 'win32'
const eslintBin = path.join(root, 'node_modules', '.bin', `eslint${isWindows ? '.cmd' : ''}`)
try {
await fs.access(eslintBin)
}
catch {
console.warn('[eslint] eslint package not found in project. Install with: npm install eslint')
return undefined
}

// Check for node/npm availability
const node = Bun.which('node')
const npm = Bun.which('npm')
if (!node || !npm) {
console.warn('[eslint] Node.js and npm are required for ESLint Language Server')
return undefined
}

// Setup dependencies (download + build if needed)
const serverPath = await setupEslintDependencies()
if (!serverPath) {
console.warn('[eslint] Failed to setup ESLint LSP dependencies. Check previous logs for details.')
return undefined
}

try {
// Spawn server with increased memory for large codebases
const proc = spawn(node, ['--max-old-space-size=8192', serverPath, '--stdio'], {
cwd: root,
})

attachLSPProcessHandlers(proc, 'eslint')
return { process: proc }
}
catch (err) {
console.error('[eslint] Failed to spawn ESLint Language Server:', err)
return undefined
}
},
}

/**
* All available LSP servers
*/
Expand All @@ -1325,6 +1520,7 @@ export const LSP_SERVERS: LSPServerInfo[] = [
VueServer, // Vue before TypeScript for .vue files
TypescriptServer,
OxlintServer,
EslintServer, // ESLint after OxlintServer for JS/TS linting
PyrightServer,
GoplsServer,
RustAnalyzerServer,
Expand Down
114 changes: 114 additions & 0 deletions packages/lsp/test/unit/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { describe, expect, test } from 'bun:test'
import {
DartServer,
DenoServer,
EslintServer,
getServerById,
getServersForExtension,
GoplsServer,
Expand Down Expand Up @@ -33,6 +34,7 @@ describe('LSP_SERVERS', () => {
expect(serverIds).toContain('dart')
expect(serverIds).toContain('vue')
expect(serverIds).toContain('prisma')
expect(serverIds).toContain('eslint')
})
})

Expand Down Expand Up @@ -100,6 +102,118 @@ describe('OxlintServer', () => {
})
})

describe('EslintServer', () => {
test('has correct id', () => {
expect(EslintServer.id).toBe('eslint')
})

test('supports JavaScript/TypeScript extensions', () => {
expect(EslintServer.extensions).toContain('.ts')
expect(EslintServer.extensions).toContain('.tsx')
expect(EslintServer.extensions).toContain('.js')
expect(EslintServer.extensions).toContain('.jsx')
expect(EslintServer.extensions).toContain('.mjs')
expect(EslintServer.extensions).toContain('.cjs')
expect(EslintServer.extensions).toContain('.mts')
expect(EslintServer.extensions).toContain('.cts')
})

test('supports Vue extension', () => {
expect(EslintServer.extensions).toContain('.vue')
})

test('has root function', () => {
expect(typeof EslintServer.root).toBe('function')
})

test('has spawn function', () => {
expect(typeof EslintServer.spawn).toBe('function')
})

test('root function detects package-lock.json', async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eslint-test-'))
try {
// Create package-lock.json
await fs.writeFile(path.join(tempDir, 'package-lock.json'), '{"name": "test"}\n')

// Create a nested source file
const srcDir = path.join(tempDir, 'src')
await fs.mkdir(srcDir)
const tsFile = path.join(srcDir, 'index.ts')
await fs.writeFile(tsFile, '// test')

const root = await EslintServer.root(tsFile, tempDir)
expect(root).toBe(tempDir)
}
finally {
await fs.rm(tempDir, { recursive: true, force: true })
}
})

test('root function detects bun.lock', async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eslint-bun-'))
try {
// Create bun.lock
await fs.writeFile(path.join(tempDir, 'bun.lock'), '')

const tsFile = path.join(tempDir, 'index.ts')
await fs.writeFile(tsFile, '// test')

const root = await EslintServer.root(tsFile, tempDir)
expect(root).toBe(tempDir)
}
finally {
await fs.rm(tempDir, { recursive: true, force: true })
}
})

test('root function returns projectPath when no lock file found', async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eslint-no-lock-'))
try {
// No lock files
const tsFile = path.join(tempDir, 'index.ts')
await fs.writeFile(tsFile, '// test')

const root = await EslintServer.root(tsFile, tempDir)
expect(root).toBe(tempDir)
}
finally {
await fs.rm(tempDir, { recursive: true, force: true })
}
})

test('root function finds nearest lock file in monorepo', async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eslint-monorepo-'))
try {
// Create root lock file
await fs.writeFile(path.join(tempDir, 'package-lock.json'), '{"name": "root"}\n')

// Create nested package with its own lock file
const innerDir = path.join(tempDir, 'packages', 'app')
await fs.mkdir(innerDir, { recursive: true })
await fs.writeFile(path.join(innerDir, 'package-lock.json'), '{"name": "app"}\n')

const srcDir = path.join(innerDir, 'src')
await fs.mkdir(srcDir)
const tsFile = path.join(srcDir, 'index.ts')
await fs.writeFile(tsFile, '// test')

// Should find the inner package's lock file
const root = await EslintServer.root(tsFile, tempDir)
expect(root).toBe(innerDir)
}
finally {
await fs.rm(tempDir, { recursive: true, force: true })
}
})

test('spawn function returns promise', () => {
const spawnFn = EslintServer.spawn
expect(typeof spawnFn).toBe('function')
expect(spawnFn.constructor.name).toBe('AsyncFunction')
})
})

describe('PyrightServer', () => {
test('has correct id', () => {
expect(PyrightServer.id).toBe('pyright')
Expand Down