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
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""Mutation dispatcher and handlers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from urllib.parse import unquote, urlparse
|
|
7
|
+
|
|
8
|
+
from fcp_core import VerbRegistry, parse_op, suggest, ParseError
|
|
9
|
+
|
|
10
|
+
from fcp_python.lsp.types import (
|
|
11
|
+
CodeAction,
|
|
12
|
+
WorkspaceEdit,
|
|
13
|
+
)
|
|
14
|
+
from fcp_python.lsp.workspace_edit import apply_workspace_edit, ApplyResult
|
|
15
|
+
from fcp_python.resolver.selectors import (
|
|
16
|
+
SelectorType,
|
|
17
|
+
parse_line_range,
|
|
18
|
+
parse_selector,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from .format import (
|
|
22
|
+
format_code_action_choices,
|
|
23
|
+
format_disambiguation,
|
|
24
|
+
format_error,
|
|
25
|
+
format_mutation_result,
|
|
26
|
+
)
|
|
27
|
+
from .model import PythonModel
|
|
28
|
+
from .query import resolve_with_fallback
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def dispatch_mutation(
|
|
32
|
+
model: PythonModel,
|
|
33
|
+
registry: VerbRegistry,
|
|
34
|
+
input_str: str,
|
|
35
|
+
) -> str:
|
|
36
|
+
"""Dispatch a mutation operation string to the appropriate handler."""
|
|
37
|
+
op = parse_op(input_str)
|
|
38
|
+
if isinstance(op, ParseError):
|
|
39
|
+
return format_error(f"parse error: {op.error}", None)
|
|
40
|
+
|
|
41
|
+
if registry.lookup(op.verb) is None:
|
|
42
|
+
verb_names = [v.verb for v in registry.verbs]
|
|
43
|
+
suggestion = suggest(op.verb, verb_names)
|
|
44
|
+
return format_error(f"unknown verb '{op.verb}'.", suggestion)
|
|
45
|
+
|
|
46
|
+
if model.lsp_client is None:
|
|
47
|
+
return format_error("no workspace open. Use python_session open PATH first.", None)
|
|
48
|
+
|
|
49
|
+
match op.verb:
|
|
50
|
+
case "rename":
|
|
51
|
+
return await handle_rename(model, op.positionals, op.selectors)
|
|
52
|
+
case "extract":
|
|
53
|
+
return await handle_extract(model, op.positionals, op.selectors)
|
|
54
|
+
case "import":
|
|
55
|
+
return await handle_import(model, op.positionals, op.selectors)
|
|
56
|
+
case _:
|
|
57
|
+
return format_error(f"verb '{op.verb}' is not a mutation.", None)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def ensure_file_synced(model: PythonModel, uri: str) -> None:
|
|
61
|
+
"""Open a file in LSP if not already open, to sync with disk."""
|
|
62
|
+
client = model.lsp_client
|
|
63
|
+
if client is None:
|
|
64
|
+
return
|
|
65
|
+
parsed = urlparse(uri)
|
|
66
|
+
if parsed.scheme != "file":
|
|
67
|
+
return
|
|
68
|
+
path = Path(unquote(parsed.path))
|
|
69
|
+
text = path.read_text()
|
|
70
|
+
await client.did_open(uri, text)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def sync_after_edit(model: PythonModel, result: ApplyResult) -> None:
|
|
74
|
+
"""After applying a WorkspaceEdit, sync all changed files with LSP."""
|
|
75
|
+
for uri, _ in result.files_changed:
|
|
76
|
+
await ensure_file_synced(model, uri)
|
|
77
|
+
for uri in result.files_created:
|
|
78
|
+
await ensure_file_synced(model, uri)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def file_uri(model: PythonModel, file_value: str) -> str:
|
|
82
|
+
"""Build a file URI from a selector value, using model root as base."""
|
|
83
|
+
if file_value.startswith("file://"):
|
|
84
|
+
return file_value
|
|
85
|
+
return f"{model.root_uri.rstrip('/')}/{file_value}"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# -- rename ---------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
async def handle_rename(
|
|
91
|
+
model: PythonModel,
|
|
92
|
+
positionals: list[str],
|
|
93
|
+
selectors: list[str],
|
|
94
|
+
) -> str:
|
|
95
|
+
if len(positionals) < 2:
|
|
96
|
+
return format_error("rename requires SYMBOL and NEW_NAME.", None)
|
|
97
|
+
old_name = positionals[0]
|
|
98
|
+
new_name = positionals[1]
|
|
99
|
+
|
|
100
|
+
parsed_selectors = [s for s in (parse_selector(sel) for sel in selectors) if s is not None]
|
|
101
|
+
resolved = await resolve_with_fallback(model, old_name, parsed_selectors)
|
|
102
|
+
|
|
103
|
+
if resolved.is_ambiguous:
|
|
104
|
+
return format_disambiguation(old_name, resolved.entries)
|
|
105
|
+
if resolved.is_not_found:
|
|
106
|
+
return format_error(f"symbol '{old_name}' not found.", None)
|
|
107
|
+
entry = resolved.entry
|
|
108
|
+
|
|
109
|
+
client = model.lsp_client
|
|
110
|
+
assert client is not None
|
|
111
|
+
|
|
112
|
+
params = {
|
|
113
|
+
"textDocument": {"uri": entry.uri},
|
|
114
|
+
"position": {
|
|
115
|
+
"line": entry.selection_range.start.line,
|
|
116
|
+
"character": entry.selection_range.start.character,
|
|
117
|
+
},
|
|
118
|
+
"newName": new_name,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
raw_edit = await client.request("textDocument/rename", params)
|
|
123
|
+
except Exception as e:
|
|
124
|
+
return format_error(f"rename failed: {e}", None)
|
|
125
|
+
|
|
126
|
+
if raw_edit is None:
|
|
127
|
+
return format_error("rename returned no edit.", None)
|
|
128
|
+
|
|
129
|
+
workspace_edit = WorkspaceEdit.from_dict(raw_edit)
|
|
130
|
+
try:
|
|
131
|
+
result = apply_workspace_edit(workspace_edit)
|
|
132
|
+
except Exception as e:
|
|
133
|
+
return format_error(f"failed to apply rename: {e}", None)
|
|
134
|
+
|
|
135
|
+
return format_mutation_result(
|
|
136
|
+
"rename", f"{old_name} → {new_name}", result, model.root_uri
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# -- extract ---------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
async def handle_extract(
|
|
143
|
+
model: PythonModel,
|
|
144
|
+
positionals: list[str],
|
|
145
|
+
selectors: list[str],
|
|
146
|
+
) -> str:
|
|
147
|
+
if not positionals:
|
|
148
|
+
return format_error("extract requires FUNC_NAME.", None)
|
|
149
|
+
func_name = positionals[0]
|
|
150
|
+
|
|
151
|
+
parsed_selectors = [s for s in (parse_selector(sel) for sel in selectors) if s is not None]
|
|
152
|
+
|
|
153
|
+
file_sel = next((s for s in parsed_selectors if s.selector_type == SelectorType.FILE), None)
|
|
154
|
+
lines_sel = next((s for s in parsed_selectors if s.selector_type == SelectorType.LINES), None)
|
|
155
|
+
|
|
156
|
+
if file_sel is None:
|
|
157
|
+
return format_error("extract requires @file:PATH selector.", None)
|
|
158
|
+
if lines_sel is None:
|
|
159
|
+
return format_error("extract requires @lines:N-M selector.", None)
|
|
160
|
+
|
|
161
|
+
line_range = parse_line_range(lines_sel.value)
|
|
162
|
+
if line_range is None:
|
|
163
|
+
return format_error(
|
|
164
|
+
f"invalid line range '{lines_sel.value}'. Use @lines:N-M.", None
|
|
165
|
+
)
|
|
166
|
+
start_line, end_line = line_range
|
|
167
|
+
|
|
168
|
+
uri = file_uri(model, file_sel.value)
|
|
169
|
+
# Convert 1-indexed user lines to 0-indexed LSP
|
|
170
|
+
lsp_start = start_line - 1 if start_line > 0 else start_line
|
|
171
|
+
lsp_end = end_line - 1 if end_line > 0 else end_line
|
|
172
|
+
|
|
173
|
+
client = model.lsp_client
|
|
174
|
+
assert client is not None
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
await ensure_file_synced(model, uri)
|
|
178
|
+
except Exception as e:
|
|
179
|
+
return format_error(f"extract: {e}", None)
|
|
180
|
+
|
|
181
|
+
params = {
|
|
182
|
+
"textDocument": {"uri": uri},
|
|
183
|
+
"range": {
|
|
184
|
+
"start": {"line": lsp_start, "character": 0},
|
|
185
|
+
"end": {"line": lsp_end, "character": 999},
|
|
186
|
+
},
|
|
187
|
+
"context": {
|
|
188
|
+
"diagnostics": [],
|
|
189
|
+
"only": ["refactor.extract.function", "refactor.extract"],
|
|
190
|
+
"triggerKind": 1,
|
|
191
|
+
},
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
raw_actions = await client.request("textDocument/codeAction", params)
|
|
196
|
+
except Exception as e:
|
|
197
|
+
return format_error(f"extract failed: {e}", None)
|
|
198
|
+
|
|
199
|
+
if not raw_actions:
|
|
200
|
+
raw_actions = []
|
|
201
|
+
|
|
202
|
+
actions = [CodeAction.from_dict(a) for a in raw_actions]
|
|
203
|
+
extract_actions = [
|
|
204
|
+
a for a in actions
|
|
205
|
+
if a.kind and a.kind.startswith("refactor.extract")
|
|
206
|
+
]
|
|
207
|
+
|
|
208
|
+
if not extract_actions:
|
|
209
|
+
return format_error("no extract action available for the selected range.", None)
|
|
210
|
+
|
|
211
|
+
if len(extract_actions) == 1:
|
|
212
|
+
action = extract_actions[0]
|
|
213
|
+
else:
|
|
214
|
+
# Prefer "Extract into function" over others
|
|
215
|
+
func_action = next(
|
|
216
|
+
(a for a in extract_actions if "function" in a.title.lower()), None
|
|
217
|
+
)
|
|
218
|
+
if func_action:
|
|
219
|
+
action = func_action
|
|
220
|
+
else:
|
|
221
|
+
preferred = next((a for a in extract_actions if a.is_preferred), None)
|
|
222
|
+
if preferred:
|
|
223
|
+
action = preferred
|
|
224
|
+
else:
|
|
225
|
+
return format_code_action_choices(extract_actions)
|
|
226
|
+
|
|
227
|
+
if action.edit is None:
|
|
228
|
+
return format_error("extract action has no edit.", None)
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
apply_result = apply_workspace_edit(action.edit)
|
|
232
|
+
except Exception as e:
|
|
233
|
+
return format_error(f"failed to apply extract: {e}", None)
|
|
234
|
+
|
|
235
|
+
# Sync changed files
|
|
236
|
+
try:
|
|
237
|
+
await sync_after_edit(model, apply_result)
|
|
238
|
+
except Exception:
|
|
239
|
+
pass
|
|
240
|
+
|
|
241
|
+
# Follow-up rename: pylsp/rope generates a placeholder name.
|
|
242
|
+
rename_result = await _follow_up_rename(model, uri, func_name)
|
|
243
|
+
|
|
244
|
+
if rename_result is not None:
|
|
245
|
+
return format_mutation_result("extract", func_name, rename_result, model.root_uri)
|
|
246
|
+
return format_mutation_result("extract", func_name, apply_result, model.root_uri)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
async def _follow_up_rename(
|
|
250
|
+
model: PythonModel,
|
|
251
|
+
uri: str,
|
|
252
|
+
desired_name: str,
|
|
253
|
+
) -> ApplyResult | None:
|
|
254
|
+
"""After extract, rename the generated function to the user's desired name."""
|
|
255
|
+
client = model.lsp_client
|
|
256
|
+
if client is None:
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
parsed = urlparse(uri)
|
|
260
|
+
if parsed.scheme != "file":
|
|
261
|
+
return None
|
|
262
|
+
path = Path(unquote(parsed.path))
|
|
263
|
+
try:
|
|
264
|
+
content = path.read_text()
|
|
265
|
+
except Exception:
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
# Rope generates "extracted_function" or similar placeholder names
|
|
269
|
+
for generated_name in ["extracted_function", "extracted_method", "extracted_variable"]:
|
|
270
|
+
fn_pattern = f"def {generated_name}"
|
|
271
|
+
byte_offset = content.find(fn_pattern)
|
|
272
|
+
if byte_offset >= 0:
|
|
273
|
+
name_offset = byte_offset + 4 # "def ".len()
|
|
274
|
+
line = content[:name_offset].count("\n")
|
|
275
|
+
last_newline = content.rfind("\n", 0, name_offset)
|
|
276
|
+
col = name_offset - (last_newline + 1) if last_newline >= 0 else name_offset
|
|
277
|
+
|
|
278
|
+
params = {
|
|
279
|
+
"textDocument": {"uri": uri},
|
|
280
|
+
"position": {"line": line, "character": col},
|
|
281
|
+
"newName": desired_name,
|
|
282
|
+
}
|
|
283
|
+
try:
|
|
284
|
+
raw_edit = await client.request("textDocument/rename", params)
|
|
285
|
+
if raw_edit:
|
|
286
|
+
workspace_edit = WorkspaceEdit.from_dict(raw_edit)
|
|
287
|
+
return apply_workspace_edit(workspace_edit)
|
|
288
|
+
except Exception:
|
|
289
|
+
pass
|
|
290
|
+
break
|
|
291
|
+
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# -- import ---------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
async def handle_import(
|
|
298
|
+
model: PythonModel,
|
|
299
|
+
positionals: list[str],
|
|
300
|
+
selectors: list[str],
|
|
301
|
+
) -> str:
|
|
302
|
+
if not positionals:
|
|
303
|
+
return format_error("import requires SYMBOL.", None)
|
|
304
|
+
symbol_name = positionals[0]
|
|
305
|
+
|
|
306
|
+
parsed_selectors = [s for s in (parse_selector(sel) for sel in selectors) if s is not None]
|
|
307
|
+
|
|
308
|
+
file_sel = next((s for s in parsed_selectors if s.selector_type == SelectorType.FILE), None)
|
|
309
|
+
line_sel = next((s for s in parsed_selectors if s.selector_type == SelectorType.LINE), None)
|
|
310
|
+
|
|
311
|
+
if file_sel is None:
|
|
312
|
+
return format_error("import requires @file:PATH selector.", None)
|
|
313
|
+
if line_sel is None:
|
|
314
|
+
return format_error("import requires @line:N selector.", None)
|
|
315
|
+
|
|
316
|
+
try:
|
|
317
|
+
line_num = int(line_sel.value)
|
|
318
|
+
except ValueError:
|
|
319
|
+
return format_error("invalid line number.", None)
|
|
320
|
+
|
|
321
|
+
uri = file_uri(model, file_sel.value)
|
|
322
|
+
lsp_line = line_num - 1 if line_num > 0 else line_num
|
|
323
|
+
|
|
324
|
+
client = model.lsp_client
|
|
325
|
+
assert client is not None
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
await ensure_file_synced(model, uri)
|
|
329
|
+
except Exception as e:
|
|
330
|
+
return format_error(f"import: {e}", None)
|
|
331
|
+
|
|
332
|
+
params = {
|
|
333
|
+
"textDocument": {"uri": uri},
|
|
334
|
+
"range": {
|
|
335
|
+
"start": {"line": lsp_line, "character": 0},
|
|
336
|
+
"end": {"line": lsp_line, "character": 999},
|
|
337
|
+
},
|
|
338
|
+
"context": {
|
|
339
|
+
"diagnostics": [],
|
|
340
|
+
"only": ["quickfix", "source", "source.organizeImports"],
|
|
341
|
+
"triggerKind": 1,
|
|
342
|
+
},
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
try:
|
|
346
|
+
raw_actions = await client.request("textDocument/codeAction", params)
|
|
347
|
+
except Exception as e:
|
|
348
|
+
return format_error(f"import failed: {e}", None)
|
|
349
|
+
|
|
350
|
+
if not raw_actions:
|
|
351
|
+
raw_actions = []
|
|
352
|
+
|
|
353
|
+
actions = [CodeAction.from_dict(a) for a in raw_actions]
|
|
354
|
+
symbol_lower = symbol_name.lower()
|
|
355
|
+
|
|
356
|
+
import_actions = [
|
|
357
|
+
a for a in actions
|
|
358
|
+
if (
|
|
359
|
+
(a.kind and (
|
|
360
|
+
"import" in a.kind
|
|
361
|
+
or a.kind == "quickfix"
|
|
362
|
+
or a.kind.startswith("source")
|
|
363
|
+
))
|
|
364
|
+
or "import" in a.title.lower()
|
|
365
|
+
or "use " in a.title.lower()
|
|
366
|
+
)
|
|
367
|
+
and symbol_lower in a.title.lower()
|
|
368
|
+
]
|
|
369
|
+
|
|
370
|
+
if not import_actions:
|
|
371
|
+
return format_error(
|
|
372
|
+
f"no import action for '{symbol_name}' at {file_sel.value}:{line_num}.",
|
|
373
|
+
None,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
if len(import_actions) == 1:
|
|
377
|
+
action = import_actions[0]
|
|
378
|
+
else:
|
|
379
|
+
preferred = next((a for a in import_actions if a.is_preferred), None)
|
|
380
|
+
if preferred:
|
|
381
|
+
action = preferred
|
|
382
|
+
else:
|
|
383
|
+
return format_code_action_choices(import_actions)
|
|
384
|
+
|
|
385
|
+
if action.edit is None:
|
|
386
|
+
return format_error("import action has no edit.", None)
|
|
387
|
+
|
|
388
|
+
try:
|
|
389
|
+
result = apply_workspace_edit(action.edit)
|
|
390
|
+
except Exception as e:
|
|
391
|
+
return format_error(f"failed to apply import: {e}", None)
|
|
392
|
+
|
|
393
|
+
return format_mutation_result("import", symbol_name, result, model.root_uri)
|