fcp-python 0.1.5__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.
Files changed (45) hide show
  1. {fcp_python-0.1.5 → fcp_python-0.2.0}/.claude-plugin/plugin.json +2 -2
  2. {fcp_python-0.1.5 → fcp_python-0.2.0}/PKG-INFO +2 -2
  3. {fcp_python-0.1.5 → fcp_python-0.2.0}/README.md +2 -4
  4. {fcp_python-0.1.5 → fcp_python-0.2.0}/pyproject.toml +2 -2
  5. {fcp_python-0.1.5 → fcp_python-0.2.0}/src/fcp_python/bridge.py +0 -3
  6. fcp_python-0.2.0/src/fcp_python/domain/mutation.py +95 -0
  7. {fcp_python-0.1.5 → fcp_python-0.2.0}/src/fcp_python/domain/verbs.py +0 -2
  8. {fcp_python-0.1.5 → fcp_python-0.2.0}/src/fcp_python/main.py +8 -11
  9. {fcp_python-0.1.5 → fcp_python-0.2.0}/tests/test_mutation.py +6 -22
  10. {fcp_python-0.1.5 → fcp_python-0.2.0}/tests/test_verbs.py +3 -3
  11. {fcp_python-0.1.5 → fcp_python-0.2.0}/uv.lock +5 -5
  12. fcp_python-0.1.5/src/fcp_python/domain/mutation.py +0 -393
  13. {fcp_python-0.1.5 → fcp_python-0.2.0}/.github/workflows/ci.yml +0 -0
  14. {fcp_python-0.1.5 → fcp_python-0.2.0}/.github/workflows/release.yml +0 -0
  15. {fcp_python-0.1.5 → fcp_python-0.2.0}/.mcp.json +0 -0
  16. {fcp_python-0.1.5 → fcp_python-0.2.0}/CLAUDE.md +0 -0
  17. {fcp_python-0.1.5 → fcp_python-0.2.0}/Makefile +0 -0
  18. {fcp_python-0.1.5 → fcp_python-0.2.0}/src/fcp_python/__init__.py +0 -0
  19. {fcp_python-0.1.5 → fcp_python-0.2.0}/src/fcp_python/domain/__init__.py +0 -0
  20. {fcp_python-0.1.5 → fcp_python-0.2.0}/src/fcp_python/domain/format.py +0 -0
  21. {fcp_python-0.1.5 → fcp_python-0.2.0}/src/fcp_python/domain/model.py +0 -0
  22. {fcp_python-0.1.5 → fcp_python-0.2.0}/src/fcp_python/domain/query.py +0 -0
  23. {fcp_python-0.1.5 → fcp_python-0.2.0}/src/fcp_python/lsp/__init__.py +0 -0
  24. {fcp_python-0.1.5 → fcp_python-0.2.0}/src/fcp_python/lsp/client.py +0 -0
  25. {fcp_python-0.1.5 → fcp_python-0.2.0}/src/fcp_python/lsp/lifecycle.py +0 -0
  26. {fcp_python-0.1.5 → fcp_python-0.2.0}/src/fcp_python/lsp/transport.py +0 -0
  27. {fcp_python-0.1.5 → fcp_python-0.2.0}/src/fcp_python/lsp/types.py +0 -0
  28. {fcp_python-0.1.5 → fcp_python-0.2.0}/src/fcp_python/lsp/workspace_edit.py +0 -0
  29. {fcp_python-0.1.5 → fcp_python-0.2.0}/src/fcp_python/resolver/__init__.py +0 -0
  30. {fcp_python-0.1.5 → fcp_python-0.2.0}/src/fcp_python/resolver/index.py +0 -0
  31. {fcp_python-0.1.5 → fcp_python-0.2.0}/src/fcp_python/resolver/pipeline.py +0 -0
  32. {fcp_python-0.1.5 → fcp_python-0.2.0}/src/fcp_python/resolver/selectors.py +0 -0
  33. {fcp_python-0.1.5 → fcp_python-0.2.0}/tests/__init__.py +0 -0
  34. {fcp_python-0.1.5 → fcp_python-0.2.0}/tests/test_bridge.py +0 -0
  35. {fcp_python-0.1.5 → fcp_python-0.2.0}/tests/test_client.py +0 -0
  36. {fcp_python-0.1.5 → fcp_python-0.2.0}/tests/test_format.py +0 -0
  37. {fcp_python-0.1.5 → fcp_python-0.2.0}/tests/test_index.py +0 -0
  38. {fcp_python-0.1.5 → fcp_python-0.2.0}/tests/test_lifecycle.py +0 -0
  39. {fcp_python-0.1.5 → fcp_python-0.2.0}/tests/test_model.py +0 -0
  40. {fcp_python-0.1.5 → fcp_python-0.2.0}/tests/test_pipeline.py +0 -0
  41. {fcp_python-0.1.5 → fcp_python-0.2.0}/tests/test_query.py +0 -0
  42. {fcp_python-0.1.5 → fcp_python-0.2.0}/tests/test_selectors.py +0 -0
  43. {fcp_python-0.1.5 → fcp_python-0.2.0}/tests/test_transport.py +0 -0
  44. {fcp_python-0.1.5 → fcp_python-0.2.0}/tests/test_types.py +0 -0
  45. {fcp_python-0.1.5 → fcp_python-0.2.0}/tests/test_workspace_edit.py +0 -0
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "fcp-python",
3
3
  "description": "Query and refactor Python codebases through intent-level commands",
