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.
@@ -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