axion-code 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- axion/__init__.py +3 -0
- axion/api/__init__.py +0 -0
- axion/api/anthropic.py +460 -0
- axion/api/client.py +259 -0
- axion/api/error.py +161 -0
- axion/api/ollama.py +597 -0
- axion/api/openai_compat.py +805 -0
- axion/api/openai_responses.py +627 -0
- axion/api/prompt_cache.py +31 -0
- axion/api/sse.py +98 -0
- axion/api/types.py +451 -0
- axion/cli/__init__.py +0 -0
- axion/cli/init_cmd.py +50 -0
- axion/cli/input.py +290 -0
- axion/cli/main.py +2953 -0
- axion/cli/render.py +489 -0
- axion/cli/tui.py +766 -0
- axion/commands/__init__.py +0 -0
- axion/commands/handlers/__init__.py +0 -0
- axion/commands/handlers/agents.py +51 -0
- axion/commands/handlers/builtin_commands.py +367 -0
- axion/commands/handlers/mcp.py +59 -0
- axion/commands/handlers/models.py +75 -0
- axion/commands/handlers/plugins.py +55 -0
- axion/commands/handlers/skills.py +61 -0
- axion/commands/parsing.py +317 -0
- axion/commands/registry.py +166 -0
- axion/compat_harness/__init__.py +0 -0
- axion/compat_harness/extractor.py +145 -0
- axion/plugins/__init__.py +0 -0
- axion/plugins/hooks.py +22 -0
- axion/plugins/manager.py +391 -0
- axion/plugins/manifest.py +270 -0
- axion/runtime/__init__.py +0 -0
- axion/runtime/bash.py +388 -0
- axion/runtime/bootstrap.py +39 -0
- axion/runtime/claude_subscription.py +300 -0
- axion/runtime/compact.py +233 -0
- axion/runtime/config.py +397 -0
- axion/runtime/conversation.py +1073 -0
- axion/runtime/file_ops.py +613 -0
- axion/runtime/git.py +213 -0
- axion/runtime/hooks.py +235 -0
- axion/runtime/image.py +212 -0
- axion/runtime/lanes.py +282 -0
- axion/runtime/lsp.py +425 -0
- axion/runtime/mcp/__init__.py +0 -0
- axion/runtime/mcp/client.py +76 -0
- axion/runtime/mcp/lifecycle.py +96 -0
- axion/runtime/mcp/stdio.py +318 -0
- axion/runtime/mcp/tool_bridge.py +79 -0
- axion/runtime/memory.py +196 -0
- axion/runtime/oauth.py +329 -0
- axion/runtime/openai_subscription.py +346 -0
- axion/runtime/permissions.py +247 -0
- axion/runtime/plan_mode.py +96 -0
- axion/runtime/policy_engine.py +259 -0
- axion/runtime/prompt.py +586 -0
- axion/runtime/recovery.py +261 -0
- axion/runtime/remote.py +28 -0
- axion/runtime/sandbox.py +68 -0
- axion/runtime/scheduler.py +231 -0
- axion/runtime/session.py +365 -0
- axion/runtime/sharing.py +159 -0
- axion/runtime/skills.py +124 -0
- axion/runtime/tasks.py +258 -0
- axion/runtime/usage.py +241 -0
- axion/runtime/workers.py +186 -0
- axion/telemetry/__init__.py +0 -0
- axion/telemetry/events.py +67 -0
- axion/telemetry/profile.py +49 -0
- axion/telemetry/sink.py +60 -0
- axion/telemetry/tracer.py +95 -0
- axion/tools/__init__.py +0 -0
- axion/tools/lane_completion.py +33 -0
- axion/tools/registry.py +853 -0
- axion/tools/tool_search.py +226 -0
- axion_code-1.0.0.dist-info/METADATA +709 -0
- axion_code-1.0.0.dist-info/RECORD +82 -0
- axion_code-1.0.0.dist-info/WHEEL +4 -0
- axion_code-1.0.0.dist-info/entry_points.txt +2 -0
- axion_code-1.0.0.dist-info/licenses/LICENSE +21 -0
axion/runtime/lanes.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Lane events, branch lock, stale branch detection, and summary compression.
|
|
2
|
+
|
|
3
|
+
Maps to: rust/crates/runtime/src/lane_events.rs, branch_lock.rs, stale_branch.rs
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import enum
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
# Lane status and events
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
class LaneStatus(enum.Enum):
|
|
18
|
+
ACTIVE = "active"
|
|
19
|
+
COMPLETED = "completed"
|
|
20
|
+
BLOCKED = "blocked"
|
|
21
|
+
RECONCILED = "reconciled"
|
|
22
|
+
FAILED = "failed"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LaneEventType(enum.Enum):
|
|
26
|
+
CREATED = "created"
|
|
27
|
+
STARTED = "started"
|
|
28
|
+
TOOL_EXECUTED = "tool_executed"
|
|
29
|
+
ITERATION_COMPLETED = "iteration_completed"
|
|
30
|
+
BLOCKED = "blocked"
|
|
31
|
+
UNBLOCKED = "unblocked"
|
|
32
|
+
GREEN_LEVEL_CHANGED = "green_level_changed"
|
|
33
|
+
REVIEW_REQUESTED = "review_requested"
|
|
34
|
+
REVIEW_APPROVED = "review_approved"
|
|
35
|
+
REVIEW_REJECTED = "review_rejected"
|
|
36
|
+
MERGE_REQUESTED = "merge_requested"
|
|
37
|
+
MERGE_COMPLETED = "merge_completed"
|
|
38
|
+
RECONCILED = "reconciled"
|
|
39
|
+
COMPLETED = "completed"
|
|
40
|
+
FAILED = "failed"
|
|
41
|
+
ESCALATED = "escalated"
|
|
42
|
+
RECOVERED = "recovered"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class LaneEvent:
|
|
47
|
+
"""Timestamped event in a lane's lifecycle."""
|
|
48
|
+
|
|
49
|
+
lane_id: str
|
|
50
|
+
event_type: LaneEventType
|
|
51
|
+
timestamp_ms: int = field(default_factory=lambda: int(time.time() * 1000))
|
|
52
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
53
|
+
green_level: int | None = None
|
|
54
|
+
message: str = ""
|
|
55
|
+
|
|
56
|
+
def to_dict(self) -> dict[str, Any]:
|
|
57
|
+
d: dict[str, Any] = {
|
|
58
|
+
"lane_id": self.lane_id,
|
|
59
|
+
"event_type": self.event_type.value,
|
|
60
|
+
"timestamp_ms": self.timestamp_ms,
|
|
61
|
+
}
|
|
62
|
+
if self.details:
|
|
63
|
+
d["details"] = self.details
|
|
64
|
+
if self.green_level is not None:
|
|
65
|
+
d["green_level"] = self.green_level
|
|
66
|
+
if self.message:
|
|
67
|
+
d["message"] = self.message
|
|
68
|
+
return d
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# Lane state tracker
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class LaneState:
|
|
77
|
+
"""Current state of a lane."""
|
|
78
|
+
|
|
79
|
+
lane_id: str
|
|
80
|
+
status: LaneStatus = LaneStatus.ACTIVE
|
|
81
|
+
green_level: int = 0
|
|
82
|
+
branch: str = ""
|
|
83
|
+
worker_id: str | None = None
|
|
84
|
+
events: list[LaneEvent] = field(default_factory=list)
|
|
85
|
+
blocker_reason: str = ""
|
|
86
|
+
created_at_ms: int = field(default_factory=lambda: int(time.time() * 1000))
|
|
87
|
+
|
|
88
|
+
def record_event(
|
|
89
|
+
self,
|
|
90
|
+
event_type: LaneEventType,
|
|
91
|
+
message: str = "",
|
|
92
|
+
**details: Any,
|
|
93
|
+
) -> LaneEvent:
|
|
94
|
+
event = LaneEvent(
|
|
95
|
+
lane_id=self.lane_id,
|
|
96
|
+
event_type=event_type,
|
|
97
|
+
green_level=self.green_level,
|
|
98
|
+
message=message,
|
|
99
|
+
details=details if details else {},
|
|
100
|
+
)
|
|
101
|
+
self.events.append(event)
|
|
102
|
+
return event
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class LaneEventLog:
|
|
106
|
+
"""Tracks lane events for git workflow coordination."""
|
|
107
|
+
|
|
108
|
+
def __init__(self) -> None:
|
|
109
|
+
self._lanes: dict[str, LaneState] = {}
|
|
110
|
+
self._all_events: list[LaneEvent] = []
|
|
111
|
+
|
|
112
|
+
def create_lane(self, lane_id: str, branch: str = "") -> LaneState:
|
|
113
|
+
state = LaneState(lane_id=lane_id, branch=branch)
|
|
114
|
+
state.record_event(LaneEventType.CREATED, f"Lane created for branch {branch}")
|
|
115
|
+
self._lanes[lane_id] = state
|
|
116
|
+
return state
|
|
117
|
+
|
|
118
|
+
def get_lane(self, lane_id: str) -> LaneState | None:
|
|
119
|
+
return self._lanes.get(lane_id)
|
|
120
|
+
|
|
121
|
+
def record(self, event: LaneEvent) -> None:
|
|
122
|
+
self._all_events.append(event)
|
|
123
|
+
lane = self._lanes.get(event.lane_id)
|
|
124
|
+
if lane:
|
|
125
|
+
lane.events.append(event)
|
|
126
|
+
|
|
127
|
+
def events_for_lane(self, lane_id: str) -> list[LaneEvent]:
|
|
128
|
+
lane = self._lanes.get(lane_id)
|
|
129
|
+
return lane.events if lane else []
|
|
130
|
+
|
|
131
|
+
def all_events(self) -> list[LaneEvent]:
|
|
132
|
+
return list(self._all_events)
|
|
133
|
+
|
|
134
|
+
def all_lanes(self) -> list[LaneState]:
|
|
135
|
+
return list(self._lanes.values())
|
|
136
|
+
|
|
137
|
+
def active_lanes(self) -> list[LaneState]:
|
|
138
|
+
return [lane for lane in self._lanes.values() if lane.status == LaneStatus.ACTIVE]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
# Branch lock
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
@dataclass
|
|
146
|
+
class BranchLock:
|
|
147
|
+
"""Lock on a git branch to prevent concurrent modifications."""
|
|
148
|
+
|
|
149
|
+
branch: str
|
|
150
|
+
holder: str
|
|
151
|
+
acquired_at_ms: int = field(default_factory=lambda: int(time.time() * 1000))
|
|
152
|
+
expires_at_ms: int | None = None
|
|
153
|
+
reason: str = ""
|
|
154
|
+
|
|
155
|
+
def is_expired(self) -> bool:
|
|
156
|
+
if self.expires_at_ms is None:
|
|
157
|
+
return False
|
|
158
|
+
return int(time.time() * 1000) > self.expires_at_ms
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class BranchLockManager:
|
|
162
|
+
"""Manages branch locks with expiry and reason tracking."""
|
|
163
|
+
|
|
164
|
+
def __init__(self) -> None:
|
|
165
|
+
self._locks: dict[str, BranchLock] = {}
|
|
166
|
+
|
|
167
|
+
def acquire(
|
|
168
|
+
self,
|
|
169
|
+
branch: str,
|
|
170
|
+
holder: str,
|
|
171
|
+
ttl_ms: int = 300_000,
|
|
172
|
+
reason: str = "",
|
|
173
|
+
) -> bool:
|
|
174
|
+
"""Acquire a branch lock. Returns False if already held by another."""
|
|
175
|
+
existing = self._locks.get(branch)
|
|
176
|
+
if existing and not existing.is_expired() and existing.holder != holder:
|
|
177
|
+
return False
|
|
178
|
+
now = int(time.time() * 1000)
|
|
179
|
+
self._locks[branch] = BranchLock(
|
|
180
|
+
branch=branch,
|
|
181
|
+
holder=holder,
|
|
182
|
+
acquired_at_ms=now,
|
|
183
|
+
expires_at_ms=now + ttl_ms,
|
|
184
|
+
reason=reason,
|
|
185
|
+
)
|
|
186
|
+
return True
|
|
187
|
+
|
|
188
|
+
def release(self, branch: str, holder: str) -> bool:
|
|
189
|
+
lock = self._locks.get(branch)
|
|
190
|
+
if lock and lock.holder == holder:
|
|
191
|
+
del self._locks[branch]
|
|
192
|
+
return True
|
|
193
|
+
return False
|
|
194
|
+
|
|
195
|
+
def is_locked(self, branch: str) -> bool:
|
|
196
|
+
lock = self._locks.get(branch)
|
|
197
|
+
return lock is not None and not lock.is_expired()
|
|
198
|
+
|
|
199
|
+
def lock_holder(self, branch: str) -> str | None:
|
|
200
|
+
lock = self._locks.get(branch)
|
|
201
|
+
if lock and not lock.is_expired():
|
|
202
|
+
return lock.holder
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
def all_locks(self) -> list[BranchLock]:
|
|
206
|
+
# Clean expired locks
|
|
207
|
+
now = int(time.time() * 1000)
|
|
208
|
+
self._locks = {
|
|
209
|
+
k: v for k, v in self._locks.items()
|
|
210
|
+
if v.expires_at_ms is None or v.expires_at_ms > now
|
|
211
|
+
}
|
|
212
|
+
return list(self._locks.values())
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ---------------------------------------------------------------------------
|
|
216
|
+
# Stale branch detection
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
DEFAULT_STALE_THRESHOLD_MS = 86_400_000 # 24 hours
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def is_stale_branch(
|
|
223
|
+
last_commit_ms: int,
|
|
224
|
+
threshold_ms: int = DEFAULT_STALE_THRESHOLD_MS,
|
|
225
|
+
) -> bool:
|
|
226
|
+
"""Check if a branch is stale (default: 24 hours since last commit)."""
|
|
227
|
+
now_ms = int(time.time() * 1000)
|
|
228
|
+
return (now_ms - last_commit_ms) > threshold_ms
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def branch_freshness_ms(last_commit_ms: int) -> int:
|
|
232
|
+
"""Get time since last commit in milliseconds."""
|
|
233
|
+
now_ms = int(time.time() * 1000)
|
|
234
|
+
return now_ms - last_commit_ms
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ---------------------------------------------------------------------------
|
|
238
|
+
# Summary compression (for lane event history)
|
|
239
|
+
# ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
def compress_lane_summary(events: list[LaneEvent], max_events: int = 20) -> str:
|
|
242
|
+
"""Compress a list of lane events into a summary string.
|
|
243
|
+
|
|
244
|
+
Keeps the first and last events, plus key transitions.
|
|
245
|
+
"""
|
|
246
|
+
if not events:
|
|
247
|
+
return "(no events)"
|
|
248
|
+
|
|
249
|
+
if len(events) <= max_events:
|
|
250
|
+
lines = []
|
|
251
|
+
for e in events:
|
|
252
|
+
ts = time.strftime("%H:%M:%S", time.gmtime(e.timestamp_ms / 1000))
|
|
253
|
+
lines.append(f"[{ts}] {e.event_type.value}: {e.message or '(no message)'}")
|
|
254
|
+
return "\n".join(lines)
|
|
255
|
+
|
|
256
|
+
# Compress: keep first, last, and key transitions
|
|
257
|
+
key_types = {
|
|
258
|
+
LaneEventType.CREATED, LaneEventType.COMPLETED, LaneEventType.FAILED,
|
|
259
|
+
LaneEventType.BLOCKED, LaneEventType.UNBLOCKED,
|
|
260
|
+
LaneEventType.MERGE_COMPLETED, LaneEventType.ESCALATED,
|
|
261
|
+
LaneEventType.GREEN_LEVEL_CHANGED,
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
kept: list[LaneEvent] = [events[0]]
|
|
265
|
+
for e in events[1:-1]:
|
|
266
|
+
if e.event_type in key_types:
|
|
267
|
+
kept.append(e)
|
|
268
|
+
kept.append(events[-1])
|
|
269
|
+
|
|
270
|
+
# Truncate if still too long
|
|
271
|
+
if len(kept) > max_events:
|
|
272
|
+
kept = kept[:max_events - 1] + [kept[-1]]
|
|
273
|
+
|
|
274
|
+
skipped = len(events) - len(kept)
|
|
275
|
+
lines = []
|
|
276
|
+
for e in kept:
|
|
277
|
+
ts = time.strftime("%H:%M:%S", time.gmtime(e.timestamp_ms / 1000))
|
|
278
|
+
lines.append(f"[{ts}] {e.event_type.value}: {e.message or '(no message)'}")
|
|
279
|
+
if skipped > 0:
|
|
280
|
+
lines.insert(-1, f" ... ({skipped} events compressed)")
|
|
281
|
+
|
|
282
|
+
return "\n".join(lines)
|
axion/runtime/lsp.py
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
"""LSP (Language Server Protocol) client with JSON-RPC communication.
|
|
2
|
+
|
|
3
|
+
Maps to: rust/crates/runtime/src/lsp_client.rs
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import enum
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class LspAction(enum.Enum):
|
|
19
|
+
DIAGNOSTICS = "diagnostics"
|
|
20
|
+
HOVER = "hover"
|
|
21
|
+
DEFINITION = "definition"
|
|
22
|
+
REFERENCES = "references"
|
|
23
|
+
COMPLETION = "completion"
|
|
24
|
+
SYMBOLS = "symbols"
|
|
25
|
+
FORMAT = "format"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LspServerStatus(enum.Enum):
|
|
29
|
+
CONNECTED = "connected"
|
|
30
|
+
DISCONNECTED = "disconnected"
|
|
31
|
+
STARTING = "starting"
|
|
32
|
+
ERROR = "error"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class LspSeverity(enum.Enum):
|
|
36
|
+
ERROR = "error"
|
|
37
|
+
WARNING = "warning"
|
|
38
|
+
INFORMATION = "information"
|
|
39
|
+
HINT = "hint"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class LspDiagnostic:
|
|
44
|
+
path: str
|
|
45
|
+
line: int
|
|
46
|
+
character: int
|
|
47
|
+
severity: LspSeverity
|
|
48
|
+
message: str
|
|
49
|
+
source: str = ""
|
|
50
|
+
code: str | None = None
|
|
51
|
+
end_line: int | None = None
|
|
52
|
+
end_character: int | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class LspLocation:
|
|
57
|
+
path: str
|
|
58
|
+
line: int
|
|
59
|
+
character: int
|
|
60
|
+
end_line: int | None = None
|
|
61
|
+
end_character: int | None = None
|
|
62
|
+
preview: str = ""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class LspHoverResult:
|
|
67
|
+
content: str
|
|
68
|
+
language: str = ""
|
|
69
|
+
range_start_line: int | None = None
|
|
70
|
+
range_start_character: int | None = None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class LspCompletionItem:
|
|
75
|
+
label: str
|
|
76
|
+
kind: str = ""
|
|
77
|
+
detail: str = ""
|
|
78
|
+
insert_text: str = ""
|
|
79
|
+
sort_text: str = ""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class LspSymbol:
|
|
84
|
+
name: str
|
|
85
|
+
kind: str
|
|
86
|
+
path: str
|
|
87
|
+
line: int
|
|
88
|
+
character: int
|
|
89
|
+
container_name: str = ""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class LspServerInfo:
|
|
94
|
+
"""Information about a connected LSP server."""
|
|
95
|
+
|
|
96
|
+
language: str
|
|
97
|
+
command: str = ""
|
|
98
|
+
status: LspServerStatus = LspServerStatus.DISCONNECTED
|
|
99
|
+
capabilities: dict[str, Any] = field(default_factory=dict)
|
|
100
|
+
root_uri: str = ""
|
|
101
|
+
error_message: str = ""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# LSP JSON-RPC client
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
class LspClient:
|
|
109
|
+
"""JSON-RPC client for a single LSP server process.
|
|
110
|
+
|
|
111
|
+
Communicates via stdin/stdout using Content-Length headers.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def __init__(self, language: str, command: str, args: list[str] | None = None) -> None:
|
|
115
|
+
self.language = language
|
|
116
|
+
self.command = command
|
|
117
|
+
self.args = args or []
|
|
118
|
+
self._process: asyncio.subprocess.Process | None = None
|
|
119
|
+
self._request_id = 0
|
|
120
|
+
self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
|
121
|
+
self._read_task: asyncio.Task[None] | None = None
|
|
122
|
+
self._capabilities: dict[str, Any] = {}
|
|
123
|
+
|
|
124
|
+
async def start(self, root_uri: str = "") -> bool:
|
|
125
|
+
"""Start the LSP server process and initialize."""
|
|
126
|
+
try:
|
|
127
|
+
self._process = await asyncio.create_subprocess_exec(
|
|
128
|
+
self.command, *self.args,
|
|
129
|
+
stdin=asyncio.subprocess.PIPE,
|
|
130
|
+
stdout=asyncio.subprocess.PIPE,
|
|
131
|
+
stderr=asyncio.subprocess.PIPE,
|
|
132
|
+
)
|
|
133
|
+
except (FileNotFoundError, OSError) as exc:
|
|
134
|
+
logger.error("Failed to start LSP server %s: %s", self.command, exc)
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
self._read_task = asyncio.create_task(self._read_loop())
|
|
138
|
+
|
|
139
|
+
# Send initialize request
|
|
140
|
+
result = await self._request("initialize", {
|
|
141
|
+
"processId": None,
|
|
142
|
+
"rootUri": root_uri,
|
|
143
|
+
"capabilities": {
|
|
144
|
+
"textDocument": {
|
|
145
|
+
"completion": {"completionItem": {"snippetSupport": False}},
|
|
146
|
+
"hover": {"contentFormat": ["plaintext"]},
|
|
147
|
+
"definition": {},
|
|
148
|
+
"references": {},
|
|
149
|
+
"documentSymbol": {},
|
|
150
|
+
"formatting": {},
|
|
151
|
+
"publishDiagnostics": {},
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
if result:
|
|
157
|
+
self._capabilities = result.get("capabilities", {})
|
|
158
|
+
|
|
159
|
+
# Send initialized notification
|
|
160
|
+
await self._notify("initialized", {})
|
|
161
|
+
return True
|
|
162
|
+
|
|
163
|
+
async def get_diagnostics(self, uri: str) -> list[LspDiagnostic]:
|
|
164
|
+
"""Get diagnostics for a document. Note: LSP pushes diagnostics, this is a placeholder."""
|
|
165
|
+
return []
|
|
166
|
+
|
|
167
|
+
async def hover(self, uri: str, line: int, character: int) -> LspHoverResult | None:
|
|
168
|
+
"""Get hover information at a position."""
|
|
169
|
+
result = await self._request("textDocument/hover", {
|
|
170
|
+
"textDocument": {"uri": uri},
|
|
171
|
+
"position": {"line": line, "character": character},
|
|
172
|
+
})
|
|
173
|
+
if not result or "contents" not in result:
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
contents = result["contents"]
|
|
177
|
+
if isinstance(contents, str):
|
|
178
|
+
return LspHoverResult(content=contents)
|
|
179
|
+
if isinstance(contents, dict):
|
|
180
|
+
return LspHoverResult(
|
|
181
|
+
content=contents.get("value", ""),
|
|
182
|
+
language=contents.get("language", ""),
|
|
183
|
+
)
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
async def definition(self, uri: str, line: int, character: int) -> list[LspLocation]:
|
|
187
|
+
"""Go to definition."""
|
|
188
|
+
result = await self._request("textDocument/definition", {
|
|
189
|
+
"textDocument": {"uri": uri},
|
|
190
|
+
"position": {"line": line, "character": character},
|
|
191
|
+
})
|
|
192
|
+
return self._parse_locations(result)
|
|
193
|
+
|
|
194
|
+
async def references(self, uri: str, line: int, character: int) -> list[LspLocation]:
|
|
195
|
+
"""Find all references."""
|
|
196
|
+
result = await self._request("textDocument/references", {
|
|
197
|
+
"textDocument": {"uri": uri},
|
|
198
|
+
"position": {"line": line, "character": character},
|
|
199
|
+
"context": {"includeDeclaration": True},
|
|
200
|
+
})
|
|
201
|
+
return self._parse_locations(result)
|
|
202
|
+
|
|
203
|
+
async def completion(self, uri: str, line: int, character: int) -> list[LspCompletionItem]:
|
|
204
|
+
"""Get completions at a position."""
|
|
205
|
+
result = await self._request("textDocument/completion", {
|
|
206
|
+
"textDocument": {"uri": uri},
|
|
207
|
+
"position": {"line": line, "character": character},
|
|
208
|
+
})
|
|
209
|
+
if not result:
|
|
210
|
+
return []
|
|
211
|
+
|
|
212
|
+
items = result.get("items", []) if isinstance(result, dict) else result
|
|
213
|
+
completions = []
|
|
214
|
+
for item in items[:50]: # Limit
|
|
215
|
+
completions.append(LspCompletionItem(
|
|
216
|
+
label=item.get("label", ""),
|
|
217
|
+
kind=str(item.get("kind", "")),
|
|
218
|
+
detail=item.get("detail", ""),
|
|
219
|
+
insert_text=item.get("insertText", item.get("label", "")),
|
|
220
|
+
sort_text=item.get("sortText", ""),
|
|
221
|
+
))
|
|
222
|
+
return completions
|
|
223
|
+
|
|
224
|
+
async def document_symbols(self, uri: str) -> list[LspSymbol]:
|
|
225
|
+
"""Get symbols in a document."""
|
|
226
|
+
result = await self._request("textDocument/documentSymbol", {
|
|
227
|
+
"textDocument": {"uri": uri},
|
|
228
|
+
})
|
|
229
|
+
if not result:
|
|
230
|
+
return []
|
|
231
|
+
|
|
232
|
+
symbols = []
|
|
233
|
+
for sym in result:
|
|
234
|
+
location = sym.get("location", {})
|
|
235
|
+
range_ = location.get("range", sym.get("range", {}))
|
|
236
|
+
start = range_.get("start", {})
|
|
237
|
+
symbols.append(LspSymbol(
|
|
238
|
+
name=sym.get("name", ""),
|
|
239
|
+
kind=str(sym.get("kind", "")),
|
|
240
|
+
path=location.get("uri", uri),
|
|
241
|
+
line=start.get("line", 0),
|
|
242
|
+
character=start.get("character", 0),
|
|
243
|
+
container_name=sym.get("containerName", ""),
|
|
244
|
+
))
|
|
245
|
+
return symbols
|
|
246
|
+
|
|
247
|
+
async def shutdown(self) -> None:
|
|
248
|
+
"""Shutdown the LSP server."""
|
|
249
|
+
if self._process:
|
|
250
|
+
await self._request("shutdown", None)
|
|
251
|
+
await self._notify("exit", None)
|
|
252
|
+
if self._read_task:
|
|
253
|
+
self._read_task.cancel()
|
|
254
|
+
try:
|
|
255
|
+
self._process.terminate()
|
|
256
|
+
await asyncio.wait_for(self._process.wait(), timeout=5.0)
|
|
257
|
+
except (asyncio.TimeoutError, ProcessLookupError):
|
|
258
|
+
self._process.kill()
|
|
259
|
+
|
|
260
|
+
@staticmethod
|
|
261
|
+
def _parse_locations(result: Any) -> list[LspLocation]:
|
|
262
|
+
if not result:
|
|
263
|
+
return []
|
|
264
|
+
locs = result if isinstance(result, list) else [result]
|
|
265
|
+
locations = []
|
|
266
|
+
for loc in locs:
|
|
267
|
+
uri = loc.get("uri", "")
|
|
268
|
+
range_ = loc.get("range", {})
|
|
269
|
+
start = range_.get("start", {})
|
|
270
|
+
end = range_.get("end", {})
|
|
271
|
+
locations.append(LspLocation(
|
|
272
|
+
path=uri,
|
|
273
|
+
line=start.get("line", 0),
|
|
274
|
+
character=start.get("character", 0),
|
|
275
|
+
end_line=end.get("line"),
|
|
276
|
+
end_character=end.get("character"),
|
|
277
|
+
))
|
|
278
|
+
return locations
|
|
279
|
+
|
|
280
|
+
def _next_id(self) -> int:
|
|
281
|
+
self._request_id += 1
|
|
282
|
+
return self._request_id
|
|
283
|
+
|
|
284
|
+
async def _request(self, method: str, params: Any) -> Any:
|
|
285
|
+
if not self._process or not self._process.stdin:
|
|
286
|
+
return None
|
|
287
|
+
req_id = self._next_id()
|
|
288
|
+
msg = {"jsonrpc": "2.0", "id": req_id, "method": method, "params": params}
|
|
289
|
+
|
|
290
|
+
future: asyncio.Future[dict[str, Any]] = asyncio.get_event_loop().create_future()
|
|
291
|
+
self._pending[req_id] = future
|
|
292
|
+
|
|
293
|
+
await self._write(msg)
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
response = await asyncio.wait_for(future, timeout=10.0)
|
|
297
|
+
return response.get("result")
|
|
298
|
+
except asyncio.TimeoutError:
|
|
299
|
+
self._pending.pop(req_id, None)
|
|
300
|
+
return None
|
|
301
|
+
|
|
302
|
+
async def _notify(self, method: str, params: Any) -> None:
|
|
303
|
+
if not self._process or not self._process.stdin:
|
|
304
|
+
return
|
|
305
|
+
msg: dict[str, Any] = {"jsonrpc": "2.0", "method": method}
|
|
306
|
+
if params is not None:
|
|
307
|
+
msg["params"] = params
|
|
308
|
+
await self._write(msg)
|
|
309
|
+
|
|
310
|
+
async def _write(self, msg: dict[str, Any]) -> None:
|
|
311
|
+
assert self._process and self._process.stdin
|
|
312
|
+
content = json.dumps(msg)
|
|
313
|
+
header = f"Content-Length: {len(content)}\r\n\r\n"
|
|
314
|
+
self._process.stdin.write((header + content).encode())
|
|
315
|
+
await self._process.stdin.drain()
|
|
316
|
+
|
|
317
|
+
async def _read_loop(self) -> None:
|
|
318
|
+
assert self._process and self._process.stdout
|
|
319
|
+
buffer = b""
|
|
320
|
+
while True:
|
|
321
|
+
try:
|
|
322
|
+
chunk = await self._process.stdout.read(4096)
|
|
323
|
+
if not chunk:
|
|
324
|
+
break
|
|
325
|
+
buffer += chunk
|
|
326
|
+
|
|
327
|
+
while b"\r\n\r\n" in buffer:
|
|
328
|
+
header_end = buffer.index(b"\r\n\r\n")
|
|
329
|
+
header = buffer[:header_end].decode()
|
|
330
|
+
content_length = 0
|
|
331
|
+
for line in header.split("\r\n"):
|
|
332
|
+
if line.lower().startswith("content-length:"):
|
|
333
|
+
content_length = int(line.split(":")[1].strip())
|
|
334
|
+
|
|
335
|
+
body_start = header_end + 4
|
|
336
|
+
if len(buffer) < body_start + content_length:
|
|
337
|
+
break
|
|
338
|
+
|
|
339
|
+
body = buffer[body_start:body_start + content_length].decode()
|
|
340
|
+
buffer = buffer[body_start + content_length:]
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
data = json.loads(body)
|
|
344
|
+
msg_id = data.get("id")
|
|
345
|
+
if msg_id is not None and msg_id in self._pending:
|
|
346
|
+
future = self._pending.pop(msg_id)
|
|
347
|
+
if not future.done():
|
|
348
|
+
future.set_result(data)
|
|
349
|
+
except json.JSONDecodeError:
|
|
350
|
+
pass
|
|
351
|
+
except asyncio.CancelledError:
|
|
352
|
+
break
|
|
353
|
+
except Exception as exc:
|
|
354
|
+
logger.error("LSP read error: %s", exc)
|
|
355
|
+
break
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# ---------------------------------------------------------------------------
|
|
359
|
+
# LSP Registry
|
|
360
|
+
# ---------------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
class LspRegistry:
|
|
363
|
+
"""Registry of connected LSP servers.
|
|
364
|
+
|
|
365
|
+
Maps to: rust/crates/runtime/src/lsp_client.rs::LspRegistry
|
|
366
|
+
"""
|
|
367
|
+
|
|
368
|
+
def __init__(self) -> None:
|
|
369
|
+
self._clients: dict[str, LspClient] = {}
|
|
370
|
+
self._server_info: dict[str, LspServerInfo] = {}
|
|
371
|
+
self._diagnostics: list[LspDiagnostic] = []
|
|
372
|
+
|
|
373
|
+
async def connect(
|
|
374
|
+
self, language: str, command: str, args: list[str] | None = None, root_uri: str = "",
|
|
375
|
+
) -> bool:
|
|
376
|
+
"""Start and connect to an LSP server."""
|
|
377
|
+
client = LspClient(language=language, command=command, args=args)
|
|
378
|
+
success = await client.start(root_uri=root_uri)
|
|
379
|
+
|
|
380
|
+
info = LspServerInfo(
|
|
381
|
+
language=language,
|
|
382
|
+
command=command,
|
|
383
|
+
status=LspServerStatus.CONNECTED if success else LspServerStatus.ERROR,
|
|
384
|
+
root_uri=root_uri,
|
|
385
|
+
)
|
|
386
|
+
self._server_info[language] = info
|
|
387
|
+
|
|
388
|
+
if success:
|
|
389
|
+
self._clients[language] = client
|
|
390
|
+
return success
|
|
391
|
+
|
|
392
|
+
def get_client(self, language: str) -> LspClient | None:
|
|
393
|
+
return self._clients.get(language)
|
|
394
|
+
|
|
395
|
+
def get_status(self, language: str) -> LspServerStatus:
|
|
396
|
+
info = self._server_info.get(language)
|
|
397
|
+
return info.status if info else LspServerStatus.DISCONNECTED
|
|
398
|
+
|
|
399
|
+
def all_servers(self) -> dict[str, LspServerInfo]:
|
|
400
|
+
return dict(self._server_info)
|
|
401
|
+
|
|
402
|
+
def connected_languages(self) -> list[str]:
|
|
403
|
+
return [lang for lang, info in self._server_info.items()
|
|
404
|
+
if info.status == LspServerStatus.CONNECTED]
|
|
405
|
+
|
|
406
|
+
def add_diagnostics(self, diagnostics: list[LspDiagnostic]) -> None:
|
|
407
|
+
self._diagnostics.extend(diagnostics)
|
|
408
|
+
|
|
409
|
+
def get_diagnostics(self, path: str | None = None) -> list[LspDiagnostic]:
|
|
410
|
+
if path is None:
|
|
411
|
+
return list(self._diagnostics)
|
|
412
|
+
return [d for d in self._diagnostics if d.path == path]
|
|
413
|
+
|
|
414
|
+
def clear_diagnostics(self, path: str | None = None) -> None:
|
|
415
|
+
if path is None:
|
|
416
|
+
self._diagnostics.clear()
|
|
417
|
+
else:
|
|
418
|
+
self._diagnostics = [d for d in self._diagnostics if d.path != path]
|
|
419
|
+
|
|
420
|
+
async def shutdown_all(self) -> None:
|
|
421
|
+
for client in self._clients.values():
|
|
422
|
+
await client.shutdown()
|
|
423
|
+
self._clients.clear()
|
|
424
|
+
for info in self._server_info.values():
|
|
425
|
+
info.status = LspServerStatus.DISCONNECTED
|
|
File without changes
|