Add TypeScript LSP setup guide and enhance debugging tests

- Created a comprehensive guide for setting up TypeScript LSP in Claude Code, detailing installation methods, configuration, and troubleshooting.
- Added multiple debugging test scripts to validate LSP communication with pyright, including direct communication tests, configuration checks, and document symbol retrieval.
- Implemented error handling and logging for better visibility during LSP interactions.
This commit is contained in:
catlog22
2026-01-20 14:53:18 +08:00
parent 2f3a14e946
commit 9c9b1ad01c
8 changed files with 1432 additions and 115 deletions

View File

@@ -0,0 +1,366 @@
# Claude Code TypeScript LSP 配置指南
> 更新日期: 2026-01-20
> 适用版本: Claude Code v2.0.74+
---
## 目录
1. [方式一:插件市场(推荐)](#方式一插件市场推荐)
2. [方式二MCP Server (cclsp)](#方式二mcp-server-cclsp)
3. [方式三内置LSP工具](#方式三内置lsp工具)
4. [配置验证](#配置验证)
5. [故障排查](#故障排查)
---
## 方式一:插件市场(推荐)
### 步骤 1: 添加插件市场
在Claude Code中执行
```bash
/plugin marketplace add boostvolt/claude-code-lsps
```
### 步骤 2: 安装TypeScript LSP插件
```bash
# TypeScript/JavaScript支持推荐vtsls
/plugin install vtsls@claude-code-lsps
```
### 步骤 3: 验证安装
```bash
/plugin list
```
应该看到:
```
✓ vtsls@claude-code-lsps (enabled)
✓ pyright-lsp@claude-plugins-official (enabled)
```
### 配置文件自动更新
安装后,`~/.claude/settings.json` 会自动添加:
```json
{
"enabledPlugins": {
"pyright-lsp@claude-plugins-official": true,
"vtsls@claude-code-lsps": true
}
}
```
### 支持的操作
- `goToDefinition` - 跳转到定义
- `findReferences` - 查找引用
- `hover` - 显示类型信息
- `documentSymbol` - 文档符号
- `getDiagnostics` - 诊断信息
---
## 方式二MCP Server (cclsp)
### 优势
- **位置容错**自动修正AI生成的不精确行号
- **更多功能**:支持重命名、完整诊断
- **灵活配置**完全自定义LSP服务器
### 安装步骤
#### 1. 安装TypeScript Language Server
```bash
npm install -g typescript-language-server typescript
```
验证安装:
```bash
typescript-language-server --version
```
#### 2. 配置cclsp
运行自动配置:
```bash
npx cclsp@latest setup --user
```
或手动创建配置文件:
**文件位置**: `~/.claude/cclsp.json``~/.config/claude/cclsp.json`
```json
{
"servers": [
{
"extensions": ["ts", "tsx", "js", "jsx"],
"command": ["typescript-language-server", "--stdio"],
"rootDir": ".",
"restartInterval": 5,
"initializationOptions": {
"preferences": {
"includeInlayParameterNameHints": "all",
"includeInlayPropertyDeclarationTypeHints": true,
"includeInlayFunctionParameterTypeHints": true,
"includeInlayVariableTypeHints": true
}
}
},
{
"extensions": ["py", "pyi"],
"command": ["pylsp"],
"rootDir": ".",
"restartInterval": 5
}
]
}
```
#### 3. 在Claude Code中启用MCP Server
添加到Claude Code配置
```bash
# 查看当前MCP配置
cat ~/.claude/.mcp.json
# 如果没有,创建新的
```
**文件**: `~/.claude/.mcp.json`
```json
{
"mcpServers": {
"cclsp": {
"command": "npx",
"args": ["cclsp@latest"]
}
}
}
```
### cclsp可用的MCP工具
使用时Claude Code会自动调用这些工具
- `find_definition` - 按名称查找定义(支持模糊匹配)
- `find_references` - 查找所有引用
- `rename_symbol` - 重命名符号(带备份)
- `get_diagnostics` - 获取诊断信息
- `restart_server` - 重启LSP服务器
---
## 方式三内置LSP工具
### 启用方式
设置环境变量:
**Linux/Mac**:
```bash
export ENABLE_LSP_TOOL=1
claude
```
**Windows (PowerShell)**:
```powershell
$env:ENABLE_LSP_TOOL=1
claude
```
**永久启用** (添加到shell配置):
```bash
# Linux/Mac
echo 'export ENABLE_LSP_TOOL=1' >> ~/.bashrc
source ~/.bashrc
# Windows (PowerShell Profile)
Add-Content $PROFILE '$env:ENABLE_LSP_TOOL=1'
```
### 限制
- 需要先安装语言服务器插件(见方式一)
- 不支持重命名等高级操作
- 无位置容错功能
---
## 配置验证
### 1. 检查LSP服务器是否可用
```bash
# 检查TypeScript Language Server
which typescript-language-server # Linux/Mac
where typescript-language-server # Windows
# 测试运行
typescript-language-server --stdio
```
### 2. 在Claude Code中测试
打开任意TypeScript文件让Claude执行
```typescript
// 测试LSP功能
LSP({
operation: "hover",
filePath: "path/to/your/file.ts",
line: 10,
character: 5
})
```
### 3. 检查插件状态
```bash
/plugin list
```
查看启用的插件:
```bash
cat ~/.claude/settings.json | grep enabledPlugins
```
---
## 故障排查
### 问题 1: "No LSP server available"
**原因**TypeScript LSP插件未安装或未启用
**解决**
```bash
# 重新安装插件
/plugin install vtsls@claude-code-lsps
# 检查settings.json
cat ~/.claude/settings.json
```
### 问题 2: "typescript-language-server: command not found"
**原因**未安装TypeScript Language Server
**解决**
```bash
npm install -g typescript-language-server typescript
# 验证
typescript-language-server --version
```
### 问题 3: LSP响应慢或超时
**原因**:项目太大或配置不当
**解决**
```json
// 在tsconfig.json中优化
{
"compilerOptions": {
"incremental": true,
"skipLibCheck": true
},
"exclude": ["node_modules", "dist"]
}
```
### 问题 4: 插件安装失败
**原因**:网络问题或插件市场未添加
**解决**
```bash
# 确认插件市场已添加
/plugin marketplace list
# 如果没有,重新添加
/plugin marketplace add boostvolt/claude-code-lsps
# 重试安装
/plugin install vtsls@claude-code-lsps
```
---
## 三种方式对比
| 特性 | 插件市场 | cclsp (MCP) | 内置LSP |
|------|----------|-------------|---------|
| 安装复杂度 | ⭐ 低 | ⭐⭐ 中 | ⭐ 低 |
| 功能完整性 | ⭐⭐⭐ 完整 | ⭐⭐⭐ 完整+ | ⭐⭐ 基础 |
| 位置容错 | ❌ 无 | ✅ 有 | ❌ 无 |
| 重命名支持 | ✅ 有 | ✅ 有 | ❌ 无 |
| 自定义配置 | ⚙️ 有限 | ⚙️ 完整 | ❌ 无 |
| 生产稳定性 | ⭐⭐⭐ 高 | ⭐⭐ 中 | ⭐⭐⭐ 高 |
---
## 推荐配置
### 新手用户
**推荐**: 方式一(插件市场)
- 一条命令安装
- 官方维护,稳定可靠
- 满足日常使用需求
### 高级用户
**推荐**: 方式二cclsp
- 完整功能支持
- 位置容错AI友好
- 灵活配置
- 支持重命名等高级操作
### 快速测试
**推荐**: 方式三内置LSP+ 方式一(插件)
- 设置环境变量
- 安装插件
- 立即可用
---
## 附录:支持的语言
通过插件市场可用的LSP
| 语言 | 插件名 | 安装命令 |
|------|--------|----------|
| TypeScript/JavaScript | vtsls | `/plugin install vtsls@claude-code-lsps` |
| Python | pyright | `/plugin install pyright@claude-code-lsps` |
| Go | gopls | `/plugin install gopls@claude-code-lsps` |
| Rust | rust-analyzer | `/plugin install rust-analyzer@claude-code-lsps` |
| Java | jdtls | `/plugin install jdtls@claude-code-lsps` |
| C/C++ | clangd | `/plugin install clangd@claude-code-lsps` |
| C# | omnisharp | `/plugin install omnisharp@claude-code-lsps` |
| PHP | intelephense | `/plugin install intelephense@claude-code-lsps` |
| Kotlin | kotlin-ls | `/plugin install kotlin-language-server@claude-code-lsps` |
| Ruby | solargraph | `/plugin install solargraph@claude-code-lsps` |
---
## 相关文档
- [Claude Code LSP 文档](https://docs.anthropic.com/claude-code/lsp)
- [cclsp GitHub](https://github.com/ktnyt/cclsp)
- [TypeScript Language Server](https://github.com/typescript-language-server/typescript-language-server)
- [Plugin Marketplace](https://github.com/boostvolt/claude-code-lsps)
---
**配置完成后重启Claude Code以应用更改**

View File

@@ -34,11 +34,56 @@ async function syncActiveExecutions() {
const { executions } = await response.json();
if (!executions || executions.length === 0) return;
executions.forEach(exec => {
// Skip if already tracked (avoid overwriting live data)
if (cliStreamExecutions[exec.id]) return;
let needsUiUpdate = false;
// Rebuild execution state
executions.forEach(exec => {
const existing = cliStreamExecutions[exec.id];
// Parse historical output from server
const historicalLines = [];
if (exec.output) {
const lines = exec.output.split('\n');
const startIndex = Math.max(0, lines.length - MAX_OUTPUT_LINES + 1);
lines.slice(startIndex).forEach(line => {
if (line.trim()) {
historicalLines.push({
type: 'stdout',
content: line,
timestamp: exec.startTime || Date.now()
});
}
});
}
if (existing) {
// Already tracked by WebSocket events - merge historical output
// Only prepend historical lines that are not already in the output
// (WebSocket events only add NEW output, so historical output should come before)
const existingContentSet = new Set(existing.output.map(o => o.content));
const missingLines = historicalLines.filter(h => !existingContentSet.has(h.content));
if (missingLines.length > 0) {
// Find the system start message index (skip it when prepending)
const systemMsgIndex = existing.output.findIndex(o => o.type === 'system');
const insertIndex = systemMsgIndex >= 0 ? systemMsgIndex + 1 : 0;
// Prepend missing historical lines after system message
existing.output.splice(insertIndex, 0, ...missingLines);
// Trim if too long
if (existing.output.length > MAX_OUTPUT_LINES) {
existing.output = existing.output.slice(-MAX_OUTPUT_LINES);
}
needsUiUpdate = true;
console.log(`[CLI Stream] Merged ${missingLines.length} historical lines for ${exec.id}`);
}
return;
}
needsUiUpdate = true;
// New execution - rebuild full state
cliStreamExecutions[exec.id] = {
tool: exec.tool || 'cli',
mode: exec.mode || 'analysis',
@@ -55,24 +100,12 @@ async function syncActiveExecutions() {
timestamp: exec.startTime
});
// Fill historical output (limit to last MAX_OUTPUT_LINES)
if (exec.output) {
const lines = exec.output.split('\n');
const startIndex = Math.max(0, lines.length - MAX_OUTPUT_LINES + 1);
lines.slice(startIndex).forEach(line => {
if (line.trim()) {
cliStreamExecutions[exec.id].output.push({
type: 'stdout',
content: line,
timestamp: Date.now()
});
}
});
}
// Add historical output
cliStreamExecutions[exec.id].output.push(...historicalLines);
});
// Update UI if we recovered any executions
if (executions.length > 0) {
// Update UI if we recovered or merged any executions
if (needsUiUpdate) {
// Set active tab to first running execution
const runningExec = executions.find(e => e.status === 'running');
if (runningExec && !activeStreamTab) {

View File

@@ -119,6 +119,7 @@ export class CliHistoryStore {
this.db = new Database(this.dbPath);
this.db.pragma('journal_mode = WAL');
this.db.pragma('synchronous = NORMAL');
this.db.pragma('busy_timeout = 5000'); // Wait up to 5 seconds for locks
this.initSchema();
this.migrateFromJson(historyDir);
@@ -365,6 +366,41 @@ export class CliHistoryStore {
}
}
/**
* Execute a database operation with retry logic for SQLITE_BUSY errors
* @param operation - Function to execute
* @param maxRetries - Maximum retry attempts (default: 3)
* @param baseDelay - Base delay in ms for exponential backoff (default: 100)
*/
private withRetry<T>(operation: () => T, maxRetries = 3, baseDelay = 100): T {
let lastError: Error | null = null;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return operation();
} catch (err) {
const error = err as Error;
// Check if it's a SQLITE_BUSY error
if (error.message?.includes('SQLITE_BUSY') || error.message?.includes('database is locked')) {
lastError = error;
if (attempt < maxRetries) {
// Exponential backoff: 100ms, 200ms, 400ms
const delay = baseDelay * Math.pow(2, attempt);
// Sync sleep using Atomics (works in Node.js)
const sharedBuffer = new SharedArrayBuffer(4);
const sharedArray = new Int32Array(sharedBuffer);
Atomics.wait(sharedArray, 0, 0, delay);
}
} else {
// Non-BUSY error, throw immediately
throw error;
}
}
}
throw lastError || new Error('Operation failed after retries');
}
/**
* Migrate existing JSON files to SQLite
*/
@@ -522,7 +558,7 @@ export class CliHistoryStore {
}
});
transaction();
this.withRetry(() => transaction());
}
/**
@@ -795,7 +831,9 @@ export class CliHistoryStore {
*/
deleteConversation(id: string): { success: boolean; error?: string } {
try {
const result = this.db.prepare('DELETE FROM conversations WHERE id = ?').run(id);
const result = this.withRetry(() =>
this.db.prepare('DELETE FROM conversations WHERE id = ?').run(id)
);
return { success: result.changes > 0 };
} catch (err) {
return { success: false, error: (err as Error).message };
@@ -821,7 +859,7 @@ export class CliHistoryStore {
}
});
transaction();
this.withRetry(() => transaction());
return {
success: true,
@@ -896,14 +934,14 @@ export class CliHistoryStore {
project_hash = @project_hash
`);
stmt.run({
this.withRetry(() => stmt.run({
ccw_id: mapping.ccw_id,
tool: mapping.tool,
native_session_id: mapping.native_session_id,
native_session_path: mapping.native_session_path || null,
project_hash: mapping.project_hash || null,
created_at: mapping.created_at || new Date().toISOString()
});
}));
}
/**
@@ -1147,7 +1185,7 @@ export class CliHistoryStore {
VALUES (@id, @created_at, @tool, @prompt_count, @patterns, @suggestions, @raw_output, @execution_id, @lang)
`);
stmt.run({
this.withRetry(() => stmt.run({
id: insight.id,
created_at: new Date().toISOString(),
tool: insight.tool,
@@ -1157,7 +1195,7 @@ export class CliHistoryStore {
raw_output: insight.rawOutput || null,
execution_id: insight.executionId || null,
lang: insight.lang || 'en'
});
}));
}
/**
@@ -1249,7 +1287,7 @@ export class CliHistoryStore {
updated_at = @updated_at
`);
const result = stmt.run({
const result = this.withRetry(() => stmt.run({
execution_id: review.execution_id,
status: review.status,
rating: review.rating ?? null,
@@ -1257,7 +1295,7 @@ export class CliHistoryStore {
reviewer: review.reviewer ?? null,
created_at,
updated_at
});
}));
return {
id: result.lastInsertRowid as number,

View File

@@ -42,10 +42,10 @@ class ServerConfig:
max_restarts: int = 3
@dataclass
@dataclass
class ServerState:
"""State of a running language server."""
config: ServerConfig
process: asyncio.subprocess.Process
reader: asyncio.StreamReader
@@ -55,6 +55,8 @@ class ServerState:
capabilities: Dict[str, Any] = field(default_factory=dict)
pending_requests: Dict[int, asyncio.Future] = field(default_factory=dict)
restart_count: int = 0
# Queue for producer-consumer pattern - continuous reading puts messages here
message_queue: asyncio.Queue = field(default_factory=asyncio.Queue)
class StandaloneLspManager:
@@ -253,20 +255,24 @@ class StandaloneLspManager:
self._servers[language_id] = state
# Start reading responses in background
self._read_tasks[language_id] = asyncio.create_task(
self._read_responses(language_id)
)
# Start reading stderr in background (prevents pipe buffer from filling up)
if process.stderr:
self._stderr_tasks[language_id] = asyncio.create_task(
self._read_stderr(language_id, process.stderr)
)
# Initialize the server
# CRITICAL: Start the continuous reader task IMMEDIATELY before any communication
# This ensures no messages are lost during initialization handshake
self._read_tasks[language_id] = asyncio.create_task(
self._continuous_reader(language_id)
)
# Start the message processor task to handle queued messages
asyncio.create_task(self._process_messages(language_id))
# Initialize the server - now uses queue for reading responses
await self._initialize_server(state)
logger.info(f"{config.display_name} started and initialized")
return state
@@ -353,49 +359,25 @@ class StandaloneLspManager:
return await self._start_server(language_id)
async def _initialize_server(self, state: ServerState) -> None:
"""Send initialize request to language server."""
"""Send initialize request and wait for response via the message queue.
The continuous reader and message processor are already running, so we just
send the request and wait for the response via pending_requests.
"""
root_uri = self.workspace_root.as_uri()
# Simplified params matching direct test that works
params = {
"processId": os.getpid(),
"processId": None, # Use None like direct test
"rootUri": root_uri,
"rootPath": str(self.workspace_root),
"capabilities": {
"textDocument": {
"synchronization": {
"dynamicRegistration": False,
"willSave": False,
"willSaveWaitUntil": False,
"didSave": True,
},
"completion": {
"dynamicRegistration": False,
"completionItem": {
"snippetSupport": False,
"documentationFormat": ["plaintext", "markdown"],
},
},
"hover": {
"dynamicRegistration": False,
"contentFormat": ["plaintext", "markdown"],
},
"definition": {
"dynamicRegistration": False,
"linkSupport": False,
},
"references": {
"dynamicRegistration": False,
},
"documentSymbol": {
"dynamicRegistration": False,
"hierarchicalDocumentSymbolSupport": True,
},
"callHierarchy": {
"dynamicRegistration": False,
},
},
"workspace": {
"workspaceFolders": True,
"configuration": True,
},
},
@@ -405,17 +387,49 @@ class StandaloneLspManager:
"name": self.workspace_root.name,
}
],
"initializationOptions": state.config.initialization_options,
}
result = await self._send_request(state, "initialize", params)
if result:
state.capabilities = result.get("capabilities", {})
state.initialized = True
# Send initialize request and wait for response via queue
state.request_id += 1
init_request_id = state.request_id
# Send initialized notification
await self._send_notification(state, "initialized", {})
# Create future for the response
future: asyncio.Future = asyncio.get_event_loop().create_future()
state.pending_requests[init_request_id] = future
# Send the request
init_message = {
"jsonrpc": "2.0",
"id": init_request_id,
"method": "initialize",
"params": params,
}
encoded = self._encode_message(init_message)
logger.debug(f"Sending initialize request id={init_request_id}")
state.writer.write(encoded)
await state.writer.drain()
# Wait for response (will be routed by _process_messages)
try:
init_result = await asyncio.wait_for(future, timeout=30.0)
except asyncio.TimeoutError:
state.pending_requests.pop(init_request_id, None)
raise RuntimeError("Initialize request timed out")
if init_result is None:
init_result = {}
# Store capabilities
state.capabilities = init_result.get("capabilities", {})
state.initialized = True
logger.debug(f"Initialize response received, capabilities: {len(state.capabilities)} keys")
# Send initialized notification
await self._send_notification(state, "initialized", {})
# Give time for server to process initialized and send any requests
# The message processor will handle workspace/configuration automatically
await asyncio.sleep(0.5)
def _encode_message(self, content: Dict[str, Any]) -> bytes:
"""Encode a JSON-RPC message with LSP headers."""
@@ -465,63 +479,121 @@ class StandaloneLspManager:
except Exception as e:
logger.error(f"Error reading message: {e}")
return None, True
async def _read_responses(self, language_id: str) -> None:
"""Background task to read responses from a language server."""
async def _continuous_reader(self, language_id: str) -> None:
"""Continuously read messages from language server and put them in the queue.
This is the PRODUCER in the producer-consumer pattern. It starts IMMEDIATELY
after subprocess creation and runs continuously until shutdown. This ensures
no messages are ever lost, even during initialization handshake.
"""
state = self._servers.get(language_id)
if not state:
return
logger.debug(f"Continuous reader started for {language_id}")
try:
while True:
# Yield to allow other tasks to run
await asyncio.sleep(0)
try:
# Read headers with timeout
content_length = 0
while True:
try:
line = await asyncio.wait_for(state.reader.readline(), timeout=5.0)
except asyncio.TimeoutError:
continue # Keep waiting for data
message, stream_closed = await self._read_message(state.reader)
if not line:
logger.debug(f"Continuous reader for {language_id}: EOF")
return
if stream_closed:
logger.debug(f"Read loop for {language_id}: stream closed")
break
line_str = line.decode("ascii").strip()
if not line_str:
break # End of headers
if message is None:
# Just a timeout, continue waiting
logger.debug(f"Read loop for {language_id}: timeout, continuing...")
continue
if line_str.lower().startswith("content-length:"):
content_length = int(line_str.split(":")[1].strip())
# Log all incoming messages for debugging
msg_id = message.get("id", "none")
msg_method = message.get("method", "none")
logger.debug(f"Received message: id={msg_id}, method={msg_method}")
if content_length == 0:
continue
# Handle response (has id but no method)
if "id" in message and "method" not in message:
request_id = message["id"]
logger.debug(f"Received response id={request_id}, pending={list(state.pending_requests.keys())}")
if request_id in state.pending_requests:
future = state.pending_requests.pop(request_id)
# Read body
body = await state.reader.readexactly(content_length)
message = json.loads(body.decode("utf-8"))
# Put message in queue for processing
await state.message_queue.put(message)
msg_id = message.get("id", "none")
msg_method = message.get("method", "none")
logger.debug(f"Queued message: id={msg_id}, method={msg_method}")
except asyncio.IncompleteReadError:
logger.debug(f"Continuous reader for {language_id}: IncompleteReadError")
return
except Exception as e:
logger.error(f"Error in continuous reader for {language_id}: {e}")
await asyncio.sleep(0.1)
except asyncio.CancelledError:
logger.debug(f"Continuous reader cancelled for {language_id}")
except Exception as e:
logger.error(f"Fatal error in continuous reader for {language_id}: {e}")
async def _process_messages(self, language_id: str) -> None:
"""Process messages from the queue and route them appropriately.
This is the CONSUMER in the producer-consumer pattern. It handles:
- Server requests (workspace/configuration, etc.) - responds immediately
- Notifications (window/logMessage, etc.) - logs them
- Responses to our requests are NOT handled here - they're consumed by _wait_for_response
"""
state = self._servers.get(language_id)
if not state:
return
logger.debug(f"Message processor started for {language_id}")
try:
while True:
# Get message from queue (blocks until available)
message = await state.message_queue.get()
msg_id = message.get("id")
method = message.get("method", "")
# Response (has id but no method) - put back for _wait_for_response to consume
if msg_id is not None and not method:
# This is a response to one of our requests
if msg_id in state.pending_requests:
future = state.pending_requests.pop(msg_id)
if "error" in message:
future.set_exception(
Exception(message["error"].get("message", "Unknown error"))
)
else:
future.set_result(message.get("result"))
logger.debug(f"Response routed to pending request id={msg_id}")
else:
logger.debug(f"No pending request for id={request_id}")
logger.debug(f"No pending request for response id={msg_id}")
# Handle server request (has both id and method) - needs response
elif "id" in message and "method" in message:
logger.info(f"Server request received: {message.get('method')} with id={message.get('id')}")
# Server request (has both id and method) - needs response
elif msg_id is not None and method:
logger.info(f"Server request: {method} (id={msg_id})")
await self._handle_server_request(state, message)
# Handle notification from server (has method but no id)
elif "method" in message:
# Notification (has method but no id)
elif method:
self._handle_server_message(language_id, message)
except asyncio.CancelledError:
pass
except Exception as e:
logger.error(f"Error in read loop for {language_id}: {e}")
state.message_queue.task_done()
except asyncio.CancelledError:
logger.debug(f"Message processor cancelled for {language_id}")
except Exception as e:
logger.error(f"Error in message processor for {language_id}: {e}")
async def _read_stderr(self, language_id: str, stderr: asyncio.StreamReader) -> None:
"""Background task to read stderr from a language server.
@@ -732,9 +804,9 @@ class StandaloneLspManager:
}
})
# Give the language server time to process the file and send any requests
# The read loop running in background will handle workspace/configuration requests
await asyncio.sleep(2.0)
# Give the language server a brief moment to process the file
# The message queue handles any server requests automatically
await asyncio.sleep(0.5)
# ========== Public LSP Methods ==========

View File

@@ -0,0 +1,149 @@
#!/usr/bin/env python
"""Compare manager read behavior vs direct read."""
import asyncio
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
from codexlens.lsp.standalone_manager import StandaloneLspManager
async def direct_test():
"""Direct communication - this works."""
workspace = Path(__file__).parent.parent.parent
print("\n=== DIRECT TEST ===")
process = await asyncio.create_subprocess_exec(
"pyright-langserver", "--stdio",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=str(workspace),
)
def encode_message(content):
body = json.dumps(content).encode("utf-8")
header = f"Content-Length: {len(body)}\r\n\r\n"
return header.encode("ascii") + body
async def send(message):
encoded = encode_message(message)
process.stdin.write(encoded)
await process.stdin.drain()
msg_desc = message.get('method') or f"response id={message.get('id')}"
print(f" SENT: {msg_desc}")
async def read_one():
content_length = 0
while True:
line = await asyncio.wait_for(process.stdout.readline(), timeout=3.0)
if not line:
return None
line_str = line.decode("ascii").strip()
if not line_str:
break
if line_str.lower().startswith("content-length:"):
content_length = int(line_str.split(":")[1].strip())
if content_length == 0:
return None
body = await process.stdout.readexactly(content_length)
return json.loads(body.decode("utf-8"))
# Initialize
print(" Sending initialize...")
await send({
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {
"processId": None,
"rootUri": workspace.as_uri(),
"capabilities": {"workspace": {"configuration": True}},
"workspaceFolders": [{"uri": workspace.as_uri(), "name": workspace.name}],
},
})
# Read until response
while True:
msg = await read_one()
if msg and msg.get("id") == 1:
print(f" Initialize response OK")
break
elif msg:
print(f" Notification: {msg.get('method')}")
# Send initialized
print(" Sending initialized...")
await send({"jsonrpc": "2.0", "method": "initialized", "params": {}})
# Check for workspace/configuration
print(" Checking for workspace/configuration (3s timeout)...")
try:
for i in range(10):
msg = await read_one()
if msg:
method = msg.get("method")
msg_id = msg.get("id")
print(f" RECV: {method or 'response'} (id={msg_id})")
if method == "workspace/configuration":
print(" SUCCESS: workspace/configuration received!")
break
except asyncio.TimeoutError:
print(" TIMEOUT: No more messages")
process.terminate()
await process.wait()
async def manager_test():
"""Manager communication - investigating why this doesn't work."""
workspace = Path(__file__).parent.parent.parent
print("\n=== MANAGER TEST ===")
manager = StandaloneLspManager(
workspace_root=str(workspace),
timeout=60.0
)
await manager.start()
# Just check if server initialized
state = manager._servers.get("python")
if state:
print(f" Server initialized: {state.initialized}")
print(f" Capabilities: {len(state.capabilities)} keys")
else:
# Force initialization by getting server for a Python file
print(" Getting server for Python file...")
test_file = workspace / "tests" / "real" / "debug_compare.py"
state = await manager._get_server(str(test_file))
if state:
print(f" Server initialized: {state.initialized}")
# Try to read directly from state.reader
if state:
print("\n Direct read test from state.reader:")
print(f" state.reader is: {type(state.reader)}")
print(f" state.reader at_eof: {state.reader.at_eof()}")
# Check if there's data available
try:
line = await asyncio.wait_for(state.reader.readline(), timeout=1.0)
if line:
print(f" Got line: {line[:50]}...")
else:
print(f" readline returned empty (EOF)")
except asyncio.TimeoutError:
print(f" readline timed out (no data)")
await manager.stop()
async def main():
await direct_test()
await manager_test()
print("\n=== DONE ===")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,216 @@
#!/usr/bin/env python
"""Test if pyright sends workspace/configuration after initialized."""
import asyncio
import json
import sys
from pathlib import Path
# Add source to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
async def read_message_direct(reader):
"""Read a JSON-RPC message - direct blocking read, no timeout."""
content_length = 0
while True:
line = await reader.readline()
if not line:
return None
line_str = line.decode("ascii").strip()
if not line_str:
break
if line_str.lower().startswith("content-length:"):
content_length = int(line_str.split(":")[1].strip())
if content_length == 0:
return None
body = await reader.readexactly(content_length)
return json.loads(body.decode("utf-8"))
async def main():
workspace = Path(__file__).parent.parent.parent
print(f"Workspace: {workspace}")
# Start pyright - exactly like in direct test
process = await asyncio.create_subprocess_exec(
"pyright-langserver", "--stdio",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=str(workspace),
)
def encode_message(content):
body = json.dumps(content).encode("utf-8")
header = f"Content-Length: {len(body)}\r\n\r\n"
return header.encode("ascii") + body
async def send(message):
encoded = encode_message(message)
process.stdin.write(encoded)
await process.stdin.drain()
method_or_resp = message.get('method') or f"response id={message.get('id')}"
print(f"SENT: {method_or_resp} ({len(encoded)} bytes)")
# Start stderr reader
async def read_stderr():
while True:
line = await process.stderr.readline()
if not line:
break
print(f"[stderr] {line.decode('utf-8', errors='replace').rstrip()}")
asyncio.create_task(read_stderr())
print("\n=== INITIALIZE ===")
await send({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"processId": None,
"rootUri": workspace.as_uri(),
"rootPath": str(workspace),
"capabilities": {
"workspace": {"configuration": True},
},
"workspaceFolders": [{"uri": workspace.as_uri(), "name": workspace.name}],
},
})
# Read until we get initialize response
print("Reading initialize response...")
while True:
msg = await asyncio.wait_for(read_message_direct(process.stdout), timeout=10)
if msg is None:
break
method = msg.get("method")
msg_id = msg.get("id")
if method:
print(f"RECV: {method} (notification)")
else:
print(f"RECV: response id={msg_id}")
if msg_id == 1:
print("Initialize OK!")
break
print("\n=== SEND INITIALIZED ===")
await send({
"jsonrpc": "2.0",
"method": "initialized",
"params": {},
})
# Now, here's the key test - will we receive workspace/configuration?
print("\n=== WAIT FOR workspace/configuration ===")
print("Reading with 5 second timeout...")
try:
for i in range(10):
msg = await asyncio.wait_for(read_message_direct(process.stdout), timeout=2)
if msg is None:
print("EOF")
break
method = msg.get("method")
msg_id = msg.get("id")
print(f"RECV: method={method}, id={msg_id}")
# Respond to server requests
if msg_id is not None and method:
if method == "workspace/configuration":
print(" -> Got workspace/configuration! Responding...")
await send({
"jsonrpc": "2.0",
"id": msg_id,
"result": [{} for _ in msg.get("params", {}).get("items", [])],
})
else:
print(f" -> Responding to {method}")
await send({"jsonrpc": "2.0", "id": msg_id, "result": None})
except asyncio.TimeoutError:
print("No more messages (timeout)")
print("\n=== Now start background read task like manager does ===")
# Store references like manager does
reader = process.stdout # This is how manager does it
writer = process.stdin
# Start background read task
async def bg_read_loop():
print("[BG] Read loop started")
try:
while True:
await asyncio.sleep(0)
try:
msg = await asyncio.wait_for(read_message_direct(reader), timeout=1.0)
if msg is None:
print("[BG] Stream closed")
break
bg_method = msg.get('method') or f"response id={msg.get('id')}"
print(f"[BG] RECV: {bg_method}")
# Handle server requests
method = msg.get("method")
msg_id = msg.get("id")
if msg_id is not None and method:
print(f"[BG] Responding to {method}")
await send({"jsonrpc": "2.0", "id": msg_id, "result": None})
except asyncio.TimeoutError:
print("[BG] timeout")
except asyncio.CancelledError:
print("[BG] Cancelled")
read_task = asyncio.create_task(bg_read_loop())
# Wait a moment
await asyncio.sleep(0.5)
# Now send didOpen and documentSymbol like manager does
print("\n=== SEND didOpen ===")
test_file = workspace / "tests" / "real" / "debug_config.py"
await send({
"jsonrpc": "2.0",
"method": "textDocument/didOpen",
"params": {
"textDocument": {
"uri": test_file.as_uri(),
"languageId": "python",
"version": 1,
"text": test_file.read_text(),
},
},
})
# Wait for processing
await asyncio.sleep(2)
print("\n=== SEND documentSymbol ===")
await send({
"jsonrpc": "2.0",
"id": 2,
"method": "textDocument/documentSymbol",
"params": {"textDocument": {"uri": test_file.as_uri()}},
})
# Wait for response
print("Waiting for documentSymbol response (max 30s)...")
deadline = asyncio.get_event_loop().time() + 30
while asyncio.get_event_loop().time() < deadline:
await asyncio.sleep(0.5)
# The background task will print when it receives the response
read_task.cancel()
try:
await read_task
except asyncio.CancelledError:
pass
process.terminate()
print("\nDone!")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,320 @@
#!/usr/bin/env python
"""Minimal direct test of pyright LSP communication."""
import asyncio
import json
import sys
from pathlib import Path
async def send_message(writer, message):
"""Send a JSON-RPC message."""
body = json.dumps(message).encode("utf-8")
header = f"Content-Length: {len(body)}\r\n\r\n".encode("ascii")
writer.write(header + body)
await writer.drain()
print(f"SENT: {message.get('method', 'response')} (id={message.get('id', 'N/A')})")
async def read_message(reader):
"""Read a JSON-RPC message."""
# Read headers
content_length = 0
while True:
line = await reader.readline()
if not line:
return None
line_str = line.decode("ascii").strip()
if not line_str:
break
if line_str.lower().startswith("content-length:"):
content_length = int(line_str.split(":")[1].strip())
if content_length == 0:
return None
# Read body
body = await reader.readexactly(content_length)
return json.loads(body.decode("utf-8"))
async def main():
workspace = Path(__file__).parent.parent.parent
test_file = workspace / "tests" / "real" / "debug_direct.py"
print(f"Workspace: {workspace}")
print(f"Test file: {test_file}")
print()
# Start pyright
print("Starting pyright-langserver...")
process = await asyncio.create_subprocess_exec(
"pyright-langserver", "--stdio",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=str(workspace),
)
# Start stderr reader
async def read_stderr():
while True:
line = await process.stderr.readline()
if not line:
break
print(f"[stderr] {line.decode('utf-8', errors='replace').rstrip()}")
stderr_task = asyncio.create_task(read_stderr())
try:
# 1. Send initialize
print("\n=== INITIALIZE ===")
await send_message(process.stdin, {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"processId": None,
"rootUri": workspace.as_uri(),
"rootPath": str(workspace),
"capabilities": {
"textDocument": {
"documentSymbol": {
"hierarchicalDocumentSymbolSupport": True,
},
},
"workspace": {
"configuration": True,
},
},
"workspaceFolders": [{"uri": workspace.as_uri(), "name": workspace.name}],
},
})
# Read all messages until we get initialize response
print("\n=== READING RESPONSES ===")
init_done = False
for i in range(20):
try:
msg = await asyncio.wait_for(read_message(process.stdout), timeout=5.0)
if msg is None:
print("EOF")
break
method = msg.get("method", "")
msg_id = msg.get("id", "N/A")
if method:
print(f"RECV: {method} (id={msg_id})")
# Handle server requests
if msg_id != "N/A":
if method == "workspace/configuration":
print(" -> Responding to workspace/configuration")
items = msg.get("params", {}).get("items", [])
await send_message(process.stdin, {
"jsonrpc": "2.0",
"id": msg_id,
"result": [{"pythonPath": "python"} for _ in items],
})
elif method == "client/registerCapability":
print(" -> Responding to client/registerCapability")
await send_message(process.stdin, {
"jsonrpc": "2.0",
"id": msg_id,
"result": None,
})
elif method == "window/workDoneProgress/create":
print(" -> Responding to window/workDoneProgress/create")
await send_message(process.stdin, {
"jsonrpc": "2.0",
"id": msg_id,
"result": None,
})
else:
print(f"RECV: response (id={msg_id})")
if msg_id == 1:
print(" -> Initialize response received!")
caps = list(msg.get("result", {}).get("capabilities", {}).keys())
print(f" -> Capabilities: {caps[:5]}...")
init_done = True
break
except asyncio.TimeoutError:
print(f" Timeout waiting for message {i+1}")
break
if not init_done:
print("ERROR: Initialize failed")
return
# 2. Send initialized notification
print("\n=== INITIALIZED ===")
await send_message(process.stdin, {
"jsonrpc": "2.0",
"method": "initialized",
"params": {},
})
# Read any messages pyright sends after initialized
print("\n=== READING POST-INITIALIZED MESSAGES ===")
for i in range(10):
try:
msg = await asyncio.wait_for(read_message(process.stdout), timeout=2.0)
if msg is None:
break
method = msg.get("method", "")
msg_id = msg.get("id", "N/A")
print(f"RECV: {method or 'response'} (id={msg_id})")
# Handle server requests
if msg_id != "N/A" and method:
if method == "workspace/configuration":
print(" -> Responding to workspace/configuration")
items = msg.get("params", {}).get("items", [])
await send_message(process.stdin, {
"jsonrpc": "2.0",
"id": msg_id,
"result": [{"pythonPath": "python"} for _ in items],
})
elif method == "client/registerCapability":
print(" -> Responding to client/registerCapability")
await send_message(process.stdin, {
"jsonrpc": "2.0",
"id": msg_id,
"result": None,
})
elif method == "window/workDoneProgress/create":
print(" -> Responding to window/workDoneProgress/create")
await send_message(process.stdin, {
"jsonrpc": "2.0",
"id": msg_id,
"result": None,
})
except asyncio.TimeoutError:
print(f" No more messages (timeout)")
break
# 3. Send didOpen
print("\n=== DIDOPEN ===")
content = test_file.read_text(encoding="utf-8")
await send_message(process.stdin, {
"jsonrpc": "2.0",
"method": "textDocument/didOpen",
"params": {
"textDocument": {
"uri": test_file.as_uri(),
"languageId": "python",
"version": 1,
"text": content,
},
},
})
# Read any messages
print("\n=== READING POST-DIDOPEN MESSAGES ===")
for i in range(10):
try:
msg = await asyncio.wait_for(read_message(process.stdout), timeout=2.0)
if msg is None:
break
method = msg.get("method", "")
msg_id = msg.get("id", "N/A")
print(f"RECV: {method or 'response'} (id={msg_id})")
# Handle server requests
if msg_id != "N/A" and method:
if method == "workspace/configuration":
print(" -> Responding to workspace/configuration")
items = msg.get("params", {}).get("items", [])
await send_message(process.stdin, {
"jsonrpc": "2.0",
"id": msg_id,
"result": [{"pythonPath": "python"} for _ in items],
})
else:
print(f" -> Responding with null to {method}")
await send_message(process.stdin, {
"jsonrpc": "2.0",
"id": msg_id,
"result": None,
})
except asyncio.TimeoutError:
print(f" No more messages (timeout)")
break
# 4. Send documentSymbol request
print("\n=== DOCUMENTSYMBOL ===")
await send_message(process.stdin, {
"jsonrpc": "2.0",
"id": 2,
"method": "textDocument/documentSymbol",
"params": {
"textDocument": {"uri": test_file.as_uri()},
},
})
# Wait for response
print("\n=== READING DOCUMENTSYMBOL RESPONSE ===")
for i in range(20):
try:
msg = await asyncio.wait_for(read_message(process.stdout), timeout=5.0)
if msg is None:
break
method = msg.get("method", "")
msg_id = msg.get("id", "N/A")
if method:
print(f"RECV: {method} (id={msg_id})")
# Handle server requests
if msg_id != "N/A":
if method == "workspace/configuration":
print(" -> Responding to workspace/configuration")
items = msg.get("params", {}).get("items", [])
await send_message(process.stdin, {
"jsonrpc": "2.0",
"id": msg_id,
"result": [{"pythonPath": "python"} for _ in items],
})
else:
print(f" -> Responding with null to {method}")
await send_message(process.stdin, {
"jsonrpc": "2.0",
"id": msg_id,
"result": None,
})
else:
print(f"RECV: response (id={msg_id})")
if msg_id == 2:
result = msg.get("result", [])
print(f" -> DocumentSymbol response: {len(result)} symbols")
for sym in result[:5]:
print(f" - {sym.get('name')} ({sym.get('kind')})")
break
except asyncio.TimeoutError:
print(f" Timeout {i+1}")
if i >= 5:
break
print("\n=== DONE ===")
finally:
stderr_task.cancel()
process.terminate()
try:
await asyncio.wait_for(process.wait(), timeout=5.0)
except asyncio.TimeoutError:
process.kill()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,123 @@
#!/usr/bin/env python
"""Debug exactly what's happening with reads after initialized."""
import asyncio
import json
from pathlib import Path
async def main():
workspace = Path(__file__).parent.parent.parent
print(f"Workspace: {workspace}")
# Start pyright
process = await asyncio.create_subprocess_exec(
"pyright-langserver", "--stdio",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=str(workspace),
)
# Helper to encode message
def encode(content):
body = json.dumps(content).encode("utf-8")
header = f"Content-Length: {len(body)}\r\n\r\n"
return header.encode("ascii") + body
# Helper to send
async def send(msg):
encoded = encode(msg)
process.stdin.write(encoded)
await process.stdin.drain()
method = msg.get("method") or f"response-{msg.get('id')}"
print(f"SENT: {method}")
# Helper to read one message
async def read_one(timeout=3.0):
content_length = 0
while True:
try:
print(f" readline(timeout={timeout})...")
line = await asyncio.wait_for(process.stdout.readline(), timeout=timeout)
print(f" got line: {repr(line[:50] if len(line) > 50 else line)}")
except asyncio.TimeoutError:
print(f" TIMEOUT on readline")
return None
if not line:
print(f" EOF")
return None
line_str = line.decode("ascii").strip()
if not line_str:
break # End of headers
if line_str.lower().startswith("content-length:"):
content_length = int(line_str.split(":")[1].strip())
if content_length == 0:
return None
body = await process.stdout.readexactly(content_length)
return json.loads(body.decode("utf-8"))
# Start stderr reader
async def read_stderr():
while True:
line = await process.stderr.readline()
if not line:
break
print(f"[stderr] {line.decode('utf-8', errors='replace').rstrip()}")
asyncio.create_task(read_stderr())
print("\n=== INITIALIZE ===")
await send({
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {
"processId": None,
"rootUri": workspace.as_uri(),
"capabilities": {"workspace": {"configuration": True}},
"workspaceFolders": [{"uri": workspace.as_uri(), "name": workspace.name}],
},
})
# Read until initialize response
print("\n=== READING UNTIL INITIALIZE RESPONSE ===")
while True:
msg = await read_one()
if msg and msg.get("id") == 1 and "method" not in msg:
print(f"Got initialize response")
break
elif msg:
print(f"Got notification: {msg.get('method')}")
print("\n=== SEND INITIALIZED ===")
await send({"jsonrpc": "2.0", "method": "initialized", "params": {}})
print("\n=== NOW TRY TO READ WORKSPACE/CONFIGURATION ===")
print("Attempting reads with 2s timeout each...")
for i in range(3):
print(f"\n--- Read attempt {i+1} ---")
msg = await read_one(timeout=2.0)
if msg:
method = msg.get("method", "")
msg_id = msg.get("id")
print(f"SUCCESS: method={method}, id={msg_id}")
if method and msg_id is not None:
# Respond to server request
print(f"Responding to {method}")
await send({"jsonrpc": "2.0", "id": msg_id, "result": [{}]})
else:
print(f"No message (timeout or EOF)")
break
print("\n=== CLEANUP ===")
process.terminate()
await process.wait()
print("Done")
if __name__ == "__main__":
asyncio.run(main())