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/types.py
ADDED
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
"""LSP 3.17 type definitions — hand-rolled subset."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import IntEnum
|
|
7
|
+
from typing import Any, Union
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Position:
|
|
12
|
+
line: int
|
|
13
|
+
character: int
|
|
14
|
+
|
|
15
|
+
def to_dict(self) -> dict:
|
|
16
|
+
return {"line": self.line, "character": self.character}
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def from_dict(cls, d: dict) -> Position:
|
|
20
|
+
return cls(line=d["line"], character=d["character"])
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Range:
|
|
25
|
+
start: Position
|
|
26
|
+
end: Position
|
|
27
|
+
|
|
28
|
+
def to_dict(self) -> dict:
|
|
29
|
+
return {"start": self.start.to_dict(), "end": self.end.to_dict()}
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_dict(cls, d: dict) -> Range:
|
|
33
|
+
return cls(start=Position.from_dict(d["start"]), end=Position.from_dict(d["end"]))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Location:
|
|
38
|
+
uri: str
|
|
39
|
+
range: Range
|
|
40
|
+
|
|
41
|
+
def to_dict(self) -> dict:
|
|
42
|
+
return {"uri": self.uri, "range": self.range.to_dict()}
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def from_dict(cls, d: dict) -> Location:
|
|
46
|
+
return cls(uri=d["uri"], range=Range.from_dict(d["range"]))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class SymbolKind(IntEnum):
|
|
50
|
+
File = 1
|
|
51
|
+
Module = 2
|
|
52
|
+
Namespace = 3
|
|
53
|
+
Package = 4
|
|
54
|
+
Class = 5
|
|
55
|
+
Method = 6
|
|
56
|
+
Property = 7
|
|
57
|
+
Field = 8
|
|
58
|
+
Constructor = 9
|
|
59
|
+
Enum = 10
|
|
60
|
+
Interface = 11
|
|
61
|
+
Function = 12
|
|
62
|
+
Variable = 13
|
|
63
|
+
Constant = 14
|
|
64
|
+
String = 15
|
|
65
|
+
Number = 16
|
|
66
|
+
Boolean = 17
|
|
67
|
+
Array = 18
|
|
68
|
+
Object = 19
|
|
69
|
+
Key = 20
|
|
70
|
+
Null = 21
|
|
71
|
+
EnumMember = 22
|
|
72
|
+
Struct = 23
|
|
73
|
+
Event = 24
|
|
74
|
+
Operator = 25
|
|
75
|
+
TypeParameter = 26
|
|
76
|
+
|
|
77
|
+
def display_name(self) -> str:
|
|
78
|
+
return self.name.lower()
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def from_value(cls, value: int) -> SymbolKind:
|
|
82
|
+
try:
|
|
83
|
+
return cls(value)
|
|
84
|
+
except ValueError:
|
|
85
|
+
# Return as-is for unknown values; store in Variable as fallback
|
|
86
|
+
return cls(value)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class DiagnosticSeverity(IntEnum):
|
|
90
|
+
Error = 1
|
|
91
|
+
Warning = 2
|
|
92
|
+
Information = 3
|
|
93
|
+
Hint = 4
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class SymbolInformation:
|
|
98
|
+
name: str
|
|
99
|
+
kind: SymbolKind
|
|
100
|
+
location: Location
|
|
101
|
+
container_name: str | None = None
|
|
102
|
+
|
|
103
|
+
def to_dict(self) -> dict:
|
|
104
|
+
d: dict[str, Any] = {
|
|
105
|
+
"name": self.name,
|
|
106
|
+
"kind": int(self.kind),
|
|
107
|
+
"location": self.location.to_dict(),
|
|
108
|
+
}
|
|
109
|
+
if self.container_name is not None:
|
|
110
|
+
d["containerName"] = self.container_name
|
|
111
|
+
return d
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def from_dict(cls, d: dict) -> SymbolInformation:
|
|
115
|
+
return cls(
|
|
116
|
+
name=d["name"],
|
|
117
|
+
kind=SymbolKind(d["kind"]),
|
|
118
|
+
location=Location.from_dict(d["location"]),
|
|
119
|
+
container_name=d.get("containerName"),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass
|
|
124
|
+
class DocumentSymbol:
|
|
125
|
+
name: str
|
|
126
|
+
kind: SymbolKind
|
|
127
|
+
range: Range
|
|
128
|
+
selection_range: Range
|
|
129
|
+
children: list[DocumentSymbol] | None = None
|
|
130
|
+
|
|
131
|
+
def to_dict(self) -> dict:
|
|
132
|
+
d: dict[str, Any] = {
|
|
133
|
+
"name": self.name,
|
|
134
|
+
"kind": int(self.kind),
|
|
135
|
+
"range": self.range.to_dict(),
|
|
136
|
+
"selectionRange": self.selection_range.to_dict(),
|
|
137
|
+
}
|
|
138
|
+
if self.children is not None:
|
|
139
|
+
d["children"] = [c.to_dict() for c in self.children]
|
|
140
|
+
return d
|
|
141
|
+
|
|
142
|
+
@classmethod
|
|
143
|
+
def from_dict(cls, d: dict) -> DocumentSymbol:
|
|
144
|
+
children = None
|
|
145
|
+
if "children" in d and d["children"] is not None:
|
|
146
|
+
children = [DocumentSymbol.from_dict(c) for c in d["children"]]
|
|
147
|
+
return cls(
|
|
148
|
+
name=d["name"],
|
|
149
|
+
kind=SymbolKind(d["kind"]),
|
|
150
|
+
range=Range.from_dict(d["range"]),
|
|
151
|
+
selection_range=Range.from_dict(d["selectionRange"]),
|
|
152
|
+
children=children,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass
|
|
157
|
+
class Diagnostic:
|
|
158
|
+
range: Range
|
|
159
|
+
message: str
|
|
160
|
+
severity: DiagnosticSeverity | None = None
|
|
161
|
+
code: Any = None
|
|
162
|
+
source: str | None = None
|
|
163
|
+
|
|
164
|
+
def to_dict(self) -> dict:
|
|
165
|
+
d: dict[str, Any] = {
|
|
166
|
+
"range": self.range.to_dict(),
|
|
167
|
+
"message": self.message,
|
|
168
|
+
}
|
|
169
|
+
if self.severity is not None:
|
|
170
|
+
d["severity"] = int(self.severity)
|
|
171
|
+
if self.code is not None:
|
|
172
|
+
d["code"] = self.code
|
|
173
|
+
if self.source is not None:
|
|
174
|
+
d["source"] = self.source
|
|
175
|
+
return d
|
|
176
|
+
|
|
177
|
+
@classmethod
|
|
178
|
+
def from_dict(cls, d: dict) -> Diagnostic:
|
|
179
|
+
severity = None
|
|
180
|
+
if "severity" in d and d["severity"] is not None:
|
|
181
|
+
severity = DiagnosticSeverity(d["severity"])
|
|
182
|
+
return cls(
|
|
183
|
+
range=Range.from_dict(d["range"]),
|
|
184
|
+
message=d["message"],
|
|
185
|
+
severity=severity,
|
|
186
|
+
code=d.get("code"),
|
|
187
|
+
source=d.get("source"),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@dataclass
|
|
192
|
+
class PublishDiagnosticsParams:
|
|
193
|
+
uri: str
|
|
194
|
+
diagnostics: list[Diagnostic]
|
|
195
|
+
|
|
196
|
+
@classmethod
|
|
197
|
+
def from_dict(cls, d: dict) -> PublishDiagnosticsParams:
|
|
198
|
+
return cls(
|
|
199
|
+
uri=d["uri"],
|
|
200
|
+
diagnostics=[Diagnostic.from_dict(diag) for diag in d["diagnostics"]],
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@dataclass
|
|
205
|
+
class TextEdit:
|
|
206
|
+
range: Range
|
|
207
|
+
new_text: str
|
|
208
|
+
|
|
209
|
+
def to_dict(self) -> dict:
|
|
210
|
+
return {"range": self.range.to_dict(), "newText": self.new_text}
|
|
211
|
+
|
|
212
|
+
@classmethod
|
|
213
|
+
def from_dict(cls, d: dict) -> TextEdit:
|
|
214
|
+
return cls(range=Range.from_dict(d["range"]), new_text=d["newText"])
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass
|
|
218
|
+
class VersionedTextDocumentIdentifier:
|
|
219
|
+
uri: str
|
|
220
|
+
version: int | None = None
|
|
221
|
+
|
|
222
|
+
def to_dict(self) -> dict:
|
|
223
|
+
d: dict[str, Any] = {"uri": self.uri}
|
|
224
|
+
if self.version is not None:
|
|
225
|
+
d["version"] = self.version
|
|
226
|
+
return d
|
|
227
|
+
|
|
228
|
+
@classmethod
|
|
229
|
+
def from_dict(cls, d: dict) -> VersionedTextDocumentIdentifier:
|
|
230
|
+
return cls(uri=d["uri"], version=d.get("version"))
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@dataclass
|
|
234
|
+
class TextDocumentEdit:
|
|
235
|
+
text_document: VersionedTextDocumentIdentifier
|
|
236
|
+
edits: list[TextEdit]
|
|
237
|
+
|
|
238
|
+
def to_dict(self) -> dict:
|
|
239
|
+
return {
|
|
240
|
+
"textDocument": self.text_document.to_dict(),
|
|
241
|
+
"edits": [e.to_dict() for e in self.edits],
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
@classmethod
|
|
245
|
+
def from_dict(cls, d: dict) -> TextDocumentEdit:
|
|
246
|
+
return cls(
|
|
247
|
+
text_document=VersionedTextDocumentIdentifier.from_dict(d["textDocument"]),
|
|
248
|
+
edits=[TextEdit.from_dict(e) for e in d["edits"]],
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@dataclass
|
|
253
|
+
class ResourceOperationCreate:
|
|
254
|
+
uri: str
|
|
255
|
+
kind: str = "create"
|
|
256
|
+
|
|
257
|
+
def to_dict(self) -> dict:
|
|
258
|
+
return {"kind": "create", "uri": self.uri}
|
|
259
|
+
|
|
260
|
+
@classmethod
|
|
261
|
+
def from_dict(cls, d: dict) -> ResourceOperationCreate:
|
|
262
|
+
return cls(uri=d["uri"])
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@dataclass
|
|
266
|
+
class ResourceOperationRename:
|
|
267
|
+
old_uri: str
|
|
268
|
+
new_uri: str
|
|
269
|
+
kind: str = "rename"
|
|
270
|
+
|
|
271
|
+
def to_dict(self) -> dict:
|
|
272
|
+
return {"kind": "rename", "oldUri": self.old_uri, "newUri": self.new_uri}
|
|
273
|
+
|
|
274
|
+
@classmethod
|
|
275
|
+
def from_dict(cls, d: dict) -> ResourceOperationRename:
|
|
276
|
+
return cls(old_uri=d["oldUri"], new_uri=d["newUri"])
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@dataclass
|
|
280
|
+
class ResourceOperationDelete:
|
|
281
|
+
uri: str
|
|
282
|
+
kind: str = "delete"
|
|
283
|
+
|
|
284
|
+
def to_dict(self) -> dict:
|
|
285
|
+
return {"kind": "delete", "uri": self.uri}
|
|
286
|
+
|
|
287
|
+
@classmethod
|
|
288
|
+
def from_dict(cls, d: dict) -> ResourceOperationDelete:
|
|
289
|
+
return cls(uri=d["uri"])
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
ResourceOperation = Union[ResourceOperationCreate, ResourceOperationRename, ResourceOperationDelete]
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def resource_operation_from_dict(d: dict) -> ResourceOperation:
|
|
296
|
+
kind = d["kind"]
|
|
297
|
+
if kind == "create":
|
|
298
|
+
return ResourceOperationCreate.from_dict(d)
|
|
299
|
+
elif kind == "rename":
|
|
300
|
+
return ResourceOperationRename.from_dict(d)
|
|
301
|
+
elif kind == "delete":
|
|
302
|
+
return ResourceOperationDelete.from_dict(d)
|
|
303
|
+
else:
|
|
304
|
+
raise ValueError(f"unknown resource operation kind: {kind}")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# DocumentChange is either a TextDocumentEdit or a ResourceOperation
|
|
308
|
+
DocumentChange = Union[TextDocumentEdit, ResourceOperation]
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def document_change_from_dict(d: dict) -> DocumentChange:
|
|
312
|
+
"""Parse a document change from LSP JSON. If it has 'kind', it's a resource op; otherwise a text edit."""
|
|
313
|
+
if "kind" in d:
|
|
314
|
+
return resource_operation_from_dict(d)
|
|
315
|
+
return TextDocumentEdit.from_dict(d)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@dataclass
|
|
319
|
+
class WorkspaceEdit:
|
|
320
|
+
changes: dict[str, list[TextEdit]] | None = None
|
|
321
|
+
document_changes: list[DocumentChange] | None = None
|
|
322
|
+
|
|
323
|
+
def to_dict(self) -> dict:
|
|
324
|
+
d: dict[str, Any] = {}
|
|
325
|
+
if self.changes is not None:
|
|
326
|
+
d["changes"] = {
|
|
327
|
+
uri: [e.to_dict() for e in edits] for uri, edits in self.changes.items()
|
|
328
|
+
}
|
|
329
|
+
if self.document_changes is not None:
|
|
330
|
+
d["documentChanges"] = [
|
|
331
|
+
c.to_dict() for c in self.document_changes # type: ignore[union-attr]
|
|
332
|
+
]
|
|
333
|
+
return d
|
|
334
|
+
|
|
335
|
+
@classmethod
|
|
336
|
+
def from_dict(cls, d: dict) -> WorkspaceEdit:
|
|
337
|
+
changes = None
|
|
338
|
+
if "changes" in d and d["changes"] is not None:
|
|
339
|
+
changes = {
|
|
340
|
+
uri: [TextEdit.from_dict(e) for e in edits]
|
|
341
|
+
for uri, edits in d["changes"].items()
|
|
342
|
+
}
|
|
343
|
+
document_changes = None
|
|
344
|
+
if "documentChanges" in d and d["documentChanges"] is not None:
|
|
345
|
+
document_changes = [document_change_from_dict(dc) for dc in d["documentChanges"]]
|
|
346
|
+
return cls(changes=changes, document_changes=document_changes)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
@dataclass
|
|
350
|
+
class MarkupContent:
|
|
351
|
+
kind: str
|
|
352
|
+
value: str
|
|
353
|
+
|
|
354
|
+
def to_dict(self) -> dict:
|
|
355
|
+
return {"kind": self.kind, "value": self.value}
|
|
356
|
+
|
|
357
|
+
@classmethod
|
|
358
|
+
def from_dict(cls, d: dict) -> MarkupContent:
|
|
359
|
+
return cls(kind=d["kind"], value=d["value"])
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# HoverContents: str | MarkupContent | list[str]
|
|
363
|
+
HoverContents = Union[str, MarkupContent, list[str]]
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def hover_contents_from_dict(d: Any) -> HoverContents:
|
|
367
|
+
if isinstance(d, str):
|
|
368
|
+
return d
|
|
369
|
+
if isinstance(d, list):
|
|
370
|
+
return [str(item) for item in d]
|
|
371
|
+
if isinstance(d, dict) and "kind" in d and "value" in d:
|
|
372
|
+
return MarkupContent.from_dict(d)
|
|
373
|
+
raise ValueError(f"cannot parse HoverContents from: {d!r}")
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
@dataclass
|
|
377
|
+
class Hover:
|
|
378
|
+
contents: HoverContents
|
|
379
|
+
range: Range | None = None
|
|
380
|
+
|
|
381
|
+
@classmethod
|
|
382
|
+
def from_dict(cls, d: dict) -> Hover:
|
|
383
|
+
range_ = Range.from_dict(d["range"]) if "range" in d and d["range"] is not None else None
|
|
384
|
+
return cls(contents=hover_contents_from_dict(d["contents"]), range=range_)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
@dataclass
|
|
388
|
+
class CallHierarchyItem:
|
|
389
|
+
name: str
|
|
390
|
+
kind: SymbolKind
|
|
391
|
+
uri: str
|
|
392
|
+
range: Range
|
|
393
|
+
selection_range: Range
|
|
394
|
+
|
|
395
|
+
def to_dict(self) -> dict:
|
|
396
|
+
return {
|
|
397
|
+
"name": self.name,
|
|
398
|
+
"kind": int(self.kind),
|
|
399
|
+
"uri": self.uri,
|
|
400
|
+
"range": self.range.to_dict(),
|
|
401
|
+
"selectionRange": self.selection_range.to_dict(),
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
@classmethod
|
|
405
|
+
def from_dict(cls, d: dict) -> CallHierarchyItem:
|
|
406
|
+
return cls(
|
|
407
|
+
name=d["name"],
|
|
408
|
+
kind=SymbolKind(d["kind"]),
|
|
409
|
+
uri=d["uri"],
|
|
410
|
+
range=Range.from_dict(d["range"]),
|
|
411
|
+
selection_range=Range.from_dict(d["selectionRange"]),
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
@dataclass
|
|
416
|
+
class CallHierarchyIncomingCall:
|
|
417
|
+
from_item: CallHierarchyItem
|
|
418
|
+
from_ranges: list[Range]
|
|
419
|
+
|
|
420
|
+
@classmethod
|
|
421
|
+
def from_dict(cls, d: dict) -> CallHierarchyIncomingCall:
|
|
422
|
+
return cls(
|
|
423
|
+
from_item=CallHierarchyItem.from_dict(d["from"]),
|
|
424
|
+
from_ranges=[Range.from_dict(r) for r in d["fromRanges"]],
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
@dataclass
|
|
429
|
+
class CallHierarchyOutgoingCall:
|
|
430
|
+
to: CallHierarchyItem
|
|
431
|
+
from_ranges: list[Range]
|
|
432
|
+
|
|
433
|
+
@classmethod
|
|
434
|
+
def from_dict(cls, d: dict) -> CallHierarchyOutgoingCall:
|
|
435
|
+
return cls(
|
|
436
|
+
to=CallHierarchyItem.from_dict(d["to"]),
|
|
437
|
+
from_ranges=[Range.from_dict(r) for r in d["fromRanges"]],
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
@dataclass
|
|
442
|
+
class CodeAction:
|
|
443
|
+
title: str
|
|
444
|
+
kind: str | None = None
|
|
445
|
+
edit: WorkspaceEdit | None = None
|
|
446
|
+
is_preferred: bool | None = None
|
|
447
|
+
|
|
448
|
+
@classmethod
|
|
449
|
+
def from_dict(cls, d: dict) -> CodeAction:
|
|
450
|
+
edit = WorkspaceEdit.from_dict(d["edit"]) if "edit" in d and d["edit"] is not None else None
|
|
451
|
+
return cls(
|
|
452
|
+
title=d["title"],
|
|
453
|
+
kind=d.get("kind"),
|
|
454
|
+
edit=edit,
|
|
455
|
+
is_preferred=d.get("isPreferred"),
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
@dataclass
|
|
460
|
+
class ServerCapabilities:
|
|
461
|
+
raw: dict = field(default_factory=dict)
|
|
462
|
+
|
|
463
|
+
def get(self, key: str) -> Any:
|
|
464
|
+
return self.raw.get(key)
|
|
465
|
+
|
|
466
|
+
@classmethod
|
|
467
|
+
def from_dict(cls, d: dict) -> ServerCapabilities:
|
|
468
|
+
return cls(raw=d)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
@dataclass
|
|
472
|
+
class InitializeResult:
|
|
473
|
+
capabilities: ServerCapabilities
|
|
474
|
+
|
|
475
|
+
@classmethod
|
|
476
|
+
def from_dict(cls, d: dict) -> InitializeResult:
|
|
477
|
+
return cls(capabilities=ServerCapabilities.from_dict(d.get("capabilities", {})))
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
@dataclass
|
|
481
|
+
class JsonRpcError:
|
|
482
|
+
code: int
|
|
483
|
+
message: str
|
|
484
|
+
data: Any = None
|
|
485
|
+
|
|
486
|
+
@classmethod
|
|
487
|
+
def from_dict(cls, d: dict) -> JsonRpcError:
|
|
488
|
+
return cls(code=d["code"], message=d["message"], data=d.get("data"))
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
@dataclass
|
|
492
|
+
class JsonRpcResponse:
|
|
493
|
+
id: Any
|
|
494
|
+
result: Any = None
|
|
495
|
+
error: JsonRpcError | None = None
|
|
496
|
+
|
|
497
|
+
@classmethod
|
|
498
|
+
def from_dict(cls, d: dict) -> JsonRpcResponse:
|
|
499
|
+
error = JsonRpcError.from_dict(d["error"]) if d.get("error") else None
|
|
500
|
+
return cls(id=d.get("id"), result=d.get("result"), error=error)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
@dataclass
|
|
504
|
+
class JsonRpcNotification:
|
|
505
|
+
method: str
|
|
506
|
+
params: Any = None
|
|
507
|
+
|
|
508
|
+
@classmethod
|
|
509
|
+
def from_dict(cls, d: dict) -> JsonRpcNotification:
|
|
510
|
+
return cls(method=d["method"], params=d.get("params"))
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Apply WorkspaceEdit to the filesystem — pure client-side logic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from urllib.parse import unquote, urlparse
|
|
9
|
+
|
|
10
|
+
from .types import (
|
|
11
|
+
Position,
|
|
12
|
+
ResourceOperationCreate,
|
|
13
|
+
ResourceOperationDelete,
|
|
14
|
+
ResourceOperationRename,
|
|
15
|
+
TextDocumentEdit,
|
|
16
|
+
TextEdit,
|
|
17
|
+
WorkspaceEdit,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ApplyResult:
|
|
23
|
+
"""Result of applying a WorkspaceEdit to the filesystem."""
|
|
24
|
+
|
|
25
|
+
files_changed: list[tuple[str, int]] = field(default_factory=list)
|
|
26
|
+
files_created: list[str] = field(default_factory=list)
|
|
27
|
+
files_renamed: list[tuple[str, str]] = field(default_factory=list)
|
|
28
|
+
|
|
29
|
+
def total_edits(self) -> int:
|
|
30
|
+
return sum(count for _, count in self.files_changed)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def position_to_offset(content: str, pos: Position) -> int | None:
|
|
34
|
+
"""Convert an LSP Position (line, character) to a string offset."""
|
|
35
|
+
offset = 0
|
|
36
|
+
for i, line in enumerate(content.split("\n")):
|
|
37
|
+
if i == pos.line:
|
|
38
|
+
clamped = min(pos.character, len(line))
|
|
39
|
+
return offset + clamped
|
|
40
|
+
offset += len(line) + 1 # +1 for '\n'
|
|
41
|
+
# Position beyond end of file
|
|
42
|
+
return len(content)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def apply_text_edits(content: str, edits: list[TextEdit]) -> str:
|
|
46
|
+
"""Apply text edits to a string. Edits are applied in reverse offset order."""
|
|
47
|
+
if not edits:
|
|
48
|
+
return content
|
|
49
|
+
|
|
50
|
+
# Sort by start position descending (reverse order)
|
|
51
|
+
sorted_edits = sorted(
|
|
52
|
+
edits,
|
|
53
|
+
key=lambda e: (e.range.start.line, e.range.start.character),
|
|
54
|
+
reverse=True,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
result = content
|
|
58
|
+
for edit in sorted_edits:
|
|
59
|
+
start = position_to_offset(result, edit.range.start)
|
|
60
|
+
end = position_to_offset(result, edit.range.end)
|
|
61
|
+
if start is not None and end is not None:
|
|
62
|
+
result = result[:start] + edit.new_text + result[end:]
|
|
63
|
+
return result
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def uri_to_path(uri: str) -> Path | None:
|
|
67
|
+
"""Convert a file:// URI to a filesystem Path."""
|
|
68
|
+
parsed = urlparse(uri)
|
|
69
|
+
if parsed.scheme != "file":
|
|
70
|
+
return None
|
|
71
|
+
return Path(unquote(parsed.path))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def apply_workspace_edit(edit: WorkspaceEdit) -> ApplyResult:
|
|
75
|
+
"""Apply a WorkspaceEdit to disk files."""
|
|
76
|
+
result = ApplyResult()
|
|
77
|
+
|
|
78
|
+
if edit.document_changes is not None:
|
|
79
|
+
for change in edit.document_changes:
|
|
80
|
+
if isinstance(change, TextDocumentEdit):
|
|
81
|
+
path = uri_to_path(change.text_document.uri)
|
|
82
|
+
if path is None:
|
|
83
|
+
raise ValueError(f"invalid URI: {change.text_document.uri}")
|
|
84
|
+
content = path.read_text()
|
|
85
|
+
new_content = apply_text_edits(content, change.edits)
|
|
86
|
+
path.write_text(new_content)
|
|
87
|
+
result.files_changed.append((change.text_document.uri, len(change.edits)))
|
|
88
|
+
elif isinstance(change, ResourceOperationCreate):
|
|
89
|
+
path = uri_to_path(change.uri)
|
|
90
|
+
if path is not None:
|
|
91
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
path.write_text("")
|
|
93
|
+
result.files_created.append(change.uri)
|
|
94
|
+
elif isinstance(change, ResourceOperationRename):
|
|
95
|
+
old_path = uri_to_path(change.old_uri)
|
|
96
|
+
new_path = uri_to_path(change.new_uri)
|
|
97
|
+
if old_path is not None and new_path is not None:
|
|
98
|
+
new_path.parent.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
os.rename(old_path, new_path)
|
|
100
|
+
result.files_renamed.append((change.old_uri, change.new_uri))
|
|
101
|
+
elif isinstance(change, ResourceOperationDelete):
|
|
102
|
+
path = uri_to_path(change.uri)
|
|
103
|
+
if path is not None and path.exists():
|
|
104
|
+
path.unlink()
|
|
105
|
+
elif edit.changes is not None:
|
|
106
|
+
for uri, edits in edit.changes.items():
|
|
107
|
+
path = uri_to_path(uri)
|
|
108
|
+
if path is None:
|
|
109
|
+
raise ValueError(f"invalid URI: {uri}")
|
|
110
|
+
content = path.read_text()
|
|
111
|
+
new_content = apply_text_edits(content, edits)
|
|
112
|
+
path.write_text(new_content)
|
|
113
|
+
result.files_changed.append((uri, len(edits)))
|
|
114
|
+
|
|
115
|
+
return result
|