From f2ce829f7b694522439f6e24ea577d1843ec75e2 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Thu, 18 Dec 2025 01:31:32 +0900 Subject: [PATCH 1/7] feat(lsp): add Dart Language Server support Add Dart LSP support with system-first fallback and auto-download: - Check for system `dart` in PATH first - Auto-download Dart SDK 3.7.1 if not found - Support all major platforms: linux-x64, linux-arm64, osx-x64, osx-arm64, win-x64 - Root detection via pubspec.yaml and pubspec.lock Closes #8 --- packages/lsp/src/__tests__/server.test.ts | 28 ++++ packages/lsp/src/index.ts | 1 + packages/lsp/src/server.ts | 182 ++++++++++++++++++++++ 3 files changed, 211 insertions(+) diff --git a/packages/lsp/src/__tests__/server.test.ts b/packages/lsp/src/__tests__/server.test.ts index 81d6d2e..ba3bcea 100644 --- a/packages/lsp/src/__tests__/server.test.ts +++ b/packages/lsp/src/__tests__/server.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test' import { + DartServer, DenoServer, getServerById, getServersForExtension, @@ -24,6 +25,7 @@ describe('LSP_SERVERS', () => { expect(serverIds).toContain('gopls') expect(serverIds).toContain('rust-analyzer') expect(serverIds).toContain('kotlin') + expect(serverIds).toContain('dart') }) }) @@ -141,6 +143,24 @@ describe('KotlinServer', () => { }) }) +describe('DartServer', () => { + test('has correct id', () => { + expect(DartServer.id).toBe('dart') + }) + + test('supports Dart extension', () => { + expect(DartServer.extensions).toContain('.dart') + }) + + test('has root function', () => { + expect(typeof DartServer.root).toBe('function') + }) + + test('has spawn function', () => { + expect(typeof DartServer.spawn).toBe('function') + }) +}) + describe('getServerById', () => { test('returns typescript server', () => { const server = getServerById('typescript') @@ -195,6 +215,14 @@ describe('getServersForExtension', () => { expect(serverIds).toContain('kotlin') }) + test('returns servers for .dart extension', () => { + const servers = getServersForExtension('.dart') + expect(servers.length).toBeGreaterThan(0) + + const serverIds = servers.map(s => s.id) + expect(serverIds).toContain('dart') + }) + test('returns empty array for unknown extension', () => { const servers = getServersForExtension('.unknown') expect(servers).toEqual([]) diff --git a/packages/lsp/src/index.ts b/packages/lsp/src/index.ts index a72c471..f42b23b 100644 --- a/packages/lsp/src/index.ts +++ b/packages/lsp/src/index.ts @@ -392,6 +392,7 @@ export { createLSPClient, type LSPClientInfo } from './client' // Re-export specific items to avoid conflicts export { getLanguageId, LANGUAGE_EXTENSIONS } from './language' export { + DartServer, DenoServer, getServerById, getServersForExtension, diff --git a/packages/lsp/src/server.ts b/packages/lsp/src/server.ts index 1f89ce4..fc73c47 100644 --- a/packages/lsp/src/server.ts +++ b/packages/lsp/src/server.ts @@ -674,6 +674,187 @@ export const KotlinServer: LSPServerInfo = { }, } +// ============================================================================= +// Dart Language Server +// ============================================================================= + +/** + * Dart SDK runtime dependency configuration + * Uses official Dart SDK with built-in language server + */ +const DART_RUNTIME_DEPS = { + version: '3.7.1', + platforms: { + 'win-x64': { + url: 'https://storage.googleapis.com/dart-archive/channels/stable/release/3.7.1/sdk/dartsdk-windows-x64-release.zip', + binaryPath: 'dart-sdk/bin/dart.exe', + }, + 'linux-x64': { + url: 'https://storage.googleapis.com/dart-archive/channels/stable/release/3.7.1/sdk/dartsdk-linux-x64-release.zip', + binaryPath: 'dart-sdk/bin/dart', + }, + 'linux-arm64': { + url: 'https://storage.googleapis.com/dart-archive/channels/stable/release/3.7.1/sdk/dartsdk-linux-arm64-release.zip', + binaryPath: 'dart-sdk/bin/dart', + }, + 'osx-x64': { + url: 'https://storage.googleapis.com/dart-archive/channels/stable/release/3.7.1/sdk/dartsdk-macos-x64-release.zip', + binaryPath: 'dart-sdk/bin/dart', + }, + 'osx-arm64': { + url: 'https://storage.googleapis.com/dart-archive/channels/stable/release/3.7.1/sdk/dartsdk-macos-arm64-release.zip', + binaryPath: 'dart-sdk/bin/dart', + }, + } as Record, +} + +/** + * Get the Dart LSP resources directory + */ +function getDartResourcesDir(): string { + return path.join(os.homedir(), '.cache', 'dora', 'dart-lsp') +} + +/** + * Setup Dart runtime dependencies + * Downloads Dart SDK if not available and dart is not in PATH + */ +async function setupDartDependencies(platformId: PlatformId): Promise { + const config = DART_RUNTIME_DEPS.platforms[platformId] + + if (!config) { + console.warn(`[dart] Unsupported platform: ${platformId}`) + return undefined + } + + const resourcesDir = getDartResourcesDir() + const dartPath = path.join(resourcesDir, config.binaryPath) + + try { + await fs.access(dartPath) + return dartPath + } + catch (err) { + const isNotFound = err instanceof Error + && 'code' in err + && (err as NodeJS.ErrnoException).code === 'ENOENT' + + if (!isNotFound) { + console.error(`[dart] Cannot access Dart at ${dartPath}:`, err instanceof Error ? err.message : err) + return undefined + } + + // Dart not found, download it + console.warn(`[dart] Downloading Dart SDK ${DART_RUNTIME_DEPS.version} for ${platformId}...`) + try { + await downloadAndExtract(config.url, resourcesDir) + + // Make dart executable on Unix platforms + if (!platformId.startsWith('win-')) { + try { + await fs.chmod(dartPath, 0o755) + } + catch (chmodErr) { + console.error(`[dart] Failed to make Dart executable at ${dartPath}:`, chmodErr instanceof Error ? chmodErr.message : chmodErr) + return undefined + } + } + } + catch (downloadErr) { + console.error(`[dart] Failed to download Dart SDK:`, downloadErr) + return undefined + } + } + + // Verify Dart exists + try { + await fs.access(dartPath) + return dartPath + } + catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err) + console.error(`[dart] Dart executable not accessible at ${dartPath}: ${errorMsg}`) + return undefined + } +} + +/** + * Dart Language Server + * Uses official Dart SDK with system-first fallback and auto-download + */ +export const DartServer: LSPServerInfo = { + id: 'dart', + extensions: ['.dart'], + root: nearestRoot(['pubspec.yaml', 'pubspec.lock']), + async spawn(root) { + // Try system dart first + const systemDart = Bun.which('dart') + if (systemDart) { + try { + const proc = spawn(systemDart, ['language-server', '--client-id', 'dora.dart', '--client-version', '1.0'], { + cwd: root, + }) + + proc.on('error', (err) => { + console.error(`[dart] Dart LSP process error:`, err) + }) + + proc.on('exit', (code, signal) => { + if (code !== 0 && code !== null) { + console.error(`[dart] Dart LSP exited with code ${code}`) + } + if (signal) { + console.error(`[dart] Dart LSP killed by signal ${signal}`) + } + }) + + return { process: proc } + } + catch (err) { + console.warn(`[dart] Failed to spawn system Dart LSP, trying auto-download:`, err) + } + } + + // Fallback to auto-download + const platformId = getPlatformId() + if (!platformId) { + console.warn(`[dart] Unsupported platform: ${process.platform}-${process.arch}`) + return undefined + } + + const dartPath = await setupDartDependencies(platformId) + if (!dartPath) { + console.warn(`[dart] Failed to setup Dart SDK. Check previous logs for details.`) + return undefined + } + + try { + const proc = spawn(dartPath, ['language-server', '--client-id', 'dora.dart', '--client-version', '1.0'], { + cwd: root, + }) + + proc.on('error', (err) => { + console.error(`[dart] Dart LSP process error:`, err) + }) + + proc.on('exit', (code, signal) => { + if (code !== 0 && code !== null) { + console.error(`[dart] Dart LSP exited with code ${code}`) + } + if (signal) { + console.error(`[dart] Dart LSP killed by signal ${signal}`) + } + }) + + return { process: proc } + } + catch (err) { + console.error(`[dart] Failed to spawn Dart LSP:`, err) + return undefined + } + }, +} + /** * All available LSP servers */ @@ -685,6 +866,7 @@ export const LSP_SERVERS: LSPServerInfo[] = [ GoplsServer, RustAnalyzerServer, KotlinServer, + DartServer, ] /** From ccad8ef97c207824d50022849063837817912875 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Thu, 18 Dec 2025 01:57:25 +0900 Subject: [PATCH 2/7] refactor(lsp): improve Dart LSP implementation - Extract process event handlers to attachLSPProcessHandlers() helper - Add documentation explaining setupDartDependencies vs Kotlin pattern - Add behavioral tests for root detection (pubspec.yaml/pubspec.lock) - Update CLAUDE.md to include Dart in supported servers table --- CLAUDE.md | 1 + packages/lsp/src/__tests__/server.test.ts | 63 +++++++++++++++++++ packages/lsp/src/server.ts | 74 +++++++++++------------ 3 files changed, 98 insertions(+), 40 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index de86154..3c88175 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,6 +125,7 @@ Claude/MCP Client <-> StdioTransport <-> McpServer <-> Providers | Go | gopls | go.mod, go.work | | 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 | ### Built-in Formatters diff --git a/packages/lsp/src/__tests__/server.test.ts b/packages/lsp/src/__tests__/server.test.ts index ba3bcea..7069b2e 100644 --- a/packages/lsp/src/__tests__/server.test.ts +++ b/packages/lsp/src/__tests__/server.test.ts @@ -1,3 +1,6 @@ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' import { describe, expect, test } from 'bun:test' import { DartServer, @@ -159,6 +162,66 @@ describe('DartServer', () => { test('has spawn function', () => { expect(typeof DartServer.spawn).toBe('function') }) + + test('root function detects pubspec.yaml', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dart-test-')) + try { + // Create pubspec.yaml + await fs.writeFile(path.join(tempDir, 'pubspec.yaml'), 'name: test_app\n') + + // Create a nested source file + const libDir = path.join(tempDir, 'lib') + await fs.mkdir(libDir) + const dartFile = path.join(libDir, 'main.dart') + await fs.writeFile(dartFile, '// test') + + const root = await DartServer.root(dartFile, tempDir) + expect(root).toBe(tempDir) + } + finally { + await fs.rm(tempDir, { recursive: true, force: true }) + } + }) + + test('root function detects pubspec.lock', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dart-test-')) + try { + // Create pubspec.lock only + await fs.writeFile(path.join(tempDir, 'pubspec.lock'), 'packages:\n') + + const dartFile = path.join(tempDir, 'main.dart') + await fs.writeFile(dartFile, '// test') + + const root = await DartServer.root(dartFile, tempDir) + expect(root).toBe(tempDir) + } + finally { + await fs.rm(tempDir, { recursive: true, force: true }) + } + }) + + test('root function returns projectPath when no pubspec found', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dart-test-')) + try { + // No pubspec files + const dartFile = path.join(tempDir, 'main.dart') + await fs.writeFile(dartFile, '// test') + + const root = await DartServer.root(dartFile, 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 = DartServer.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', () => { diff --git a/packages/lsp/src/server.ts b/packages/lsp/src/server.ts index fc73c47..a3c418f 100644 --- a/packages/lsp/src/server.ts +++ b/packages/lsp/src/server.ts @@ -130,6 +130,32 @@ async function downloadAndExtract(url: string, destDir: string): Promise { } } +// ============================================================================= +// Process Lifecycle Utilities +// ============================================================================= + +/** + * Attach error and exit event handlers to an LSP process + * Centralizes logging for process lifecycle events + */ +function attachLSPProcessHandlers( + proc: ChildProcessWithoutNullStreams, + serverId: string, +): void { + proc.on('error', (err) => { + console.error(`[${serverId}] LSP process error:`, err) + }) + + proc.on('exit', (code, signal) => { + if (code !== 0 && code !== null) { + console.error(`[${serverId}] LSP exited with code ${code}`) + } + if (signal) { + console.error(`[${serverId}] LSP killed by signal ${signal}`) + } + }) +} + // ============================================================================= // Root Detection Utilities // ============================================================================= @@ -651,20 +677,7 @@ export const KotlinServer: LSPServerInfo = { }, }) - // Log spawn errors - proc.on('error', (err) => { - console.error(`[kotlin] Kotlin LSP process error:`, err) - }) - - proc.on('exit', (code, signal) => { - if (code !== 0 && code !== null) { - console.error(`[kotlin] Kotlin LSP exited with code ${code}`) - } - if (signal) { - console.error(`[kotlin] Kotlin LSP killed by signal ${signal}`) - } - }) - + attachLSPProcessHandlers(proc, 'kotlin') return { process: proc } } catch (err) { @@ -718,6 +731,11 @@ function getDartResourcesDir(): string { /** * Setup Dart runtime dependencies * Downloads Dart SDK if not available and dart is not in PATH + * + * Unlike setupKotlinDependencies which returns { javaHomePath, kotlinLspPath }, + * this returns only the binary path because: + * - Dart SDK is self-contained (no separate JRE dependency) + * - No environment variables (like JAVA_HOME) required for execution */ async function setupDartDependencies(platformId: PlatformId): Promise { const config = DART_RUNTIME_DEPS.platforms[platformId] @@ -795,19 +813,7 @@ export const DartServer: LSPServerInfo = { cwd: root, }) - proc.on('error', (err) => { - console.error(`[dart] Dart LSP process error:`, err) - }) - - proc.on('exit', (code, signal) => { - if (code !== 0 && code !== null) { - console.error(`[dart] Dart LSP exited with code ${code}`) - } - if (signal) { - console.error(`[dart] Dart LSP killed by signal ${signal}`) - } - }) - + attachLSPProcessHandlers(proc, 'dart') return { process: proc } } catch (err) { @@ -833,19 +839,7 @@ export const DartServer: LSPServerInfo = { cwd: root, }) - proc.on('error', (err) => { - console.error(`[dart] Dart LSP process error:`, err) - }) - - proc.on('exit', (code, signal) => { - if (code !== 0 && code !== null) { - console.error(`[dart] Dart LSP exited with code ${code}`) - } - if (signal) { - console.error(`[dart] Dart LSP killed by signal ${signal}`) - } - }) - + attachLSPProcessHandlers(proc, 'dart') return { process: proc } } catch (err) { From 9e94535d56c43c70bee90d43a3a95cc6cdb384a8 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Thu, 18 Dec 2025 02:14:52 +0900 Subject: [PATCH 3/7] test(lsp): add monorepo and deep nesting tests for DartServer - Add test for nested monorepo package detection (inner pubspec.yaml) - Add test for deep directory structure root detection --- packages/lsp/src/__tests__/server.test.ts | 58 +++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/packages/lsp/src/__tests__/server.test.ts b/packages/lsp/src/__tests__/server.test.ts index 7069b2e..49fedb3 100644 --- a/packages/lsp/src/__tests__/server.test.ts +++ b/packages/lsp/src/__tests__/server.test.ts @@ -222,6 +222,64 @@ describe('DartServer', () => { // Verify it's an async function by checking the constructor name expect(spawnFn.constructor.name).toBe('AsyncFunction') }) + + test('root function detects nested monorepo package', async () => { + // Simulates a monorepo with nested Dart packages + // root/ + // pubspec.yaml (root package) + // packages/ + // inner_app/ + // pubspec.yaml (inner package) + // lib/ + // main.dart + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dart-monorepo-')) + try { + // Create root pubspec.yaml + await fs.writeFile(path.join(tempDir, 'pubspec.yaml'), 'name: root_app\n') + + // Create nested package structure + const innerPkgDir = path.join(tempDir, 'packages', 'inner_app') + await fs.mkdir(innerPkgDir, { recursive: true }) + await fs.writeFile(path.join(innerPkgDir, 'pubspec.yaml'), 'name: inner_app\n') + + const libDir = path.join(innerPkgDir, 'lib') + await fs.mkdir(libDir) + const dartFile = path.join(libDir, 'main.dart') + await fs.writeFile(dartFile, '// inner package code') + + // Should find the inner package's pubspec.yaml, not root + const root = await DartServer.root(dartFile, tempDir) + expect(root).toBe(innerPkgDir) + } + finally { + await fs.rm(tempDir, { recursive: true, force: true }) + } + }) + + test('root function finds nearest pubspec in deep nesting', async () => { + // Simulates deep directory structure + // root/ + // pubspec.yaml + // src/ + // features/ + // auth/ + // login.dart + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dart-deep-')) + try { + await fs.writeFile(path.join(tempDir, 'pubspec.yaml'), 'name: deep_app\n') + + const deepDir = path.join(tempDir, 'src', 'features', 'auth') + await fs.mkdir(deepDir, { recursive: true }) + const dartFile = path.join(deepDir, 'login.dart') + await fs.writeFile(dartFile, '// login feature') + + const root = await DartServer.root(dartFile, tempDir) + expect(root).toBe(tempDir) + } + finally { + await fs.rm(tempDir, { recursive: true, force: true }) + } + }) }) describe('getServerById', () => { From 49b12aa551eaa153557eff494490b8cc864419e2 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Thu, 18 Dec 2025 02:28:52 +0900 Subject: [PATCH 4/7] refactor(lsp): migrate tests to Bun ecosystem pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move tests from src/__tests__/ to test/ directory - Add test/unit/ for unit tests - Add test/integration/ for integration tests - Add test/fixtures/ for test data - Create Dart project fixture for LSP integration tests - Add DartServer integration tests (serena pattern) - Update package.json with test:unit and test:integration scripts Directory structure follows Bun/Elysia conventions: test/ ├── unit/ # Unit tests ├── integration/ # Integration tests └── fixtures/ # Test data --- packages/lsp/package.json | 4 +- .../fixture/fake-lsp-server.js | 0 .../fixtures/dart-project/lib/helper.dart | 17 +++ .../test/fixtures/dart-project/lib/main.dart | 56 ++++++++ .../test/fixtures/dart-project/pubspec.yaml | 6 + .../test/integration/dart.integration.test.ts | 128 ++++++++++++++++++ .../__tests__ => test/unit}/client.test.ts | 6 +- .../__tests__ => test/unit}/index.test.ts | 2 +- .../__tests__ => test/unit}/server.test.ts | 2 +- 9 files changed, 215 insertions(+), 6 deletions(-) rename packages/lsp/{src/__tests__ => test}/fixture/fake-lsp-server.js (100%) create mode 100644 packages/lsp/test/fixtures/dart-project/lib/helper.dart create mode 100644 packages/lsp/test/fixtures/dart-project/lib/main.dart create mode 100644 packages/lsp/test/fixtures/dart-project/pubspec.yaml create mode 100644 packages/lsp/test/integration/dart.integration.test.ts rename packages/lsp/{src/__tests__ => test/unit}/client.test.ts (96%) rename packages/lsp/{src/__tests__ => test/unit}/index.test.ts (99%) rename packages/lsp/{src/__tests__ => test/unit}/server.test.ts (99%) diff --git a/packages/lsp/package.json b/packages/lsp/package.json index 21574d8..f40927e 100644 --- a/packages/lsp/package.json +++ b/packages/lsp/package.json @@ -25,7 +25,9 @@ ], "scripts": { "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "bun test ./src" + "test": "bun test ./test", + "test:unit": "bun test ./test/unit", + "test:integration": "bun test ./test/integration" }, "dependencies": { "vscode-jsonrpc": "^8.2.1", diff --git a/packages/lsp/src/__tests__/fixture/fake-lsp-server.js b/packages/lsp/test/fixture/fake-lsp-server.js similarity index 100% rename from packages/lsp/src/__tests__/fixture/fake-lsp-server.js rename to packages/lsp/test/fixture/fake-lsp-server.js diff --git a/packages/lsp/test/fixtures/dart-project/lib/helper.dart b/packages/lsp/test/fixtures/dart-project/lib/helper.dart new file mode 100644 index 0000000..65c0986 --- /dev/null +++ b/packages/lsp/test/fixtures/dart-project/lib/helper.dart @@ -0,0 +1,17 @@ +/// Standalone subtract function +int subtract(int a, int b) { + return a - b; +} + +/// Divides two numbers with error handling +double divide(double a, double b) { + if (b == 0) { + throw ArgumentError('Cannot divide by zero'); + } + return a / b; +} + +/// Formats a number as currency +String formatCurrency(double amount, {String symbol = '\$'}) { + return '$symbol${amount.toStringAsFixed(2)}'; +} diff --git a/packages/lsp/test/fixtures/dart-project/lib/main.dart b/packages/lsp/test/fixtures/dart-project/lib/main.dart new file mode 100644 index 0000000..288411b --- /dev/null +++ b/packages/lsp/test/fixtures/dart-project/lib/main.dart @@ -0,0 +1,56 @@ +/// Calculator class for basic arithmetic operations +class Calculator { + /// Adds two numbers + int add(int a, int b) { + final result = a + b; + return result; + } + + /// Subtracts two numbers + int subtract(int a, int b) { + return a - b; + } + + /// Multiplies two numbers + int multiply(int a, int b) { + return a * b; + } +} + +/// Helper class with static methods +class MathHelper { + /// Calculates power + static double power(double base, int exponent) { + double result = 1; + for (int i = 0; i < exponent; i++) { + result *= base; + } + return result; + } + + /// Calculates absolute value + static double abs(double value) { + return value < 0 ? -value : value; + } +} + +/// Main entry point +void main() { + final calc = Calculator(); + + // Test arithmetic operations + final sum = calc.add(5, 3); + final diff = calc.subtract(10, 4); + final product = calc.multiply(6, 7); + + print('Sum: $sum'); + print('Difference: $diff'); + print('Product: $product'); + + // Test helper methods + final squared = MathHelper.power(2.0, 3); + final absolute = MathHelper.abs(-42.5); + + print('2^3 = $squared'); + print('|-42.5| = $absolute'); +} diff --git a/packages/lsp/test/fixtures/dart-project/pubspec.yaml b/packages/lsp/test/fixtures/dart-project/pubspec.yaml new file mode 100644 index 0000000..08857bf --- /dev/null +++ b/packages/lsp/test/fixtures/dart-project/pubspec.yaml @@ -0,0 +1,6 @@ +name: test_dart_app +description: Test Dart project for LSP integration tests +version: 1.0.0 + +environment: + sdk: ^3.0.0 diff --git a/packages/lsp/test/integration/dart.integration.test.ts b/packages/lsp/test/integration/dart.integration.test.ts new file mode 100644 index 0000000..4cc6ec0 --- /dev/null +++ b/packages/lsp/test/integration/dart.integration.test.ts @@ -0,0 +1,128 @@ +/** + * Dart Language Server Integration Tests + * + * These tests spawn an actual Dart language server and verify LSP functionality. + * Requires Dart SDK to be installed or will auto-download. + * + * Based on serena test patterns: ref/serena/test/solidlsp/dart/test_dart_basic.py + */ + +import path from 'node:path' +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { LSPManager } from '../../src/index' + +const DART_PROJECT_PATH = path.join(import.meta.dir, '../fixtures/dart-project') +const MAIN_DART_PATH = path.join(DART_PROJECT_PATH, 'lib/main.dart') +const HELPER_DART_PATH = path.join(DART_PROJECT_PATH, 'lib/helper.dart') + +// Check if Dart is available +const isDartAvailable = Bun.which('dart') !== null + +describe.skipIf(!isDartAvailable)('DartServer Integration', () => { + let manager: LSPManager + + beforeAll(async () => { + manager = new LSPManager(DART_PROJECT_PATH) + // Touch the main file to initialize LSP and wait for diagnostics + await manager.touchFile(MAIN_DART_PATH, true) + }, 60000) // 60s timeout for server startup + + afterAll(async () => { + await manager.shutdown() + }) + + test('LSP server starts and connects', async () => { + const status = await manager.status() + expect(status.length).toBeGreaterThan(0) + + const dartStatus = status.find(s => s.id === 'dart') + expect(dartStatus).toBeDefined() + expect(dartStatus?.status).toBe('connected') + }) + + test('returns diagnostics for Dart file', async () => { + // The test file is valid, so we expect no errors + const diagnostics = await manager.diagnostics() + const mainDartDiags = diagnostics[MAIN_DART_PATH] || [] + + // Valid file should have no errors (might have warnings/hints) + const errors = mainDartDiags.filter(d => d.severity === 1) + expect(errors.length).toBe(0) + }) + + test('provides hover information', async () => { + // Hover over 'Calculator' class name (line 1, char 6) + const hovers = await manager.hover({ + file: MAIN_DART_PATH, + line: 1, // class Calculator { + character: 6, + }) + + expect(hovers.length).toBeGreaterThan(0) + // At least one hover response should be non-null + const validHovers = hovers.filter(h => h !== null) + expect(validHovers.length).toBeGreaterThan(0) + }) + + test('finds workspace symbols', async () => { + const symbols = await manager.workspaceSymbol('Calculator') + + expect(symbols.length).toBeGreaterThan(0) + + // Should find the Calculator class + const calculatorSymbol = symbols.find(s => s.name === 'Calculator') + expect(calculatorSymbol).toBeDefined() + }) + + test('finds document symbols', async () => { + const uri = `file://${MAIN_DART_PATH}` + const symbols = await manager.documentSymbol(uri) + + expect(symbols.length).toBeGreaterThan(0) + + // Should find classes and methods + const symbolNames = symbols.map(s => s.name) + expect(symbolNames).toContain('Calculator') + }) + + test('touches multiple Dart files', async () => { + // Touch helper file + await manager.touchFile(HELPER_DART_PATH, true) + + const diagnostics = await manager.diagnostics() + const helperDiags = diagnostics[HELPER_DART_PATH] || [] + + // Valid file should have no errors + const errors = helperDiags.filter(d => d.severity === 1) + expect(errors.length).toBe(0) + }) + + test('finds symbols across files', async () => { + // Search for subtract function defined in helper.dart + const symbols = await manager.workspaceSymbol('subtract') + + // Should find it in helper.dart + expect(symbols.length).toBeGreaterThan(0) + const subtractSymbol = symbols.find(s => s.name === 'subtract') + expect(subtractSymbol).toBeDefined() + }) +}) + +// Separate describe block for tests that don't require Dart +describe('DartServer Integration (no Dart required)', () => { + test('fixture files exist', async () => { + const mainDart = Bun.file(MAIN_DART_PATH) + expect(await mainDart.exists()).toBe(true) + + const helperDart = Bun.file(HELPER_DART_PATH) + expect(await helperDart.exists()).toBe(true) + + const pubspec = Bun.file(path.join(DART_PROJECT_PATH, 'pubspec.yaml')) + expect(await pubspec.exists()).toBe(true) + }) + + test('manager creates without Dart installed', () => { + const manager = new LSPManager(DART_PROJECT_PATH) + expect(manager).toBeDefined() + }) +}) diff --git a/packages/lsp/src/__tests__/client.test.ts b/packages/lsp/test/unit/client.test.ts similarity index 96% rename from packages/lsp/src/__tests__/client.test.ts rename to packages/lsp/test/unit/client.test.ts index dff7494..2434729 100644 --- a/packages/lsp/src/__tests__/client.test.ts +++ b/packages/lsp/test/unit/client.test.ts @@ -1,11 +1,11 @@ -import type { LSPClientInfo } from '../client' +import type { LSPClientInfo } from '../../src/client' import { spawn } from 'node:child_process' import path from 'node:path' import { afterEach, describe, expect, test } from 'bun:test' -import { createLSPClient } from '../client' +import { createLSPClient } from '../../src/client' function spawnFakeServer() { - const serverPath = path.join(__dirname, 'fixture/fake-lsp-server.js') + const serverPath = path.join(import.meta.dir, '../fixture/fake-lsp-server.js') return { process: spawn(process.execPath, [serverPath], { stdio: 'pipe', diff --git a/packages/lsp/src/__tests__/index.test.ts b/packages/lsp/test/unit/index.test.ts similarity index 99% rename from packages/lsp/src/__tests__/index.test.ts rename to packages/lsp/test/unit/index.test.ts index 18c5b28..ecd4d65 100644 --- a/packages/lsp/src/__tests__/index.test.ts +++ b/packages/lsp/test/unit/index.test.ts @@ -6,7 +6,7 @@ import { LANGUAGE_EXTENSIONS, LSPManager, SymbolKind, -} from '../index' +} from '../../src/index' describe('LSPManager', () => { test('creates manager with project path', () => { diff --git a/packages/lsp/src/__tests__/server.test.ts b/packages/lsp/test/unit/server.test.ts similarity index 99% rename from packages/lsp/src/__tests__/server.test.ts rename to packages/lsp/test/unit/server.test.ts index 49fedb3..c29e99c 100644 --- a/packages/lsp/src/__tests__/server.test.ts +++ b/packages/lsp/test/unit/server.test.ts @@ -14,7 +14,7 @@ import { PyrightServer, RustAnalyzerServer, TypescriptServer, -} from '../server' +} from '../../src/server' describe('LSP_SERVERS', () => { test('contains expected servers', () => { From c3a21c0696f1bfa4a99965fdcf7abedc79c32fb3 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Thu, 18 Dec 2025 02:30:03 +0900 Subject: [PATCH 5/7] ci: fix test command for new directory structure - Use `bun run test` which runs turbo for all packages - Generate coverage from packages/lsp separately - Update coverage file path for Codecov --- .github/workflows/ci.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7162d2f..1ff89af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,12 +49,17 @@ jobs: - name: Install dependencies run: bun install - - name: Test with coverage - run: bun test --coverage --coverage-reporter=lcov + - name: Run tests + run: bun run test + + - name: Run tests with coverage + run: | + cd packages/lsp && bun test --coverage --coverage-reporter=lcov + continue-on-error: true - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} - files: ./coverage/lcov.info + files: ./packages/lsp/coverage/lcov.info fail_ci_if_error: false From 0b3c3150c1069cec0a10ab37301bd9ea706ad1cd Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Thu, 18 Dec 2025 02:34:06 +0900 Subject: [PATCH 6/7] ci: add Codecov components for monorepo packages - Create codecov.yml with component configuration - Define components for each package: code, code-format, code-lsp, dora - Generate coverage reports for all packages with tests - Upload multiple coverage files to Codecov --- .github/workflows/ci.yml | 9 +++++++-- codecov.yml | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 codecov.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ff89af..f33d89f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,14 +52,19 @@ jobs: - name: Run tests run: bun run test - - name: Run tests with coverage + - name: Generate coverage reports run: | cd packages/lsp && bun test --coverage --coverage-reporter=lcov + cd ../code && bun test --coverage --coverage-reporter=lcov || true + cd ../dora && bun test --coverage --coverage-reporter=lcov || true continue-on-error: true - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} - files: ./packages/lsp/coverage/lcov.info + files: | + ./packages/lsp/coverage/lcov.info + ./packages/code/coverage/lcov.info + ./packages/dora/coverage/lcov.info fail_ci_if_error: false diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..31c7fdc --- /dev/null +++ b/codecov.yml @@ -0,0 +1,36 @@ +coverage: + status: + project: + default: + target: auto + threshold: 1% + patch: + default: + target: auto + +component_management: + default_rules: + statuses: + - type: project + target: auto + + individual_components: + - component_id: code + name: "@pleaseai/code" + paths: + - packages/code/src/** + + - component_id: code-format + name: "@pleaseai/code-format" + paths: + - packages/format/src/** + + - component_id: code-lsp + name: "@pleaseai/code-lsp" + paths: + - packages/lsp/src/** + + - component_id: dora + name: "@pleaseai/dora" + paths: + - packages/dora/src/** From b0557aed38f82d4be11b2718ad69de0c17aa6496 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Thu, 18 Dec 2025 02:37:45 +0900 Subject: [PATCH 7/7] fix: use single quotes in codecov.yml --- codecov.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/codecov.yml b/codecov.yml index 31c7fdc..70a5163 100644 --- a/codecov.yml +++ b/codecov.yml @@ -16,21 +16,21 @@ component_management: individual_components: - component_id: code - name: "@pleaseai/code" + name: '@pleaseai/code' paths: - packages/code/src/** - component_id: code-format - name: "@pleaseai/code-format" + name: '@pleaseai/code-format' paths: - packages/format/src/** - component_id: code-lsp - name: "@pleaseai/code-lsp" + name: '@pleaseai/code-lsp' paths: - packages/lsp/src/** - component_id: dora - name: "@pleaseai/dora" + name: '@pleaseai/dora' paths: - packages/dora/src/**