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,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)