4
- "version": "0.1.5",
4
+ "version": "0.1.6",
5
5
  "license": "MIT",
6
6
  "author": {
7
7
  "name": "Scott Meyer"
8
8
  },
9
- "repository": "https://github.com/aetherwing-io/fcp-python",
9
+ "repository": "https://github.com/os-tack/fcp-python",
10
10
  "keywords": ["python", "pylsp", "refactoring", "code-intelligence", "mcp"]
11
11
  }
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fcp-python
3
- Version: 0.1.5
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.1.17
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 refactoring Python codebases through intent-level commands.
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) with [rope](https://github.com/python-rope/rope) for refactoring.
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.1.5"
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.1.17",
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
 
@@ -26,11 +26,13 @@ from fcp_python.lsp.types import PublishDiagnosticsParams, SymbolInformation
26
26
  from fcp_python.resolver.index import SymbolEntry, SymbolIndex
27
27
 
28
28
  mcp = FastMCP(
29
- "python-fcp",
29
+ "fcp-python",
30
30
  instructions=(
31
- "FCP Python server for querying Python codebases via pylsp. "
32
- "Use python_session to open a workspace, python_query for read-only queries, "
33
- "and python_help for the reference card."
31
+ "FCP Python server for querying and refactoring Python codebases via pylsp. "
32
+ "Use python_session to open a workspace directory containing Python files, "
33
+ "python_query for read-only queries like finding definitions, references, "
34
+ "diagnostics, and symbols, python for refactoring operations, and python_help "
35
+ "for the full verb reference. Start every interaction with python_session."
34
36
  ),
35
37
  )
36
38
 
@@ -53,9 +55,7 @@ _registry = _make_registry()
53
55
  @mcp.tool(
54
56
  description=(
55
57
  "Execute Python mutation operations. Examples: "
56
- "'rename Config Settings', "
57
- "'extract validate @file:server.py @lines:15-30', "
58
- "'import os @file:main.py @line:5'"
58
+ "'rename Config Settings'"
59
59
  )
60
60
  )
61
61
  async def python(ops: list[str]) -> str:
@@ -106,13 +106,10 @@ async def python_help() -> str:
106
106
  " @kind:KIND — filter by symbol kind (function, class, method, variable, ...)\n"
107
107
  " @module:NAME — filter by module\n"
108
108
  " @line:N — filter by line number\n"
109
- " @lines:N-M — line range for extract\n"
110
109
  " @decorator:NAME — filter by decorator"
111
110
  ),
112
111
  "Mutation Examples": (
113
- ' python ["rename Config Settings"] — cross-file semantic rename\n'
114
- ' python ["extract validate @file:server.py @lines:15-30"] — extract function\n'
115
- ' python ["import os @file:main.py @line:5"] — add missing import'
112
+ ' python ["rename Config Settings"] — cross-file semantic rename'
116
113
  ),
117
114
  }
118
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, file_uri
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 test_dispatch_extract_recognized():
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
- result = await dispatch_mutation(model, reg, "extract validate @file:server.py @lines:15-30")
65
- assert "no workspace open" in result
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", "extract", "import"]:
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) == 3
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) == 17 # 11 query + 3 mutation + 3 session
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.1.17"
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/66/58/1e99ab092a1cafba7c9ef91a422284bcb22501aed79e071b75158a3cee92/fcp_core-0.1.17.tar.gz", hash = "sha256:53eb542b0b8b5a308a0a6db41e49e7996f19a27a92d52e73c783807fb60bd3a5", size = 49364, upload-time = "2026-03-05T16:17:49.872Z" }
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/e8/17/a3acff00705a94854513265861d03b406fb6b545eeee2ebca3aeb1204926/fcp_core-0.1.17-py3-none-any.whl", hash = "sha256:00234863120164c0cdc3f801d6d69ccb0881d14f4a35fb765b209614d448025f", size = 16935, upload-time = "2026-03-05T16:17:48.458Z" },
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.1.5"
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.1.17" },
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