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
fcp_python/main.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""fcp-python — Python Code Intelligence FCP MCP server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from fastmcp import FastMCP
|
|
11
|
+
|
|
12
|
+
from fcp_core import VerbRegistry, suggest
|
|
13
|
+
|
|
14
|
+
from fcp_python.domain.format import format_error
|
|
15
|
+
from fcp_python.domain.model import PythonModel
|
|
16
|
+
from fcp_python.domain.mutation import dispatch_mutation
|
|
17
|
+
from fcp_python.domain.query import dispatch_query
|
|
18
|
+
from fcp_python.domain.verbs import (
|
|
19
|
+
register_mutation_verbs,
|
|
20
|
+
register_query_verbs,
|
|
21
|
+
register_session_verbs,
|
|
22
|
+
)
|
|
23
|
+
from fcp_python.lsp.client import LspClient
|
|
24
|
+
from fcp_python.lsp.lifecycle import ServerStatus
|
|
25
|
+
from fcp_python.lsp.types import PublishDiagnosticsParams, SymbolInformation
|
|
26
|
+
from fcp_python.resolver.index import SymbolEntry, SymbolIndex
|
|
27
|
+
|
|
28
|
+
mcp = FastMCP(
|
|
29
|
+
"python-fcp",
|
|
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."
|
|
34
|
+
),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Global state
|
|
38
|
+
_model = PythonModel("file:///")
|
|
39
|
+
_lock = asyncio.Lock()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _make_registry() -> VerbRegistry:
|
|
43
|
+
reg = VerbRegistry()
|
|
44
|
+
register_query_verbs(reg)
|
|
45
|
+
register_mutation_verbs(reg)
|
|
46
|
+
register_session_verbs(reg)
|
|
47
|
+
return reg
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
_registry = _make_registry()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@mcp.tool(
|
|
54
|
+
description=(
|
|
55
|
+
"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'"
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
async def python(ops: list[str]) -> str:
|
|
62
|
+
"""Execute mutation operations."""
|
|
63
|
+
async with _lock:
|
|
64
|
+
results = []
|
|
65
|
+
for op in ops:
|
|
66
|
+
result = await dispatch_mutation(_model, _registry, op)
|
|
67
|
+
results.append(result)
|
|
68
|
+
return "\n\n".join(results)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@mcp.tool(
|
|
72
|
+
description=(
|
|
73
|
+
"Execute a read-only FCP query on the Python workspace. Examples: "
|
|
74
|
+
"'find Config', 'def main @file:main.py', 'diagnose', 'unused', 'map'"
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
async def python_query(input: str) -> str:
|
|
78
|
+
"""Execute a read-only query."""
|
|
79
|
+
async with _lock:
|
|
80
|
+
return await dispatch_query(_model, _registry, input)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@mcp.tool(
|
|
84
|
+
description=(
|
|
85
|
+
"Manage the Python workspace session. Actions: "
|
|
86
|
+
"'open PATH' to open a workspace, "
|
|
87
|
+
"'status' to check server status, "
|
|
88
|
+
"'close' to close the workspace."
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
async def python_session(action: str) -> str:
|
|
92
|
+
"""Manage workspace session."""
|
|
93
|
+
async with _lock:
|
|
94
|
+
return await _handle_session(action)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@mcp.tool(
|
|
98
|
+
description="Show the FCP Python reference card with all available verbs and their syntax."
|
|
99
|
+
)
|
|
100
|
+
async def python_help() -> str:
|
|
101
|
+
"""Show reference card."""
|
|
102
|
+
extra = {
|
|
103
|
+
"Selectors": (
|
|
104
|
+
" @file:PATH — filter by file path\n"
|
|
105
|
+
" @class:NAME — filter by containing class\n"
|
|
106
|
+
" @kind:KIND — filter by symbol kind (function, class, method, variable, ...)\n"
|
|
107
|
+
" @module:NAME — filter by module\n"
|
|
108
|
+
" @line:N — filter by line number\n"
|
|
109
|
+
" @lines:N-M — line range for extract\n"
|
|
110
|
+
" @decorator:NAME — filter by decorator"
|
|
111
|
+
),
|
|
112
|
+
"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'
|
|
116
|
+
),
|
|
117
|
+
}
|
|
118
|
+
return _registry.generate_reference_card(extra)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def _handle_session(action: str) -> str:
|
|
122
|
+
global _model
|
|
123
|
+
tokens = action.split()
|
|
124
|
+
if not tokens:
|
|
125
|
+
return "! empty session action."
|
|
126
|
+
|
|
127
|
+
cmd = tokens[0]
|
|
128
|
+
if cmd == "open":
|
|
129
|
+
if len(tokens) < 2:
|
|
130
|
+
return "! open requires a path."
|
|
131
|
+
return await _handle_open(tokens[1])
|
|
132
|
+
elif cmd == "status":
|
|
133
|
+
return _handle_status()
|
|
134
|
+
elif cmd == "close":
|
|
135
|
+
return await _handle_close()
|
|
136
|
+
else:
|
|
137
|
+
return f"! unknown session action '{cmd}'."
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
async def _handle_open(path: str) -> str:
|
|
141
|
+
global _model
|
|
142
|
+
|
|
143
|
+
if path.startswith("file://"):
|
|
144
|
+
uri = path
|
|
145
|
+
else:
|
|
146
|
+
p = Path(path).resolve()
|
|
147
|
+
if not p.exists():
|
|
148
|
+
return f"! path not found: {path}"
|
|
149
|
+
uri = p.as_uri()
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
client = await LspClient.spawn("pylsp", [], uri)
|
|
153
|
+
except Exception as e:
|
|
154
|
+
return f"! failed to start pylsp: {e}"
|
|
155
|
+
|
|
156
|
+
_model = PythonModel(uri)
|
|
157
|
+
_model.lsp_client = client
|
|
158
|
+
_model.server_status = ServerStatus.Ready
|
|
159
|
+
_model.py_file_count = _count_py_files(path)
|
|
160
|
+
|
|
161
|
+
# Start notification handler
|
|
162
|
+
asyncio.create_task(_notification_handler(client.notification_queue, _model))
|
|
163
|
+
|
|
164
|
+
# Populate initial index
|
|
165
|
+
symbol_count = await _populate_initial_index(client, _model)
|
|
166
|
+
|
|
167
|
+
_model.last_reload = time.time()
|
|
168
|
+
|
|
169
|
+
return f"Opened workspace: {path} ({_model.py_file_count} files, {symbol_count} symbols)"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _handle_status() -> str:
|
|
173
|
+
status_str = _model.server_status.name
|
|
174
|
+
errors, warnings = _model.total_diagnostics()
|
|
175
|
+
return (
|
|
176
|
+
f"Status: {status_str}\n"
|
|
177
|
+
f"Workspace: {_model.root_uri}\n"
|
|
178
|
+
f"Files: {_model.py_file_count}\n"
|
|
179
|
+
f"Symbols: {_model.symbol_index.size()}\n"
|
|
180
|
+
f"Diagnostics: {errors} errors, {warnings} warnings"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
async def _handle_close() -> str:
|
|
185
|
+
global _model
|
|
186
|
+
if _model.lsp_client:
|
|
187
|
+
try:
|
|
188
|
+
await _model.lsp_client.shutdown()
|
|
189
|
+
except Exception:
|
|
190
|
+
pass
|
|
191
|
+
|
|
192
|
+
_model.server_status = ServerStatus.Stopped
|
|
193
|
+
_model.lsp_client = None
|
|
194
|
+
_model.symbol_index = SymbolIndex()
|
|
195
|
+
_model.diagnostics.clear()
|
|
196
|
+
_model.open_documents.clear()
|
|
197
|
+
|
|
198
|
+
return "Workspace closed."
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
async def _notification_handler(
|
|
202
|
+
queue: asyncio.Queue,
|
|
203
|
+
model: PythonModel,
|
|
204
|
+
) -> None:
|
|
205
|
+
"""Process LSP notifications (diagnostics etc)."""
|
|
206
|
+
while True:
|
|
207
|
+
try:
|
|
208
|
+
notif = await queue.get()
|
|
209
|
+
except Exception:
|
|
210
|
+
break
|
|
211
|
+
if notif.method == "textDocument/publishDiagnostics":
|
|
212
|
+
if notif.params:
|
|
213
|
+
try:
|
|
214
|
+
params = PublishDiagnosticsParams.from_dict(notif.params)
|
|
215
|
+
model.update_diagnostics(params.uri, params.diagnostics)
|
|
216
|
+
except Exception:
|
|
217
|
+
pass
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
async def _populate_initial_index(client: LspClient, model: PythonModel) -> int:
|
|
221
|
+
"""Populate symbol index with workspace/symbol query, with retries."""
|
|
222
|
+
for attempt in range(10):
|
|
223
|
+
try:
|
|
224
|
+
raw_symbols = await client.request("workspace/symbol", {"query": "*"})
|
|
225
|
+
if raw_symbols:
|
|
226
|
+
for sym_dict in raw_symbols:
|
|
227
|
+
sym = SymbolInformation.from_dict(sym_dict)
|
|
228
|
+
model.symbol_index.insert(SymbolEntry(
|
|
229
|
+
name=sym.name,
|
|
230
|
+
kind=sym.kind,
|
|
231
|
+
container_name=sym.container_name,
|
|
232
|
+
uri=sym.location.uri,
|
|
233
|
+
range=sym.location.range,
|
|
234
|
+
selection_range=sym.location.range,
|
|
235
|
+
))
|
|
236
|
+
return len(raw_symbols)
|
|
237
|
+
except Exception:
|
|
238
|
+
pass
|
|
239
|
+
if attempt < 9:
|
|
240
|
+
await asyncio.sleep(0.5)
|
|
241
|
+
return 0
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _count_py_files(path: str) -> int:
|
|
245
|
+
"""Count .py files in directory, skipping hidden dirs and __pycache__."""
|
|
246
|
+
if not os.path.isdir(path):
|
|
247
|
+
return 0
|
|
248
|
+
skip_dirs = {"__pycache__", "node_modules", ".venv", "venv"}
|
|
249
|
+
count = 0
|
|
250
|
+
try:
|
|
251
|
+
for entry in os.scandir(path):
|
|
252
|
+
if entry.is_dir(follow_symlinks=False):
|
|
253
|
+
if entry.name.startswith(".") or entry.name in skip_dirs:
|
|
254
|
+
continue
|
|
255
|
+
count += _count_py_files(entry.path)
|
|
256
|
+
elif entry.name.endswith(".py"):
|
|
257
|
+
count += 1
|
|
258
|
+
except PermissionError:
|
|
259
|
+
pass
|
|
260
|
+
return count
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def main() -> None:
|
|
264
|
+
# Spawn slipstream bridge in background (silent no-op if daemon not running)
|
|
265
|
+
from fcp_python.bridge import start_bridge
|
|
266
|
+
|
|
267
|
+
async def _bridge_session(action: str) -> str:
|
|
268
|
+
async with _lock:
|
|
269
|
+
return await _handle_session(action)
|
|
270
|
+
|
|
271
|
+
async def _bridge_query(q: str) -> str:
|
|
272
|
+
async with _lock:
|
|
273
|
+
return await dispatch_query(_model, _registry, q)
|
|
274
|
+
|
|
275
|
+
async def _bridge_mutation(ops: list[str]) -> str:
|
|
276
|
+
async with _lock:
|
|
277
|
+
results = []
|
|
278
|
+
for op in ops:
|
|
279
|
+
results.append(await dispatch_mutation(_model, _registry, op))
|
|
280
|
+
return "\n\n".join(results)
|
|
281
|
+
|
|
282
|
+
start_bridge(_bridge_session, _bridge_query, _bridge_mutation)
|
|
283
|
+
|
|
284
|
+
mcp.run()
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
if __name__ == "__main__":
|
|
288
|
+
main()
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Resolver layer: symbol indexing, selector filtering, resolution pipeline."""
|
|
2
|
+
|
|
3
|
+
from fcp_python.resolver.index import SymbolEntry, SymbolIndex
|
|
4
|
+
from fcp_python.resolver.selectors import (
|
|
5
|
+
ParsedSelector,
|
|
6
|
+
SelectorType,
|
|
7
|
+
filter_by_selectors,
|
|
8
|
+
parse_line_range,
|
|
9
|
+
parse_selector,
|
|
10
|
+
symbol_kind_from_string,
|
|
11
|
+
)
|
|
12
|
+
from fcp_python.resolver.pipeline import ResolveResult, SymbolResolver
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"SymbolEntry",
|
|
16
|
+
"SymbolIndex",
|
|
17
|
+
"ParsedSelector",
|
|
18
|
+
"SelectorType",
|
|
19
|
+
"filter_by_selectors",
|
|
20
|
+
"parse_line_range",
|
|
21
|
+
"parse_selector",
|
|
22
|
+
"symbol_kind_from_string",
|
|
23
|
+
"ResolveResult",
|
|
24
|
+
"SymbolResolver",
|
|
25
|
+
]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Triple-indexed symbol cache for fast resolution lookups."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from fcp_python.lsp.types import Range, SymbolKind
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class SymbolEntry:
|
|
12
|
+
name: str
|
|
13
|
+
kind: SymbolKind
|
|
14
|
+
container_name: str | None
|
|
15
|
+
uri: str
|
|
16
|
+
range: Range
|
|
17
|
+
selection_range: Range
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SymbolIndex:
|
|
21
|
+
"""Triple-indexed symbol store: by name, by file URI, by container."""
|
|
22
|
+
|
|
23
|
+
def __init__(self) -> None:
|
|
24
|
+
self._by_name: dict[str, list[SymbolEntry]] = {}
|
|
25
|
+
self._by_file: dict[str, list[SymbolEntry]] = {}
|
|
26
|
+
self._by_container: dict[str, list[SymbolEntry]] = {}
|
|
27
|
+
|
|
28
|
+
def insert(self, entry: SymbolEntry) -> None:
|
|
29
|
+
self._by_name.setdefault(entry.name, []).append(entry)
|
|
30
|
+
self._by_file.setdefault(entry.uri, []).append(entry)
|
|
31
|
+
if entry.container_name is not None:
|
|
32
|
+
self._by_container.setdefault(entry.container_name, []).append(entry)
|
|
33
|
+
|
|
34
|
+
def lookup_by_name(self, name: str) -> list[SymbolEntry]:
|
|
35
|
+
return list(self._by_name.get(name, []))
|
|
36
|
+
|
|
37
|
+
def lookup_by_file(self, uri: str) -> list[SymbolEntry]:
|
|
38
|
+
return list(self._by_file.get(uri, []))
|
|
39
|
+
|
|
40
|
+
def lookup_by_container(self, container: str) -> list[SymbolEntry]:
|
|
41
|
+
return list(self._by_container.get(container, []))
|
|
42
|
+
|
|
43
|
+
def invalidate_file(self, uri: str) -> None:
|
|
44
|
+
self._by_file.pop(uri, None)
|
|
45
|
+
|
|
46
|
+
for entries in self._by_name.values():
|
|
47
|
+
entries[:] = [e for e in entries if e.uri != uri]
|
|
48
|
+
self._by_name = {k: v for k, v in self._by_name.items() if v}
|
|
49
|
+
|
|
50
|
+
for entries in self._by_container.values():
|
|
51
|
+
entries[:] = [e for e in entries if e.uri != uri]
|
|
52
|
+
self._by_container = {k: v for k, v in self._by_container.items() if v}
|
|
53
|
+
|
|
54
|
+
def size(self) -> int:
|
|
55
|
+
return sum(len(v) for v in self._by_file.values())
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""3-tier symbol resolution pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import Enum, auto
|
|
7
|
+
|
|
8
|
+
from fcp_python.lsp.types import Location, SymbolInformation
|
|
9
|
+
from fcp_python.resolver.index import SymbolEntry, SymbolIndex
|
|
10
|
+
from fcp_python.resolver.selectors import ParsedSelector, filter_by_selectors
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _ResultKind(Enum):
|
|
14
|
+
FOUND = auto()
|
|
15
|
+
AMBIGUOUS = auto()
|
|
16
|
+
NOT_FOUND = auto()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ResolveResult:
|
|
20
|
+
"""Tagged union: Found(entry), Ambiguous(entries), NotFound."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, kind: _ResultKind, entry: SymbolEntry | None = None, entries: list[SymbolEntry] | None = None):
|
|
23
|
+
self._kind = kind
|
|
24
|
+
self._entry = entry
|
|
25
|
+
self._entries = entries
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def found(entry: SymbolEntry) -> ResolveResult:
|
|
29
|
+
return ResolveResult(_ResultKind.FOUND, entry=entry)
|
|
30
|
+
|
|
31
|
+
@staticmethod
|
|
32
|
+
def ambiguous(entries: list[SymbolEntry]) -> ResolveResult:
|
|
33
|
+
return ResolveResult(_ResultKind.AMBIGUOUS, entries=entries)
|
|
34
|
+
|
|
35
|
+
@staticmethod
|
|
36
|
+
def not_found() -> ResolveResult:
|
|
37
|
+
return ResolveResult(_ResultKind.NOT_FOUND)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def is_found(self) -> bool:
|
|
41
|
+
return self._kind == _ResultKind.FOUND
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def is_ambiguous(self) -> bool:
|
|
45
|
+
return self._kind == _ResultKind.AMBIGUOUS
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def is_not_found(self) -> bool:
|
|
49
|
+
return self._kind == _ResultKind.NOT_FOUND
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def entry(self) -> SymbolEntry:
|
|
53
|
+
assert self._kind == _ResultKind.FOUND and self._entry is not None
|
|
54
|
+
return self._entry
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def entries(self) -> list[SymbolEntry]:
|
|
58
|
+
assert self._kind == _ResultKind.AMBIGUOUS and self._entries is not None
|
|
59
|
+
return self._entries
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _entry_to_symbol_info(entry: SymbolEntry) -> SymbolInformation:
|
|
63
|
+
return SymbolInformation(
|
|
64
|
+
name=entry.name,
|
|
65
|
+
kind=entry.kind,
|
|
66
|
+
location=Location(uri=entry.uri, range=entry.range),
|
|
67
|
+
container_name=entry.container_name,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class SymbolResolver:
|
|
72
|
+
"""Multi-tier symbol resolver."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, index: SymbolIndex) -> None:
|
|
75
|
+
self._index = index
|
|
76
|
+
|
|
77
|
+
def resolve_from_index(
|
|
78
|
+
self,
|
|
79
|
+
name: str,
|
|
80
|
+
selectors: list[ParsedSelector],
|
|
81
|
+
) -> ResolveResult:
|
|
82
|
+
"""Tier 1: resolve from in-memory index with selector filtering."""
|
|
83
|
+
entries = self._index.lookup_by_name(name)
|
|
84
|
+
|
|
85
|
+
if not entries:
|
|
86
|
+
return ResolveResult.not_found()
|
|
87
|
+
|
|
88
|
+
if selectors:
|
|
89
|
+
sym_infos = [_entry_to_symbol_info(e) for e in entries]
|
|
90
|
+
filtered_infos = filter_by_selectors(sym_infos, selectors)
|
|
91
|
+
# Map back to entries by matching indices
|
|
92
|
+
filtered_info_set = set(id(si) for si in filtered_infos)
|
|
93
|
+
filtered = [
|
|
94
|
+
e for e, si in zip(entries, sym_infos)
|
|
95
|
+
if id(si) in filtered_info_set
|
|
96
|
+
]
|
|
97
|
+
else:
|
|
98
|
+
filtered = entries
|
|
99
|
+
|
|
100
|
+
if len(filtered) == 0:
|
|
101
|
+
return ResolveResult.not_found()
|
|
102
|
+
elif len(filtered) == 1:
|
|
103
|
+
return ResolveResult.found(filtered[0])
|
|
104
|
+
else:
|
|
105
|
+
return ResolveResult.ambiguous(filtered)
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Selector parsing and filtering for symbol resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import Enum
|
|
7
|
+
|
|
8
|
+
from fcp_python.lsp.types import SymbolInformation, SymbolKind
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SelectorType(Enum):
|
|
12
|
+
FILE = "file"
|
|
13
|
+
CLASS = "class"
|
|
14
|
+
KIND = "kind"
|
|
15
|
+
MODULE = "module"
|
|
16
|
+
LINE = "line"
|
|
17
|
+
LINES = "lines"
|
|
18
|
+
DECORATOR = "decorator"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ParsedSelector:
|
|
23
|
+
selector_type: SelectorType
|
|
24
|
+
value: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parse_selector(selector: str) -> ParsedSelector | None:
|
|
28
|
+
"""Parse @type:value selector string.
|
|
29
|
+
|
|
30
|
+
Maps: @file:PATH, @class:NAME (@struct:NAME alias),
|
|
31
|
+
@kind:KIND, @module:NAME (@mod:NAME alias),
|
|
32
|
+
@line:N, @lines:N-M, @decorator:NAME
|
|
33
|
+
"""
|
|
34
|
+
if not selector.startswith("@"):
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
rest = selector[1:]
|
|
38
|
+
colon_idx = rest.find(":")
|
|
39
|
+
if colon_idx == -1:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
type_str = rest[:colon_idx]
|
|
43
|
+
value = rest[colon_idx + 1:]
|
|
44
|
+
|
|
45
|
+
type_map: dict[str, SelectorType] = {
|
|
46
|
+
"file": SelectorType.FILE,
|
|
47
|
+
"class": SelectorType.CLASS,
|
|
48
|
+
"struct": SelectorType.CLASS, # Rust compat alias
|
|
49
|
+
"kind": SelectorType.KIND,
|
|
50
|
+
"module": SelectorType.MODULE,
|
|
51
|
+
"mod": SelectorType.MODULE,
|
|
52
|
+
"line": SelectorType.LINE,
|
|
53
|
+
"lines": SelectorType.LINES,
|
|
54
|
+
"decorator": SelectorType.DECORATOR,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
selector_type = type_map.get(type_str)
|
|
58
|
+
if selector_type is None:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
return ParsedSelector(selector_type=selector_type, value=value)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def filter_by_selectors(
|
|
65
|
+
symbols: list[SymbolInformation],
|
|
66
|
+
selectors: list[ParsedSelector],
|
|
67
|
+
) -> list[SymbolInformation]:
|
|
68
|
+
"""Filter symbols by selectors (AND logic)."""
|
|
69
|
+
return [
|
|
70
|
+
sym for sym in symbols
|
|
71
|
+
if all(_matches_selector(sym, sel) for sel in selectors)
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _matches_selector(sym: SymbolInformation, sel: ParsedSelector) -> bool:
|
|
76
|
+
match sel.selector_type:
|
|
77
|
+
case SelectorType.FILE:
|
|
78
|
+
return sel.value in sym.location.uri
|
|
79
|
+
case SelectorType.CLASS:
|
|
80
|
+
return (
|
|
81
|
+
sym.container_name == sel.value
|
|
82
|
+
or (sym.name == sel.value and sym.kind == SymbolKind.Class)
|
|
83
|
+
)
|
|
84
|
+
case SelectorType.KIND:
|
|
85
|
+
kind = symbol_kind_from_string(sel.value)
|
|
86
|
+
return kind is not None and sym.kind == kind
|
|
87
|
+
case SelectorType.MODULE:
|
|
88
|
+
container_match = (
|
|
89
|
+
sym.container_name is not None and sel.value in sym.container_name
|
|
90
|
+
)
|
|
91
|
+
return container_match or sel.value in sym.location.uri
|
|
92
|
+
case SelectorType.LINE:
|
|
93
|
+
try:
|
|
94
|
+
line = int(sel.value)
|
|
95
|
+
except ValueError:
|
|
96
|
+
return False
|
|
97
|
+
return sym.location.range.start.line <= line <= sym.location.range.end.line
|
|
98
|
+
case SelectorType.LINES:
|
|
99
|
+
# Consumed by mutation handlers, not symbol filtering
|
|
100
|
+
return True
|
|
101
|
+
case SelectorType.DECORATOR:
|
|
102
|
+
# Would need AST analysis; pass-through for now
|
|
103
|
+
return True
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def parse_line_range(value: str) -> tuple[int, int] | None:
|
|
107
|
+
"""Parse '15-30' into (15, 30). Returns None if invalid."""
|
|
108
|
+
parts = value.split("-", 1)
|
|
109
|
+
if len(parts) != 2:
|
|
110
|
+
return None
|
|
111
|
+
try:
|
|
112
|
+
start = int(parts[0])
|
|
113
|
+
end = int(parts[1])
|
|
114
|
+
except ValueError:
|
|
115
|
+
return None
|
|
116
|
+
if start > end:
|
|
117
|
+
return None
|
|
118
|
+
return (start, end)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def symbol_kind_from_string(s: str) -> SymbolKind | None:
|
|
122
|
+
"""Convert string to SymbolKind. Python-specific additions included."""
|
|
123
|
+
mapping: dict[str, SymbolKind] = {
|
|
124
|
+
"function": SymbolKind.Function,
|
|
125
|
+
"fn": SymbolKind.Function,
|
|
126
|
+
"def": SymbolKind.Function,
|
|
127
|
+
"method": SymbolKind.Method,
|
|
128
|
+
"class": SymbolKind.Class,
|
|
129
|
+
"struct": SymbolKind.Struct,
|
|
130
|
+
"enum": SymbolKind.Enum,
|
|
131
|
+
"interface": SymbolKind.Interface,
|
|
132
|
+
"trait": SymbolKind.Interface,
|
|
133
|
+
"variable": SymbolKind.Variable,
|
|
134
|
+
"var": SymbolKind.Variable,
|
|
135
|
+
"constant": SymbolKind.Constant,
|
|
136
|
+
"const": SymbolKind.Constant,
|
|
137
|
+
"property": SymbolKind.Property,
|
|
138
|
+
"module": SymbolKind.Module,
|
|
139
|
+
"mod": SymbolKind.Module,
|
|
140
|
+
"namespace": SymbolKind.Namespace,
|
|
141
|
+
"field": SymbolKind.Field,
|
|
142
|
+
"constructor": SymbolKind.Constructor,
|
|
143
|
+
"type_parameter": SymbolKind.TypeParameter,
|
|
144
|
+
"typeparameter": SymbolKind.TypeParameter,
|
|
145
|
+
"file": SymbolKind.File,
|
|
146
|
+
"package": SymbolKind.Package,
|
|
147
|
+
"string": SymbolKind.String,
|
|
148
|
+
"number": SymbolKind.Number,
|
|
149
|
+
"boolean": SymbolKind.Boolean,
|
|
150
|
+
"bool": SymbolKind.Boolean,
|
|
151
|
+
"array": SymbolKind.Array,
|
|
152
|
+
"object": SymbolKind.Object,
|
|
153
|
+
"key": SymbolKind.Key,
|
|
154
|
+
"null": SymbolKind.Null,
|
|
155
|
+
"enum_member": SymbolKind.EnumMember,
|
|
156
|
+
"enummember": SymbolKind.EnumMember,
|
|
157
|
+
"event": SymbolKind.Event,
|
|
158
|
+
"operator": SymbolKind.Operator,
|
|
159
|
+
"decorator": SymbolKind.Function, # decorators are functions
|
|
160
|
+
}
|
|
161
|
+
return mapping.get(s.lower())
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fcp-python
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python Code Intelligence FCP — semantic Python code operations for LLMs via pylsp
|
|
5
|
+
Requires-Python: <3.14,>=3.11
|
|
6
|
+
Requires-Dist: fastmcp>=3.0
|
|
7
|
+
Requires-Dist: fcp-core>=0.1.17
|
|
8
|
+
Requires-Dist: python-lsp-server[rope]>=1.12
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
fcp_python/__init__.py,sha256=jXbt2O90IFhcdC8JQ4qr2zYFSlz1Zvpml9hEam5JstU,58
|
|
2
|
+
fcp_python/bridge.py,sha256=KLfOkCdZR-KJsKzvnsus7PgLCbjrWK3NmTYFV-0hdEU,5680
|
|
3
|
+
fcp_python/main.py,sha256=nc_tjq8GfXXBJavLQP7ey6s_ROs7fOUyCYtnaSkx_6Q,8787
|
|
4
|
+
fcp_python/domain/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
fcp_python/domain/format.py,sha256=9oHtn3d9vvRT28DrJxNs9m-YpIWjfjIr-pMWF5Tu4v0,7311
|
|
6
|
+
fcp_python/domain/model.py,sha256=yI2kVvEWv50wICqqmrHV_302EVMtZd_t1v1X50ZonO8,1458
|
|
7
|
+
fcp_python/domain/mutation.py,sha256=ggkm-4zY9rTHfNYYWEcfGNok14MFXUS6_nsBVXaQqoU,12578
|
|
8
|
+
fcp_python/domain/query.py,sha256=KFve1HFAKwyHJPgT1INEQ97jieUUDvhswho-NHqrxkc,21706
|
|
9
|
+
fcp_python/domain/verbs.py,sha256=3u2SJ5do1UKXSA39KaCDvdTkhMXHpUAhm363kEQKDXs,1919
|
|
10
|
+
fcp_python/lsp/__init__.py,sha256=Kai6_M5ap1qjrLEfpkdcVZenIzQDQHMUu28JscQg_kM,39
|
|
11
|
+
fcp_python/lsp/client.py,sha256=H-I1Hq8ayAUimGcsMpiLmFZcdYHRvQBL0jYG--F51JU,6428
|
|
12
|
+
fcp_python/lsp/lifecycle.py,sha256=i-b61LGOeQxZIUjUAf_oR8nMqS7rnGR52EYb9KM3zHc,2774
|
|
13
|
+
fcp_python/lsp/transport.py,sha256=XN3vOMa3aT_uBh_-s05mxxwf4w_YF9n3a-D7tJS578k,3616
|
|
14
|
+
fcp_python/lsp/types.py,sha256=aj_5U-dHX-pXMjW6UerYGdQV8lwT8kHn58U26R0Cgm4,13332
|
|
15
|
+
fcp_python/lsp/workspace_edit.py,sha256=WDT1bsPW38fI2k4_SaMpHONBds96oTKBlIPiVhVDixA,4204
|
|
16
|
+
fcp_python/resolver/__init__.py,sha256=9ImXXfUvA0TWGDZx1GMniKzav7SKT9q9u1C_0EOZE9w,637
|
|
17
|
+
fcp_python/resolver/index.py,sha256=9y56ZoBNqwl8a4_Ue54ZBjNWzj3kZ548miIZvab_90k,1859
|
|
18
|
+
fcp_python/resolver/pipeline.py,sha256=cPqawRgQ3hxNKottcsco1RGLT4f2c3IWidqnuSzhh7c,3174
|
|
19
|
+
fcp_python/resolver/selectors.py,sha256=u_NQdp6eU2fPSKnSpWNhTZbxwy6OgvxVTR3pKdB_7Rg,5070
|
|
20
|
+
fcp_python-0.1.0.dist-info/METADATA,sha256=mwRqNS2f4dkcGWab_RdZ2PBcgB0Y85MK6n1yMOf2sUM,282
|
|
21
|
+
fcp_python-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
22
|
+
fcp_python-0.1.0.dist-info/entry_points.txt,sha256=iSyoGO8uLz21KTX8mL3twnRXWsuc7-8O3U03WCSiJ_s,52
|
|
23
|
+
fcp_python-0.1.0.dist-info/RECORD,,
|