fcp-python 0.1.6__tar.gz → 0.2.0__tar.gz
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-0.1.6 → fcp_python-0.2.0}/.claude-plugin/plugin.json +1 -1
- {fcp_python-0.1.6 → fcp_python-0.2.0}/PKG-INFO +2 -2
- {fcp_python-0.1.6 → fcp_python-0.2.0}/README.md +2 -4
- {fcp_python-0.1.6 → fcp_python-0.2.0}/pyproject.toml +2 -2
- {fcp_python-0.1.6 → fcp_python-0.2.0}/src/fcp_python/bridge.py +0 -3
- fcp_python-0.2.0/src/fcp_python/domain/mutation.py +95 -0
- {fcp_python-0.1.6 → fcp_python-0.2.0}/src/fcp_python/domain/verbs.py +0 -2
- {fcp_python-0.1.6 → fcp_python-0.2.0}/src/fcp_python/main.py +2 -7
- {fcp_python-0.1.6 → fcp_python-0.2.0}/tests/test_mutation.py +6 -22
- {fcp_python-0.1.6 → fcp_python-0.2.0}/tests/test_verbs.py +3 -3
- {fcp_python-0.1.6 → fcp_python-0.2.0}/uv.lock +5 -5
- fcp_python-0.1.6/src/fcp_python/domain/mutation.py +0 -393
- {fcp_python-0.1.6 → fcp_python-0.2.0}/.github/workflows/ci.yml +0 -0
- {fcp_python-0.1.6 → fcp_python-0.2.0}/.github/workflows/release.yml +0 -0
- {fcp_python-0.1.6 → fcp_python-0.2.0}/.mcp.json +0 -0
- {fcp_python-0.1.6 → fcp_python-0.2.0}/CLAUDE.md +0 -0
- {fcp_python-0.1.6 → fcp_python-0.2.0}/Makefile +0 -0
- {fcp_python-0.1.6 → fcp_python-0.2.0}/src/fcp_python/__init__.py +0 -0
- {fcp_python-0.1.6 → fcp_python-0.2.0}/src/fcp_python/domain/__init__.py +0 -0
- {fcp_python-0.1.6 → fcp_python-0.2.0}/src/fcp_python/domain/format.py +0 -0
- {fcp_python-0.1.6 → fcp_python-0.2.0}/src/fcp_python/domain/model.py +0 -0
- {fcp_python-0.1.6 → fcp_python-0.2.0}/src/fcp_python/domain/query.py +0 -0
- {fcp_python-0.1.6 → fcp_python-0.2.0}/src/fcp_python/lsp/__init__.py +0 -0
- {fcp_python-0.1.6 → fcp_python-0.2.0}/src/fcp_python/lsp/client.py +0 -0
- {fcp_python-0.1.6 → fcp_python-0.2.0}/src/fcp_python/lsp/lifecycle.py +0 -0
- {fcp_python-0.1.6 → fcp_python-0.2.0}/src/fcp_python/lsp/transport.py +0 -0
- {fcp_python-0.1.6 → fcp_python-0.2.0}/src/fcp_python/lsp/types.py +0 -0
- {fcp_python-0.1.6 → fcp_python-0.2.0}/src/fcp_python/lsp/workspace_edit.py +0 -0
- {fcp_python-0.1.6 → fcp_python-0.2.0}/src/fcp_python/resolver/__init__.py +0 -0
- {fcp_python-0.1.6 → fcp_python-0.2.0}/src/fcp_python/resolver/index.py +0 -0
- {fcp_python-0.1.6 → fcp_python-0.2.0}/src/fcp_python/resolver/pipeline.py +0 -0
- {fcp_python-0.1.6 → fcp_python-0.2.0}/src/fcp_python/resolver/selectors.py +0 -0
- {fcp_python-0.1.6 → fcp_python-0.2.0}/tests/__init__.py +0 -0
- {fcp_python-0.1.6 → fcp_python-0.2.0}/tests/test_bridge.py +0 -0
- {fcp_python-0.1.6 → fcp_python-0.2.0}/tests/test_client.py +0 -0
- {fcp_python-0.1.6 → fcp_python-0.2.0}/tests/test_format.py +0 -0
- {fcp_python-0.1.6 → fcp_python-0.2.0}/tests/test_index.py +0 -0
- {fcp_python-0.1.6 → fcp_python-0.2.0}/tests/test_lifecycle.py +0 -0
- {fcp_python-0.1.6 → fcp_python-0.2.0}/tests/test_model.py +0 -0
- {fcp_python-0.1.6 → fcp_python-0.2.0}/tests/test_pipeline.py +0 -0
- {fcp_python-0.1.6 → fcp_python-0.2.0}/tests/test_query.py +0 -0
- {fcp_python-0.1.6 → fcp_python-0.2.0}/tests/test_selectors.py +0 -0
- {fcp_python-0.1.6 → fcp_python-0.2.0}/tests/test_transport.py +0 -0
- {fcp_python-0.1.6 → fcp_python-0.2.0}/tests/test_types.py +0 -0
- {fcp_python-0.1.6 → fcp_python-0.2.0}/tests/test_workspace_edit.py +0 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fcp-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Python Code Intelligence FCP — semantic Python code operations for LLMs via pylsp
|
|
5
5
|
Requires-Python: <3.14,>=3.11
|
|
6
6
|
Requires-Dist: fastmcp>=3.0
|
|
7
|
-
Requires-Dist: fcp-core>=0.
|
|
7
|
+
Requires-Dist: fcp-core>=0.2.0
|
|
8
8
|
Requires-Dist: python-lsp-server[rope]>=1.12
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# fcp-python
|
|
2
2
|
|
|
3
|
-
Python Code Intelligence FCP — an MCP server for querying and
|
|
3
|
+
Python Code Intelligence FCP — an MCP server for querying Python codebases and performing cross-file symbol rename through intent-level commands.
|
|
4
4
|
|
|
5
|
-
Wraps [pylsp](https://github.com/python-lsp/python-lsp-server) (python-lsp-server)
|
|
5
|
+
Wraps [pylsp](https://github.com/python-lsp/python-lsp-server) (python-lsp-server), backed by jedi for navigation/queries and rope for rename. The mutation surface is intentionally narrow: `rename` is the only mutation verb. Everything else (extracting functions, adding imports, etc.) is something the calling LLM should do directly with a normal file edit — routing single-line changes through MCP→DSL→LSP adds ceremony without adding value.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -24,8 +24,6 @@ python_query -> callers process_data
|
|
|
24
24
|
python_query -> map
|
|
25
25
|
python_query -> unused
|
|
26
26
|
python -> rename Config Settings
|
|
27
|
-
python -> extract validate @file:server.py @lines:15-30
|
|
28
|
-
python -> import os @file:main.py @line:5
|
|
29
27
|
python_help -> (shows reference card)
|
|
30
28
|
```
|
|
31
29
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "fcp-python"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
4
4
|
description = "Python Code Intelligence FCP — semantic Python code operations for LLMs via pylsp"
|
|
5
5
|
requires-python = ">=3.11,<3.14"
|
|
6
6
|
dependencies = [
|
|
7
|
-
"fcp-core>=0.
|
|
7
|
+
"fcp-core>=0.2.0",
|
|
8
8
|
"fastmcp>=3.0",
|
|
9
9
|
"python-lsp-server[rope]>=1.12",
|
|
10
10
|
]
|
|
@@ -47,8 +47,6 @@ slipstream fcp python_query "unused @file:src/utils.py"
|
|
|
47
47
|
#### Refactoring
|
|
48
48
|
```
|
|
49
49
|
slipstream fcp python "rename Config Settings"
|
|
50
|
-
slipstream fcp python "extract validate @file:server.py @lines:15-30"
|
|
51
|
-
slipstream fcp python "import os @file:main.py @line:5"
|
|
52
50
|
```
|
|
53
51
|
|
|
54
52
|
#### Selectors
|
|
@@ -57,7 +55,6 @@ slipstream fcp python "import os @file:main.py @line:5"
|
|
|
57
55
|
- `@module:NAME` — filter by module
|
|
58
56
|
- `@kind:KIND` — function, class, method, variable, constant, module, property
|
|
59
57
|
- `@line:N` — filter by line number
|
|
60
|
-
- `@lines:N-M` — line range (for extract)
|
|
61
58
|
- `@decorator:NAME` — filter by decorator (e.g. `@decorator:staticmethod`)
|
|
62
59
|
"""
|
|
63
60
|
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Mutation dispatcher and handlers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fcp_core import VerbRegistry, parse_op, suggest, ParseError
|
|
6
|
+
|
|
7
|
+
from fcp_python.lsp.types import WorkspaceEdit
|
|
8
|
+
from fcp_python.lsp.workspace_edit import apply_workspace_edit
|
|
9
|
+
from fcp_python.resolver.selectors import parse_selector
|
|
10
|
+
|
|
11
|
+
from .format import (
|
|
12
|
+
format_disambiguation,
|
|
13
|
+
format_error,
|
|
14
|
+
format_mutation_result,
|
|
15
|
+
)
|
|
16
|
+
from .model import PythonModel
|
|
17
|
+
from .query import resolve_with_fallback
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def dispatch_mutation(
|
|
21
|
+
model: PythonModel,
|
|
22
|
+
registry: VerbRegistry,
|
|
23
|
+
input_str: str,
|
|
24
|
+
) -> str:
|
|
25
|
+
"""Dispatch a mutation operation string to the appropriate handler."""
|
|
26
|
+
op = parse_op(input_str)
|
|
27
|
+
if isinstance(op, ParseError):
|
|
28
|
+
return format_error(f"parse error: {op.error}", None)
|
|
29
|
+
|
|
30
|
+
if registry.lookup(op.verb) is None:
|
|
31
|
+
verb_names = [v.verb for v in registry.verbs]
|
|
32
|
+
suggestion = suggest(op.verb, verb_names)
|
|
33
|
+
return format_error(f"unknown verb '{op.verb}'.", suggestion)
|
|
34
|
+
|
|
35
|
+
if model.lsp_client is None:
|
|
36
|
+
return format_error("no workspace open. Use python_session open PATH first.", None)
|
|
37
|
+
|
|
38
|
+
match op.verb:
|
|
39
|
+
case "rename":
|
|
40
|
+
return await handle_rename(model, op.positionals, op.selectors)
|
|
41
|
+
case _:
|
|
42
|
+
return format_error(f"verb '{op.verb}' is not a mutation.", None)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# -- rename ---------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
async def handle_rename(
|
|
48
|
+
model: PythonModel,
|
|
49
|
+
positionals: list[str],
|
|
50
|
+
selectors: list[str],
|
|
51
|
+
) -> str:
|
|
52
|
+
if len(positionals) < 2:
|
|
53
|
+
return format_error("rename requires SYMBOL and NEW_NAME.", None)
|
|
54
|
+
old_name = positionals[0]
|
|
55
|
+
new_name = positionals[1]
|
|
56
|
+
|
|
57
|
+
parsed_selectors = [s for s in (parse_selector(sel) for sel in selectors) if s is not None]
|
|
58
|
+
resolved = await resolve_with_fallback(model, old_name, parsed_selectors)
|
|
59
|
+
|
|
60
|
+
if resolved.is_ambiguous:
|
|
61
|
+
return format_disambiguation(old_name, resolved.entries)
|
|
62
|
+
if resolved.is_not_found:
|
|
63
|
+
return format_error(f"symbol '{old_name}' not found.", None)
|
|
64
|
+
entry = resolved.entry
|
|
65
|
+
|
|
66
|
+
client = model.lsp_client
|
|
67
|
+
assert client is not None
|
|
68
|
+
|
|
69
|
+
params = {
|
|
70
|
+
"textDocument": {"uri": entry.uri},
|
|
71
|
+
"position": {
|
|
72
|
+
"line": entry.selection_range.start.line,
|
|
73
|
+
"character": entry.selection_range.start.character,
|
|
74
|
+
},
|
|
75
|
+
"newName": new_name,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
raw_edit = await client.request("textDocument/rename", params)
|
|
80
|
+
except Exception as e:
|
|
81
|
+
return format_error(f"rename failed: {e}", None)
|
|
82
|
+
|
|
83
|
+
if raw_edit is None:
|
|
84
|
+
return format_error("rename returned no edit.", None)
|
|
85
|
+
|
|
86
|
+
workspace_edit = WorkspaceEdit.from_dict(raw_edit)
|
|
87
|
+
try:
|
|
88
|
+
result = apply_workspace_edit(workspace_edit)
|
|
89
|
+
except Exception as e:
|
|
90
|
+
return format_error(f"failed to apply rename: {e}", None)
|
|
91
|
+
|
|
92
|
+
return format_mutation_result(
|
|
93
|
+
"rename", f"{old_name} → {new_name}", result, model.root_uri
|
|
94
|
+
)
|
|
95
|
+
|
|
@@ -24,8 +24,6 @@ def register_query_verbs(registry: VerbRegistry) -> None:
|
|
|
24
24
|
def register_mutation_verbs(registry: VerbRegistry) -> None:
|
|
25
25
|
registry.register_many([
|
|
26
26
|
VerbSpec(verb="rename", syntax="rename SYMBOL NEW_NAME [@selectors...]", category="mutation"),
|
|
27
|
-
VerbSpec(verb="extract", syntax="extract FUNC_NAME @file:PATH @lines:N-M", category="mutation"),
|
|
28
|
-
VerbSpec(verb="import", syntax="import SYMBOL @file:PATH @line:N", category="mutation"),
|
|
29
27
|
])
|
|
30
28
|
|
|
31
29
|
|
|
@@ -55,9 +55,7 @@ _registry = _make_registry()
|
|
|
55
55
|
@mcp.tool(
|
|
56
56
|
description=(
|
|
57
57
|
"Execute Python mutation operations. Examples: "
|
|
58
|
-
"'rename Config Settings'
|
|
59
|
-
"'extract validate @file:server.py @lines:15-30', "
|
|
60
|
-
"'import os @file:main.py @line:5'"
|
|
58
|
+
"'rename Config Settings'"
|
|
61
59
|
)
|
|
62
60
|
)
|
|
63
61
|
async def python(ops: list[str]) -> str:
|
|
@@ -108,13 +106,10 @@ async def python_help() -> str:
|
|
|
108
106
|
" @kind:KIND — filter by symbol kind (function, class, method, variable, ...)\n"
|
|
109
107
|
" @module:NAME — filter by module\n"
|
|
110
108
|
" @line:N — filter by line number\n"
|
|
111
|
-
" @lines:N-M — line range for extract\n"
|
|
112
109
|
" @decorator:NAME — filter by decorator"
|
|
113
110
|
),
|
|
114
111
|
"Mutation Examples": (
|
|
115
|
-
' python ["rename Config Settings"] — cross-file semantic rename
|
|
116
|
-
' python ["extract validate @file:server.py @lines:15-30"] — extract function\n'
|
|
117
|
-
' python ["import os @file:main.py @line:5"] — add missing import'
|
|
112
|
+
' python ["rename Config Settings"] — cross-file semantic rename'
|
|
118
113
|
),
|
|
119
114
|
}
|
|
120
115
|
return _registry.generate_reference_card(extra)
|
|
@@ -5,7 +5,7 @@ import pytest
|
|
|
5
5
|
from fcp_core import VerbRegistry
|
|
6
6
|
|
|
7
7
|
from fcp_python.domain.model import PythonModel
|
|
8
|
-
from fcp_python.domain.mutation import dispatch_mutation
|
|
8
|
+
from fcp_python.domain.mutation import dispatch_mutation
|
|
9
9
|
from fcp_python.domain.verbs import (
|
|
10
10
|
register_mutation_verbs,
|
|
11
11
|
register_query_verbs,
|
|
@@ -58,26 +58,10 @@ async def test_dispatch_rename_recognized():
|
|
|
58
58
|
|
|
59
59
|
|
|
60
60
|
@pytest.mark.asyncio
|
|
61
|
-
async def
|
|
61
|
+
async def test_dispatch_mutation_unrecognized_removed_verb():
|
|
62
|
+
"""extract/import were removed as broken/low-value; verify they're rejected cleanly."""
|
|
62
63
|
model = _make_model()
|
|
63
64
|
reg = _make_registry()
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
@pytest.mark.asyncio
|
|
69
|
-
async def test_dispatch_import_recognized():
|
|
70
|
-
model = _make_model()
|
|
71
|
-
reg = _make_registry()
|
|
72
|
-
result = await dispatch_mutation(model, reg, "import os @file:main.py @line:5")
|
|
73
|
-
assert "no workspace open" in result
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def test_file_uri_absolute():
|
|
77
|
-
model = _make_model()
|
|
78
|
-
assert file_uri(model, "file:///other/path.py") == "file:///other/path.py"
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def test_file_uri_relative():
|
|
82
|
-
model = _make_model()
|
|
83
|
-
assert file_uri(model, "src/main.py") == "file:///project/src/main.py"
|
|
65
|
+
for verb in ("extract", "import"):
|
|
66
|
+
result = await dispatch_mutation(model, reg, f"{verb} foo @file:server.py")
|
|
67
|
+
assert "unknown verb" in result
|
|
@@ -38,14 +38,14 @@ def test_session_verb_count():
|
|
|
38
38
|
def test_register_mutation_verbs():
|
|
39
39
|
reg = VerbRegistry()
|
|
40
40
|
register_mutation_verbs(reg)
|
|
41
|
-
for name in ["rename"
|
|
41
|
+
for name in ["rename"]:
|
|
42
42
|
assert reg.lookup(name) is not None, f"missing mutation verb: {name}"
|
|
43
43
|
|
|
44
44
|
|
|
45
45
|
def test_mutation_verb_count():
|
|
46
46
|
reg = VerbRegistry()
|
|
47
47
|
register_mutation_verbs(reg)
|
|
48
|
-
assert len(reg.verbs) ==
|
|
48
|
+
assert len(reg.verbs) == 1
|
|
49
49
|
|
|
50
50
|
|
|
51
51
|
def test_reference_card_has_categories():
|
|
@@ -65,4 +65,4 @@ def test_all_verbs_registered():
|
|
|
65
65
|
register_query_verbs(reg)
|
|
66
66
|
register_mutation_verbs(reg)
|
|
67
67
|
register_session_verbs(reg)
|
|
68
|
-
assert len(reg.verbs) ==
|
|
68
|
+
assert len(reg.verbs) == 15 # 11 query + 1 mutation + 3 session
|
|
@@ -374,19 +374,19 @@ wheels = [
|
|
|
374
374
|
|
|
375
375
|
[[package]]
|
|
376
376
|
name = "fcp-core"
|
|
377
|
-
version = "0.
|
|
377
|
+
version = "0.2.0"
|
|
378
378
|
source = { registry = "https://pypi.org/simple" }
|
|
379
379
|
dependencies = [
|
|
380
380
|
{ name = "mcp" },
|
|
381
381
|
]
|
|
382
|
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
|
382
|
+
sdist = { url = "https://files.pythonhosted.org/packages/e1/89/39e9544fdf8126a7e7377f0936f4f4aca401cb63e749ff686a4550032a75/fcp_core-0.2.0.tar.gz", hash = "sha256:1179bf6f9be0cf2308bb4423b5daafe2e1e36c146216f82bd8097382859803a0", size = 53777, upload-time = "2026-07-06T15:51:53.009Z" }
|
|
383
383
|
wheels = [
|
|
384
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
384
|
+
{ url = "https://files.pythonhosted.org/packages/e6/cf/d9366956f56f85e8efd6fbe8d1b3c6e4f65b54b2f497f440558620970298/fcp_core-0.2.0-py3-none-any.whl", hash = "sha256:1a1430a9bc4e4c8f7557113e2104290e3d7b2c2f9a61de617ab73e4674884fae", size = 17390, upload-time = "2026-07-06T15:51:51.882Z" },
|
|
385
385
|
]
|
|
386
386
|
|
|
387
387
|
[[package]]
|
|
388
388
|
name = "fcp-python"
|
|
389
|
-
version = "0.
|
|
389
|
+
version = "0.2.0"
|
|
390
390
|
source = { editable = "." }
|
|
391
391
|
dependencies = [
|
|
392
392
|
{ name = "fastmcp" },
|
|
@@ -405,7 +405,7 @@ dev = [
|
|
|
405
405
|
[package.metadata]
|
|
406
406
|
requires-dist = [
|
|
407
407
|
{ name = "fastmcp", specifier = ">=3.0" },
|
|
408
|
-
{ name = "fcp-core", specifier = ">=0.
|
|
408
|
+
{ name = "fcp-core", specifier = ">=0.2.0" },
|
|
409
409
|
{ name = "python-lsp-server", extras = ["rope"], specifier = ">=1.12" },
|
|
410
410
|
]
|
|
411
411
|
|
|
@@ -1,393 +0,0 @@
|
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|