fcp-python 0.1.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.
- fcp_python/__init__.py +1 -0
- fcp_python/bridge.py +195 -0
- fcp_python/domain/__init__.py +0 -0
- fcp_python/domain/format.py +221 -0
- fcp_python/domain/model.py +42 -0
- fcp_python/domain/mutation.py +393 -0
- fcp_python/domain/query.py +627 -0
- fcp_python/domain/verbs.py +37 -0
- fcp_python/lsp/__init__.py +1 -0
- fcp_python/lsp/client.py +196 -0
- fcp_python/lsp/lifecycle.py +89 -0
- fcp_python/lsp/transport.py +105 -0
- fcp_python/lsp/types.py +510 -0
- fcp_python/lsp/workspace_edit.py +115 -0
- fcp_python/main.py +288 -0
- fcp_python/resolver/__init__.py +25 -0
- fcp_python/resolver/index.py +55 -0
- fcp_python/resolver/pipeline.py +105 -0
- fcp_python/resolver/selectors.py +161 -0
- fcp_python-0.1.0.dist-info/METADATA +8 -0
- fcp_python-0.1.0.dist-info/RECORD +23 -0
- fcp_python-0.1.0.dist-info/WHEEL +4 -0
- fcp_python-0.1.0.dist-info/entry_points.txt +2 -0
fcp_python/lsp/client.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""LSP client implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .transport import LspWriter, read_loop
|
|
11
|
+
from .types import InitializeResult, JsonRpcNotification, ServerCapabilities
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LspError(Exception):
|
|
17
|
+
"""Error from LSP server."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, code: int, message: str) -> None:
|
|
20
|
+
self.code = code
|
|
21
|
+
super().__init__(f"LSP error {code}: {message}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LspClient:
|
|
25
|
+
"""JSON-RPC client for communicating with an LSP server subprocess."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
process: asyncio.subprocess.Process,
|
|
30
|
+
writer: LspWriter,
|
|
31
|
+
pending: dict[str, asyncio.Future],
|
|
32
|
+
pending_lock: asyncio.Lock,
|
|
33
|
+
notification_queue: asyncio.Queue[JsonRpcNotification],
|
|
34
|
+
read_task: asyncio.Task,
|
|
35
|
+
server_capabilities: ServerCapabilities | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
self._process = process
|
|
38
|
+
self._writer = writer
|
|
39
|
+
self._pending = pending
|
|
40
|
+
self._pending_lock = pending_lock
|
|
41
|
+
self._notification_queue = notification_queue
|
|
42
|
+
self._read_task = read_task
|
|
43
|
+
self._next_id = 1
|
|
44
|
+
self.server_capabilities = server_capabilities
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
async def spawn(cls, command: str, args: list[str], root_uri: str) -> LspClient:
|
|
48
|
+
"""Spawn an LSP server process and perform the initialize handshake."""
|
|
49
|
+
process = await asyncio.create_subprocess_exec(
|
|
50
|
+
command,
|
|
51
|
+
*args,
|
|
52
|
+
stdin=asyncio.subprocess.PIPE,
|
|
53
|
+
stdout=asyncio.subprocess.PIPE,
|
|
54
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
assert process.stdin is not None
|
|
58
|
+
assert process.stdout is not None
|
|
59
|
+
|
|
60
|
+
writer = LspWriter(process.stdin)
|
|
61
|
+
pending: dict[str, asyncio.Future] = {}
|
|
62
|
+
pending_lock = asyncio.Lock()
|
|
63
|
+
notification_queue: asyncio.Queue[JsonRpcNotification] = asyncio.Queue(maxsize=64)
|
|
64
|
+
|
|
65
|
+
reader = process.stdout
|
|
66
|
+
read_task = asyncio.create_task(
|
|
67
|
+
read_loop(reader, pending, notification_queue, pending_lock)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
client = cls(
|
|
71
|
+
process=process,
|
|
72
|
+
writer=writer,
|
|
73
|
+
pending=pending,
|
|
74
|
+
pending_lock=pending_lock,
|
|
75
|
+
notification_queue=notification_queue,
|
|
76
|
+
read_task=read_task,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Initialize handshake
|
|
80
|
+
caps = await client._initialize(root_uri)
|
|
81
|
+
client.server_capabilities = caps
|
|
82
|
+
|
|
83
|
+
# Send initialized notification
|
|
84
|
+
await client.notify("initialized", {})
|
|
85
|
+
|
|
86
|
+
return client
|
|
87
|
+
|
|
88
|
+
async def _initialize(self, root_uri: str) -> ServerCapabilities:
|
|
89
|
+
params = {
|
|
90
|
+
"processId": os.getpid(),
|
|
91
|
+
"rootUri": root_uri,
|
|
92
|
+
"capabilities": {
|
|
93
|
+
"textDocument": {
|
|
94
|
+
"codeAction": {
|
|
95
|
+
"codeActionLiteralSupport": {
|
|
96
|
+
"codeActionKind": {
|
|
97
|
+
"valueSet": [
|
|
98
|
+
"quickfix",
|
|
99
|
+
"refactor",
|
|
100
|
+
"refactor.extract",
|
|
101
|
+
"refactor.inline",
|
|
102
|
+
"refactor.rewrite",
|
|
103
|
+
"source",
|
|
104
|
+
"source.organizeImports",
|
|
105
|
+
]
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
"rename": {"prepareSupport": False},
|
|
110
|
+
},
|
|
111
|
+
"workspace": {
|
|
112
|
+
"applyEdit": True,
|
|
113
|
+
"workspaceEdit": {
|
|
114
|
+
"documentChanges": True,
|
|
115
|
+
"resourceOperations": ["create", "rename", "delete"],
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
result = await self.request("initialize", params)
|
|
122
|
+
init_result = InitializeResult.from_dict(result)
|
|
123
|
+
return init_result.capabilities
|
|
124
|
+
|
|
125
|
+
async def request(self, method: str, params: Any) -> Any:
|
|
126
|
+
"""Send a JSON-RPC request and await the response."""
|
|
127
|
+
req_id = self._next_id
|
|
128
|
+
self._next_id += 1
|
|
129
|
+
|
|
130
|
+
loop = asyncio.get_running_loop()
|
|
131
|
+
fut: asyncio.Future = loop.create_future()
|
|
132
|
+
|
|
133
|
+
async with self._pending_lock:
|
|
134
|
+
self._pending[str(req_id)] = fut
|
|
135
|
+
|
|
136
|
+
await self._writer.send_request(req_id, method, params)
|
|
137
|
+
|
|
138
|
+
resp = await fut
|
|
139
|
+
|
|
140
|
+
if resp.error is not None:
|
|
141
|
+
raise LspError(resp.error.code, resp.error.message)
|
|
142
|
+
|
|
143
|
+
return resp.result
|
|
144
|
+
|
|
145
|
+
async def notify(self, method: str, params: Any) -> None:
|
|
146
|
+
"""Send a JSON-RPC notification (no response expected)."""
|
|
147
|
+
await self._writer.send_notification(method, params)
|
|
148
|
+
|
|
149
|
+
async def did_open(self, uri: str, text: str) -> None:
|
|
150
|
+
"""Send textDocument/didOpen notification."""
|
|
151
|
+
params = {
|
|
152
|
+
"textDocument": {
|
|
153
|
+
"uri": uri,
|
|
154
|
+
"languageId": "python",
|
|
155
|
+
"version": 1,
|
|
156
|
+
"text": text,
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
await self.notify("textDocument/didOpen", params)
|
|
160
|
+
|
|
161
|
+
async def did_change(self, uri: str, version: int, text: str) -> None:
|
|
162
|
+
"""Send textDocument/didChange notification (full sync)."""
|
|
163
|
+
params = {
|
|
164
|
+
"textDocument": {"uri": uri, "version": version},
|
|
165
|
+
"contentChanges": [{"text": text}],
|
|
166
|
+
}
|
|
167
|
+
await self.notify("textDocument/didChange", params)
|
|
168
|
+
|
|
169
|
+
async def did_close(self, uri: str) -> None:
|
|
170
|
+
"""Send textDocument/didClose notification."""
|
|
171
|
+
params = {"textDocument": {"uri": uri}}
|
|
172
|
+
await self.notify("textDocument/didClose", params)
|
|
173
|
+
|
|
174
|
+
async def shutdown(self) -> None:
|
|
175
|
+
"""Send shutdown request and exit notification, then wait for process."""
|
|
176
|
+
try:
|
|
177
|
+
await self.request("shutdown", None)
|
|
178
|
+
except Exception:
|
|
179
|
+
pass
|
|
180
|
+
try:
|
|
181
|
+
await self.notify("exit", None)
|
|
182
|
+
except Exception:
|
|
183
|
+
pass
|
|
184
|
+
try:
|
|
185
|
+
await self._process.wait()
|
|
186
|
+
except Exception:
|
|
187
|
+
pass
|
|
188
|
+
self._read_task.cancel()
|
|
189
|
+
try:
|
|
190
|
+
await self._read_task
|
|
191
|
+
except asyncio.CancelledError:
|
|
192
|
+
pass
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def notification_queue(self) -> asyncio.Queue[JsonRpcNotification]:
|
|
196
|
+
return self._notification_queue
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""LSP server lifecycle manager with crash recovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from enum import Enum, auto
|
|
8
|
+
|
|
9
|
+
from .client import LspClient
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ServerStatus(Enum):
|
|
15
|
+
NotStarted = auto()
|
|
16
|
+
Starting = auto()
|
|
17
|
+
Ready = auto()
|
|
18
|
+
Indexing = auto()
|
|
19
|
+
Crashed = auto()
|
|
20
|
+
Stopped = auto()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LifecycleManager:
|
|
24
|
+
"""Manages LSP server lifecycle including crash recovery and document replay."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
command: str,
|
|
29
|
+
args: list[str],
|
|
30
|
+
root_uri: str,
|
|
31
|
+
max_restarts: int = 3,
|
|
32
|
+
) -> None:
|
|
33
|
+
self._command = command
|
|
34
|
+
self._args = args
|
|
35
|
+
self._root_uri = root_uri
|
|
36
|
+
self._client: LspClient | None = None
|
|
37
|
+
self._status = ServerStatus.NotStarted
|
|
38
|
+
self._restart_count = 0
|
|
39
|
+
self._max_restarts = max_restarts
|
|
40
|
+
self._last_restart: float | None = None
|
|
41
|
+
self._tracked_documents: dict[str, str] = {}
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def status(self) -> ServerStatus:
|
|
45
|
+
return self._status
|
|
46
|
+
|
|
47
|
+
async def ensure_client(self) -> LspClient:
|
|
48
|
+
"""Ensure the LSP client is running. Starts or restarts if needed."""
|
|
49
|
+
if self._client is not None and self._status == ServerStatus.Ready:
|
|
50
|
+
return self._client
|
|
51
|
+
|
|
52
|
+
if self._status == ServerStatus.Crashed and self._restart_count >= self._max_restarts:
|
|
53
|
+
raise RuntimeError("max restarts exceeded")
|
|
54
|
+
|
|
55
|
+
self._status = ServerStatus.Starting
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
client = await LspClient.spawn(self._command, self._args, self._root_uri)
|
|
59
|
+
self._client = client
|
|
60
|
+
self._status = ServerStatus.Ready
|
|
61
|
+
self._last_restart = time.monotonic()
|
|
62
|
+
|
|
63
|
+
# Replay tracked documents
|
|
64
|
+
for uri, text in list(self._tracked_documents.items()):
|
|
65
|
+
try:
|
|
66
|
+
await client.did_open(uri, text)
|
|
67
|
+
except Exception:
|
|
68
|
+
logger.warning("failed to replay document: %s", uri)
|
|
69
|
+
|
|
70
|
+
return client
|
|
71
|
+
except Exception as e:
|
|
72
|
+
self._status = ServerStatus.Crashed
|
|
73
|
+
self._restart_count += 1
|
|
74
|
+
raise RuntimeError(f"failed to start LSP server: {e}") from e
|
|
75
|
+
|
|
76
|
+
def track_document(self, uri: str, text: str) -> None:
|
|
77
|
+
"""Track a document for replay on restart."""
|
|
78
|
+
self._tracked_documents[uri] = text
|
|
79
|
+
|
|
80
|
+
def untrack_document(self, uri: str) -> None:
|
|
81
|
+
"""Untrack a document."""
|
|
82
|
+
self._tracked_documents.pop(uri, None)
|
|
83
|
+
|
|
84
|
+
async def shutdown(self) -> None:
|
|
85
|
+
"""Shutdown the LSP server gracefully."""
|
|
86
|
+
if self._client is not None:
|
|
87
|
+
await self._client.shutdown()
|
|
88
|
+
self._client = None
|
|
89
|
+
self._status = ServerStatus.Stopped
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Content-Length framed LSP transport."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .types import JsonRpcNotification, JsonRpcResponse
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def encode_message(body: bytes) -> bytes:
|
|
16
|
+
"""Prepend Content-Length header to a message body."""
|
|
17
|
+
header = f"Content-Length: {len(body)}\r\n\r\n".encode("ascii")
|
|
18
|
+
return header + body
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def decode_message(reader: asyncio.StreamReader) -> dict:
|
|
22
|
+
"""Read a Content-Length framed message and parse as JSON."""
|
|
23
|
+
content_length: int | None = None
|
|
24
|
+
|
|
25
|
+
# Read headers line by line
|
|
26
|
+
while True:
|
|
27
|
+
line = await reader.readline()
|
|
28
|
+
if not line:
|
|
29
|
+
raise ConnectionError("unexpected EOF reading headers")
|
|
30
|
+
|
|
31
|
+
line_str = line.decode("ascii", errors="replace").strip()
|
|
32
|
+
if not line_str:
|
|
33
|
+
# Empty line = end of headers
|
|
34
|
+
break
|
|
35
|
+
|
|
36
|
+
if line_str.lower().startswith("content-length:"):
|
|
37
|
+
value = line_str.split(":", 1)[1].strip()
|
|
38
|
+
try:
|
|
39
|
+
content_length = int(value)
|
|
40
|
+
except ValueError:
|
|
41
|
+
raise ValueError(f"invalid Content-Length: {value}")
|
|
42
|
+
|
|
43
|
+
if content_length is None:
|
|
44
|
+
raise ValueError("missing Content-Length header")
|
|
45
|
+
|
|
46
|
+
body = await reader.readexactly(content_length)
|
|
47
|
+
return json.loads(body)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class LspWriter:
|
|
51
|
+
"""Writer wrapper for sending LSP messages with Content-Length framing."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, writer: asyncio.StreamWriter) -> None:
|
|
54
|
+
self._writer = writer
|
|
55
|
+
self._lock = asyncio.Lock()
|
|
56
|
+
|
|
57
|
+
async def send_request(self, id: Any, method: str, params: Any) -> None:
|
|
58
|
+
msg = {"jsonrpc": "2.0", "id": id, "method": method, "params": params}
|
|
59
|
+
body = json.dumps(msg).encode("utf-8")
|
|
60
|
+
frame = encode_message(body)
|
|
61
|
+
async with self._lock:
|
|
62
|
+
self._writer.write(frame)
|
|
63
|
+
await self._writer.drain()
|
|
64
|
+
|
|
65
|
+
async def send_notification(self, method: str, params: Any) -> None:
|
|
66
|
+
msg = {"jsonrpc": "2.0", "method": method, "params": params}
|
|
67
|
+
body = json.dumps(msg).encode("utf-8")
|
|
68
|
+
frame = encode_message(body)
|
|
69
|
+
async with self._lock:
|
|
70
|
+
self._writer.write(frame)
|
|
71
|
+
await self._writer.drain()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def read_loop(
|
|
75
|
+
reader: asyncio.StreamReader,
|
|
76
|
+
pending: dict[str, asyncio.Future],
|
|
77
|
+
notification_queue: asyncio.Queue[JsonRpcNotification],
|
|
78
|
+
pending_lock: asyncio.Lock,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Dispatch incoming messages to pending futures or notification queue."""
|
|
81
|
+
while True:
|
|
82
|
+
try:
|
|
83
|
+
msg = await decode_message(reader)
|
|
84
|
+
except (ConnectionError, asyncio.IncompleteReadError):
|
|
85
|
+
return # EOF or read error
|
|
86
|
+
except Exception:
|
|
87
|
+
logger.debug("read_loop: error decoding message", exc_info=True)
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
# Response: has "id" and no "method"
|
|
91
|
+
if "id" in msg and "method" not in msg:
|
|
92
|
+
resp = JsonRpcResponse.from_dict(msg)
|
|
93
|
+
id_str = str(resp.id)
|
|
94
|
+
async with pending_lock:
|
|
95
|
+
fut = pending.pop(id_str, None)
|
|
96
|
+
if fut is not None and not fut.done():
|
|
97
|
+
fut.set_result(resp)
|
|
98
|
+
elif "method" in msg and "id" not in msg:
|
|
99
|
+
# Notification
|
|
100
|
+
notif = JsonRpcNotification.from_dict(msg)
|
|
101
|
+
try:
|
|
102
|
+
notification_queue.put_nowait(notif)
|
|
103
|
+
except asyncio.QueueFull:
|
|
104
|
+
logger.warning("notification queue full, dropping: %s", notif.method)
|
|
105
|
+
# Server requests (both id and method) are ignored for now
|