fcp-python 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fcp_python/__init__.py +1 -0
- fcp_python/bridge.py +195 -0
- fcp_python/domain/__init__.py +0 -0
- fcp_python/domain/format.py +221 -0
- fcp_python/domain/model.py +42 -0
- fcp_python/domain/mutation.py +393 -0
- fcp_python/domain/query.py +627 -0
- fcp_python/domain/verbs.py +37 -0
- fcp_python/lsp/__init__.py +1 -0
- fcp_python/lsp/client.py +196 -0
- fcp_python/lsp/lifecycle.py +89 -0
- fcp_python/lsp/transport.py +105 -0
- fcp_python/lsp/types.py +510 -0
- fcp_python/lsp/workspace_edit.py +115 -0
- fcp_python/main.py +288 -0
- fcp_python/resolver/__init__.py +25 -0
- fcp_python/resolver/index.py +55 -0
- fcp_python/resolver/pipeline.py +105 -0
- fcp_python/resolver/selectors.py +161 -0
- fcp_python-0.1.0.dist-info/METADATA +8 -0
- fcp_python-0.1.0.dist-info/RECORD +23 -0
- fcp_python-0.1.0.dist-info/WHEEL +4 -0
- fcp_python-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
"""Query 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 (
|
|
8
|
+
CallHierarchyIncomingCall,
|
|
9
|
+
CallHierarchyOutgoingCall,
|
|
10
|
+
CallHierarchyItem,
|
|
11
|
+
Diagnostic,
|
|
12
|
+
DiagnosticSeverity,
|
|
13
|
+
DocumentSymbol,
|
|
14
|
+
Hover,
|
|
15
|
+
HoverContents,
|
|
16
|
+
Location,
|
|
17
|
+
MarkupContent,
|
|
18
|
+
SymbolInformation,
|
|
19
|
+
SymbolKind,
|
|
20
|
+
)
|
|
21
|
+
from fcp_python.resolver.index import SymbolEntry
|
|
22
|
+
from fcp_python.resolver.pipeline import ResolveResult, SymbolResolver
|
|
23
|
+
from fcp_python.resolver.selectors import (
|
|
24
|
+
SelectorType,
|
|
25
|
+
filter_by_selectors,
|
|
26
|
+
parse_selector,
|
|
27
|
+
symbol_kind_from_string,
|
|
28
|
+
ParsedSelector,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
from .format import (
|
|
32
|
+
format_callers,
|
|
33
|
+
format_callees,
|
|
34
|
+
format_code_action_choices,
|
|
35
|
+
format_definition,
|
|
36
|
+
format_diagnostics,
|
|
37
|
+
format_disambiguation,
|
|
38
|
+
format_error,
|
|
39
|
+
format_hover,
|
|
40
|
+
format_implementations,
|
|
41
|
+
format_navigation_result,
|
|
42
|
+
format_symbol_outline,
|
|
43
|
+
format_unused,
|
|
44
|
+
format_workspace_map,
|
|
45
|
+
)
|
|
46
|
+
from .model import PythonModel
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def find_in_doc_symbols(
|
|
50
|
+
symbols: list[DocumentSymbol],
|
|
51
|
+
name: str,
|
|
52
|
+
line: int,
|
|
53
|
+
parent_name: str | None = None,
|
|
54
|
+
) -> SymbolEntry | None:
|
|
55
|
+
"""Search a DocumentSymbol tree for a symbol matching by name + line range."""
|
|
56
|
+
for sym in symbols:
|
|
57
|
+
if sym.name == name and sym.range.start.line <= line <= sym.range.end.line:
|
|
58
|
+
return SymbolEntry(
|
|
59
|
+
name=sym.name,
|
|
60
|
+
kind=sym.kind,
|
|
61
|
+
container_name=parent_name,
|
|
62
|
+
uri="", # caller fills in
|
|
63
|
+
range=sym.range,
|
|
64
|
+
selection_range=sym.selection_range,
|
|
65
|
+
)
|
|
66
|
+
if sym.children:
|
|
67
|
+
found = find_in_doc_symbols(sym.children, name, line, sym.name)
|
|
68
|
+
if found is not None:
|
|
69
|
+
return found
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def find_by_name_in_doc_symbols(
|
|
74
|
+
symbols: list[DocumentSymbol],
|
|
75
|
+
name: str,
|
|
76
|
+
parent_name: str | None = None,
|
|
77
|
+
) -> SymbolEntry | None:
|
|
78
|
+
"""Search a DocumentSymbol tree by name only (for @class: path resolution)."""
|
|
79
|
+
for sym in symbols:
|
|
80
|
+
if sym.name == name:
|
|
81
|
+
return SymbolEntry(
|
|
82
|
+
name=sym.name,
|
|
83
|
+
kind=sym.kind,
|
|
84
|
+
container_name=parent_name,
|
|
85
|
+
uri="", # caller fills in
|
|
86
|
+
range=sym.range,
|
|
87
|
+
selection_range=sym.selection_range,
|
|
88
|
+
)
|
|
89
|
+
if sym.children:
|
|
90
|
+
found = find_by_name_in_doc_symbols(sym.children, name, sym.name)
|
|
91
|
+
if found is not None:
|
|
92
|
+
return found
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def resolve_with_fallback(
|
|
97
|
+
model: PythonModel,
|
|
98
|
+
name: str,
|
|
99
|
+
selectors: list[ParsedSelector],
|
|
100
|
+
) -> ResolveResult:
|
|
101
|
+
"""3-tier resolution: index -> workspace/symbol -> documentSymbol."""
|
|
102
|
+
# Tier 1: in-memory index
|
|
103
|
+
resolver = SymbolResolver(model.symbol_index)
|
|
104
|
+
result = resolver.resolve_from_index(name, selectors)
|
|
105
|
+
|
|
106
|
+
if not result.is_not_found:
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
client = model.lsp_client
|
|
110
|
+
if client is None:
|
|
111
|
+
return result
|
|
112
|
+
|
|
113
|
+
# Tier 2: workspace/symbol LSP
|
|
114
|
+
try:
|
|
115
|
+
raw_symbols = await client.request("workspace/symbol", {"query": name})
|
|
116
|
+
except Exception:
|
|
117
|
+
raw_symbols = []
|
|
118
|
+
|
|
119
|
+
if raw_symbols:
|
|
120
|
+
symbols = [SymbolInformation.from_dict(s) for s in raw_symbols]
|
|
121
|
+
|
|
122
|
+
if selectors:
|
|
123
|
+
filtered = filter_by_selectors(symbols, selectors)
|
|
124
|
+
else:
|
|
125
|
+
filtered = symbols
|
|
126
|
+
|
|
127
|
+
if len(filtered) == 1:
|
|
128
|
+
sym = filtered[0]
|
|
129
|
+
return ResolveResult.found(SymbolEntry(
|
|
130
|
+
name=sym.name,
|
|
131
|
+
kind=sym.kind,
|
|
132
|
+
container_name=sym.container_name,
|
|
133
|
+
uri=sym.location.uri,
|
|
134
|
+
range=sym.location.range,
|
|
135
|
+
selection_range=sym.location.range,
|
|
136
|
+
))
|
|
137
|
+
elif len(filtered) > 1:
|
|
138
|
+
entries = [
|
|
139
|
+
SymbolEntry(
|
|
140
|
+
name=s.name,
|
|
141
|
+
kind=s.kind,
|
|
142
|
+
container_name=s.container_name,
|
|
143
|
+
uri=s.location.uri,
|
|
144
|
+
range=s.location.range,
|
|
145
|
+
selection_range=s.location.range,
|
|
146
|
+
)
|
|
147
|
+
for s in filtered
|
|
148
|
+
]
|
|
149
|
+
return ResolveResult.ambiguous(entries)
|
|
150
|
+
|
|
151
|
+
# Tier 3: documentSymbol fallback
|
|
152
|
+
file_sel = next((s for s in selectors if s.selector_type == SelectorType.FILE), None)
|
|
153
|
+
line_sel = next((s for s in selectors if s.selector_type == SelectorType.LINE), None)
|
|
154
|
+
class_sel = next((s for s in selectors if s.selector_type == SelectorType.CLASS), None)
|
|
155
|
+
|
|
156
|
+
# Tier 3a: @file + @line
|
|
157
|
+
if file_sel is not None and line_sel is not None:
|
|
158
|
+
try:
|
|
159
|
+
line_num = int(line_sel.value)
|
|
160
|
+
except ValueError:
|
|
161
|
+
pass
|
|
162
|
+
else:
|
|
163
|
+
uri = (
|
|
164
|
+
file_sel.value
|
|
165
|
+
if file_sel.value.startswith("file://")
|
|
166
|
+
else f"{model.root_uri.rstrip('/')}/{file_sel.value}"
|
|
167
|
+
)
|
|
168
|
+
try:
|
|
169
|
+
raw_doc_symbols = await client.request(
|
|
170
|
+
"textDocument/documentSymbol", {"textDocument": {"uri": uri}}
|
|
171
|
+
)
|
|
172
|
+
except Exception:
|
|
173
|
+
raw_doc_symbols = None
|
|
174
|
+
|
|
175
|
+
if raw_doc_symbols:
|
|
176
|
+
doc_symbols = [DocumentSymbol.from_dict(s) for s in raw_doc_symbols]
|
|
177
|
+
lsp_line = line_num - 1 if line_num > 0 else line_num
|
|
178
|
+
entry = find_in_doc_symbols(doc_symbols, name, lsp_line)
|
|
179
|
+
if entry is not None:
|
|
180
|
+
entry.uri = uri
|
|
181
|
+
return ResolveResult.found(entry)
|
|
182
|
+
|
|
183
|
+
# Tier 3b: @class:NAME — locate class file via workspace/symbol, then documentSymbol
|
|
184
|
+
if class_sel is not None:
|
|
185
|
+
try:
|
|
186
|
+
raw_class_symbols = await client.request(
|
|
187
|
+
"workspace/symbol", {"query": class_sel.value}
|
|
188
|
+
)
|
|
189
|
+
except Exception:
|
|
190
|
+
raw_class_symbols = []
|
|
191
|
+
|
|
192
|
+
if raw_class_symbols:
|
|
193
|
+
class_symbols = [SymbolInformation.from_dict(s) for s in raw_class_symbols]
|
|
194
|
+
class_info = next(
|
|
195
|
+
(s for s in class_symbols if s.name == class_sel.value and s.kind == SymbolKind.Class),
|
|
196
|
+
None,
|
|
197
|
+
)
|
|
198
|
+
if class_info is not None:
|
|
199
|
+
uri = class_info.location.uri
|
|
200
|
+
try:
|
|
201
|
+
raw_doc_symbols = await client.request(
|
|
202
|
+
"textDocument/documentSymbol", {"textDocument": {"uri": uri}}
|
|
203
|
+
)
|
|
204
|
+
except Exception:
|
|
205
|
+
raw_doc_symbols = None
|
|
206
|
+
|
|
207
|
+
if raw_doc_symbols:
|
|
208
|
+
doc_symbols = [DocumentSymbol.from_dict(s) for s in raw_doc_symbols]
|
|
209
|
+
for sym in doc_symbols:
|
|
210
|
+
if sym.name == class_sel.value and sym.kind == SymbolKind.Class:
|
|
211
|
+
if sym.children:
|
|
212
|
+
entry = find_by_name_in_doc_symbols(sym.children, name, sym.name)
|
|
213
|
+
if entry is not None:
|
|
214
|
+
entry.uri = uri
|
|
215
|
+
return ResolveResult.found(entry)
|
|
216
|
+
break
|
|
217
|
+
|
|
218
|
+
return ResolveResult.not_found()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
async def dispatch_query(
|
|
222
|
+
model: PythonModel,
|
|
223
|
+
registry: VerbRegistry,
|
|
224
|
+
input_str: str,
|
|
225
|
+
) -> str:
|
|
226
|
+
"""Parse input, route to handler."""
|
|
227
|
+
op = parse_op(input_str)
|
|
228
|
+
if isinstance(op, ParseError):
|
|
229
|
+
return format_error(f"parse error: {op.error}", None)
|
|
230
|
+
|
|
231
|
+
if registry.lookup(op.verb) is None:
|
|
232
|
+
verb_names = [v.verb for v in registry.verbs]
|
|
233
|
+
suggestion = suggest(op.verb, verb_names)
|
|
234
|
+
return format_error(f"unknown verb '{op.verb}'.", suggestion)
|
|
235
|
+
|
|
236
|
+
match op.verb:
|
|
237
|
+
case "find":
|
|
238
|
+
return await handle_find(model, op.positionals, op.params)
|
|
239
|
+
case "def":
|
|
240
|
+
return await handle_def(model, op.positionals, op.selectors)
|
|
241
|
+
case "refs":
|
|
242
|
+
return await handle_refs(model, op.positionals, op.selectors)
|
|
243
|
+
case "symbols":
|
|
244
|
+
return await handle_symbols(model, op.positionals)
|
|
245
|
+
case "diagnose":
|
|
246
|
+
return handle_diagnose(model, op.positionals)
|
|
247
|
+
case "inspect":
|
|
248
|
+
return await handle_inspect(model, op.positionals, op.selectors)
|
|
249
|
+
case "callers":
|
|
250
|
+
return await handle_callers(model, op.positionals, op.selectors)
|
|
251
|
+
case "callees":
|
|
252
|
+
return await handle_callees(model, op.positionals, op.selectors)
|
|
253
|
+
case "impl":
|
|
254
|
+
return await handle_impl(model, op.positionals, op.selectors)
|
|
255
|
+
case "map":
|
|
256
|
+
return handle_map(model)
|
|
257
|
+
case "unused":
|
|
258
|
+
return handle_unused(model, op.selectors)
|
|
259
|
+
case _:
|
|
260
|
+
return format_error(f"unhandled verb '{op.verb}'.", None)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
async def handle_find(
|
|
264
|
+
model: PythonModel,
|
|
265
|
+
positionals: list[str],
|
|
266
|
+
params: dict[str, str],
|
|
267
|
+
) -> str:
|
|
268
|
+
if not positionals:
|
|
269
|
+
return format_error("find requires a search query.", None)
|
|
270
|
+
query = positionals[0]
|
|
271
|
+
kind_filter = params.get("kind")
|
|
272
|
+
|
|
273
|
+
entries = model.symbol_index.lookup_by_name(query)
|
|
274
|
+
|
|
275
|
+
if kind_filter is not None:
|
|
276
|
+
target_kind = symbol_kind_from_string(kind_filter)
|
|
277
|
+
if target_kind is None:
|
|
278
|
+
return format_error(f"unknown kind '{kind_filter}'.", None)
|
|
279
|
+
entries = [e for e in entries if e.kind == target_kind]
|
|
280
|
+
|
|
281
|
+
if not entries:
|
|
282
|
+
# Try LSP workspace/symbol as fallback
|
|
283
|
+
if model.lsp_client is not None:
|
|
284
|
+
try:
|
|
285
|
+
raw_symbols = await model.lsp_client.request(
|
|
286
|
+
"workspace/symbol", {"query": query}
|
|
287
|
+
)
|
|
288
|
+
if raw_symbols:
|
|
289
|
+
symbols = [SymbolInformation.from_dict(s) for s in raw_symbols]
|
|
290
|
+
locs = [Location(uri=s.location.uri, range=s.location.range) for s in symbols]
|
|
291
|
+
return format_navigation_result(locs, f"matches for '{query}'")
|
|
292
|
+
except Exception:
|
|
293
|
+
pass
|
|
294
|
+
return format_error(f"no symbols matching '{query}'.", None)
|
|
295
|
+
|
|
296
|
+
locs = [Location(uri=e.uri, range=e.range) for e in entries]
|
|
297
|
+
return format_navigation_result(locs, f"matches for '{query}'")
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
async def handle_def(
|
|
301
|
+
model: PythonModel,
|
|
302
|
+
positionals: list[str],
|
|
303
|
+
selectors: list[str],
|
|
304
|
+
) -> str:
|
|
305
|
+
if not positionals:
|
|
306
|
+
return format_error("def requires a symbol name.", None)
|
|
307
|
+
name = positionals[0]
|
|
308
|
+
|
|
309
|
+
parsed_selectors = [s for s in (parse_selector(sel) for sel in selectors) if s is not None]
|
|
310
|
+
result = await resolve_with_fallback(model, name, parsed_selectors)
|
|
311
|
+
|
|
312
|
+
if result.is_found:
|
|
313
|
+
return format_definition(result.entry.uri, result.entry.range)
|
|
314
|
+
elif result.is_ambiguous:
|
|
315
|
+
return format_disambiguation(name, result.entries)
|
|
316
|
+
else:
|
|
317
|
+
return format_error(f"symbol '{name}' not found.", None)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
async def handle_refs(
|
|
321
|
+
model: PythonModel,
|
|
322
|
+
positionals: list[str],
|
|
323
|
+
selectors: list[str],
|
|
324
|
+
) -> str:
|
|
325
|
+
if not positionals:
|
|
326
|
+
return format_error("refs requires a symbol name.", None)
|
|
327
|
+
name = positionals[0]
|
|
328
|
+
|
|
329
|
+
parsed_selectors = [s for s in (parse_selector(sel) for sel in selectors) if s is not None]
|
|
330
|
+
result = await resolve_with_fallback(model, name, parsed_selectors)
|
|
331
|
+
|
|
332
|
+
if result.is_ambiguous:
|
|
333
|
+
return format_disambiguation(name, result.entries)
|
|
334
|
+
if result.is_not_found:
|
|
335
|
+
return format_error(f"symbol '{name}' not found.", None)
|
|
336
|
+
entry = result.entry
|
|
337
|
+
|
|
338
|
+
client = model.lsp_client
|
|
339
|
+
if client is None:
|
|
340
|
+
return format_error("no workspace open.", None)
|
|
341
|
+
|
|
342
|
+
params = {
|
|
343
|
+
"textDocument": {"uri": entry.uri},
|
|
344
|
+
"position": {"line": entry.range.start.line, "character": entry.range.start.character},
|
|
345
|
+
"context": {"includeDeclaration": True},
|
|
346
|
+
}
|
|
347
|
+
try:
|
|
348
|
+
raw_locs = await client.request("textDocument/references", params)
|
|
349
|
+
locations = [Location.from_dict(loc) for loc in raw_locs]
|
|
350
|
+
return format_navigation_result(locations, f"references to '{name}'")
|
|
351
|
+
except Exception as e:
|
|
352
|
+
return format_error(f"LSP error: {e}", None)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
async def handle_symbols(
|
|
356
|
+
model: PythonModel,
|
|
357
|
+
positionals: list[str],
|
|
358
|
+
) -> str:
|
|
359
|
+
if not positionals:
|
|
360
|
+
return format_error("symbols requires a file path.", None)
|
|
361
|
+
path = positionals[0]
|
|
362
|
+
|
|
363
|
+
client = model.lsp_client
|
|
364
|
+
if client is None:
|
|
365
|
+
return format_error("no workspace open.", None)
|
|
366
|
+
|
|
367
|
+
uri = path if path.startswith("file://") else f"{model.root_uri.rstrip('/')}/{path}"
|
|
368
|
+
params = {"textDocument": {"uri": uri}}
|
|
369
|
+
|
|
370
|
+
try:
|
|
371
|
+
raw_symbols = await client.request("textDocument/documentSymbol", params)
|
|
372
|
+
if raw_symbols and isinstance(raw_symbols, list):
|
|
373
|
+
# Try DocumentSymbol[] (hierarchical) first
|
|
374
|
+
if "range" in raw_symbols[0]:
|
|
375
|
+
symbols = [DocumentSymbol.from_dict(s) for s in raw_symbols]
|
|
376
|
+
return format_symbol_outline(uri, symbols, 0)
|
|
377
|
+
else:
|
|
378
|
+
# Fallback: SymbolInformation[]
|
|
379
|
+
sym_infos = [SymbolInformation.from_dict(s) for s in raw_symbols]
|
|
380
|
+
doc_symbols = [
|
|
381
|
+
DocumentSymbol(
|
|
382
|
+
name=s.name,
|
|
383
|
+
kind=s.kind,
|
|
384
|
+
range=s.location.range,
|
|
385
|
+
selection_range=s.location.range,
|
|
386
|
+
)
|
|
387
|
+
for s in sym_infos
|
|
388
|
+
]
|
|
389
|
+
return format_symbol_outline(uri, doc_symbols, 0)
|
|
390
|
+
return format_symbol_outline(uri, [], 0)
|
|
391
|
+
except Exception as e:
|
|
392
|
+
return format_error(f"LSP error: {e}", None)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def handle_diagnose(model: PythonModel, positionals: list[str]) -> str:
|
|
396
|
+
if positionals:
|
|
397
|
+
path = positionals[0]
|
|
398
|
+
uri = path if path.startswith("file://") else f"{model.root_uri.rstrip('/')}/{path}"
|
|
399
|
+
for diag_uri, diags in model.diagnostics.items():
|
|
400
|
+
if diag_uri == uri or diag_uri.endswith(path):
|
|
401
|
+
return format_diagnostics(diag_uri, diags)
|
|
402
|
+
return format_diagnostics(uri, [])
|
|
403
|
+
else:
|
|
404
|
+
if not model.diagnostics:
|
|
405
|
+
return "Workspace: clean — no diagnostics."
|
|
406
|
+
lines = []
|
|
407
|
+
errors, warnings = model.total_diagnostics()
|
|
408
|
+
lines.append(f"Workspace diagnostics: {errors} errors, {warnings} warnings")
|
|
409
|
+
for uri, diags in model.diagnostics.items():
|
|
410
|
+
lines.append(format_diagnostics(uri, diags))
|
|
411
|
+
return "\n\n".join(lines)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
async def handle_inspect(
|
|
415
|
+
model: PythonModel,
|
|
416
|
+
positionals: list[str],
|
|
417
|
+
selectors: list[str],
|
|
418
|
+
) -> str:
|
|
419
|
+
if not positionals:
|
|
420
|
+
return format_error("inspect requires a symbol name.", None)
|
|
421
|
+
name = positionals[0]
|
|
422
|
+
|
|
423
|
+
parsed_selectors = [s for s in (parse_selector(sel) for sel in selectors) if s is not None]
|
|
424
|
+
result = await resolve_with_fallback(model, name, parsed_selectors)
|
|
425
|
+
|
|
426
|
+
if result.is_ambiguous:
|
|
427
|
+
return format_disambiguation(name, result.entries)
|
|
428
|
+
if result.is_not_found:
|
|
429
|
+
return format_error(f"symbol '{name}' not found.", None)
|
|
430
|
+
entry = result.entry
|
|
431
|
+
|
|
432
|
+
client = model.lsp_client
|
|
433
|
+
if client is None:
|
|
434
|
+
kind_str = entry.kind.display_name()
|
|
435
|
+
return format_hover(name, kind_str, entry.uri, entry.range, "")
|
|
436
|
+
|
|
437
|
+
params = {
|
|
438
|
+
"textDocument": {"uri": entry.uri},
|
|
439
|
+
"position": {
|
|
440
|
+
"line": entry.selection_range.start.line,
|
|
441
|
+
"character": entry.selection_range.start.character,
|
|
442
|
+
},
|
|
443
|
+
}
|
|
444
|
+
try:
|
|
445
|
+
raw_hover = await client.request("textDocument/hover", params)
|
|
446
|
+
if raw_hover is None:
|
|
447
|
+
kind_str = entry.kind.display_name()
|
|
448
|
+
return format_hover(name, kind_str, entry.uri, entry.range, "")
|
|
449
|
+
hover = Hover.from_dict(raw_hover)
|
|
450
|
+
contents = hover.contents
|
|
451
|
+
if isinstance(contents, str):
|
|
452
|
+
text = contents
|
|
453
|
+
elif isinstance(contents, MarkupContent):
|
|
454
|
+
text = contents.value
|
|
455
|
+
elif isinstance(contents, list):
|
|
456
|
+
text = "\n".join(contents)
|
|
457
|
+
else:
|
|
458
|
+
text = ""
|
|
459
|
+
kind_str = entry.kind.display_name()
|
|
460
|
+
return format_hover(name, kind_str, entry.uri, entry.range, text)
|
|
461
|
+
except Exception as e:
|
|
462
|
+
return format_error(f"LSP error: {e}", None)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
async def handle_callers(
|
|
466
|
+
model: PythonModel,
|
|
467
|
+
positionals: list[str],
|
|
468
|
+
selectors: list[str],
|
|
469
|
+
) -> str:
|
|
470
|
+
if not positionals:
|
|
471
|
+
return format_error("callers requires a symbol name.", None)
|
|
472
|
+
name = positionals[0]
|
|
473
|
+
|
|
474
|
+
parsed_selectors = [s for s in (parse_selector(sel) for sel in selectors) if s is not None]
|
|
475
|
+
result = await resolve_with_fallback(model, name, parsed_selectors)
|
|
476
|
+
|
|
477
|
+
if result.is_ambiguous:
|
|
478
|
+
return format_disambiguation(name, result.entries)
|
|
479
|
+
if result.is_not_found:
|
|
480
|
+
return format_error(f"symbol '{name}' not found.", None)
|
|
481
|
+
entry = result.entry
|
|
482
|
+
|
|
483
|
+
client = model.lsp_client
|
|
484
|
+
if client is None:
|
|
485
|
+
return format_error("no workspace open.", None)
|
|
486
|
+
|
|
487
|
+
prepare_params = {
|
|
488
|
+
"textDocument": {"uri": entry.uri},
|
|
489
|
+
"position": {
|
|
490
|
+
"line": entry.selection_range.start.line,
|
|
491
|
+
"character": entry.selection_range.start.character,
|
|
492
|
+
},
|
|
493
|
+
}
|
|
494
|
+
try:
|
|
495
|
+
raw_items = await client.request("textDocument/prepareCallHierarchy", prepare_params)
|
|
496
|
+
except Exception as e:
|
|
497
|
+
return format_error(f"LSP error: {e}", None)
|
|
498
|
+
|
|
499
|
+
if not raw_items:
|
|
500
|
+
return format_callers(name, [])
|
|
501
|
+
|
|
502
|
+
item = CallHierarchyItem.from_dict(raw_items[0])
|
|
503
|
+
try:
|
|
504
|
+
raw_calls = await client.request(
|
|
505
|
+
"callHierarchy/incomingCalls", {"item": item.to_dict()}
|
|
506
|
+
)
|
|
507
|
+
calls = [CallHierarchyIncomingCall.from_dict(c) for c in raw_calls]
|
|
508
|
+
return format_callers(name, calls)
|
|
509
|
+
except Exception as e:
|
|
510
|
+
return format_error(f"LSP error: {e}", None)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
async def handle_callees(
|
|
514
|
+
model: PythonModel,
|
|
515
|
+
positionals: list[str],
|
|
516
|
+
selectors: list[str],
|
|
517
|
+
) -> str:
|
|
518
|
+
if not positionals:
|
|
519
|
+
return format_error("callees requires a symbol name.", None)
|
|
520
|
+
name = positionals[0]
|
|
521
|
+
|
|
522
|
+
parsed_selectors = [s for s in (parse_selector(sel) for sel in selectors) if s is not None]
|
|
523
|
+
result = await resolve_with_fallback(model, name, parsed_selectors)
|
|
524
|
+
|
|
525
|
+
if result.is_ambiguous:
|
|
526
|
+
return format_disambiguation(name, result.entries)
|
|
527
|
+
if result.is_not_found:
|
|
528
|
+
return format_error(f"symbol '{name}' not found.", None)
|
|
529
|
+
entry = result.entry
|
|
530
|
+
|
|
531
|
+
client = model.lsp_client
|
|
532
|
+
if client is None:
|
|
533
|
+
return format_error("no workspace open.", None)
|
|
534
|
+
|
|
535
|
+
prepare_params = {
|
|
536
|
+
"textDocument": {"uri": entry.uri},
|
|
537
|
+
"position": {
|
|
538
|
+
"line": entry.selection_range.start.line,
|
|
539
|
+
"character": entry.selection_range.start.character,
|
|
540
|
+
},
|
|
541
|
+
}
|
|
542
|
+
try:
|
|
543
|
+
raw_items = await client.request("textDocument/prepareCallHierarchy", prepare_params)
|
|
544
|
+
except Exception as e:
|
|
545
|
+
return format_error(f"LSP error: {e}", None)
|
|
546
|
+
|
|
547
|
+
if not raw_items:
|
|
548
|
+
return format_callees(name, [])
|
|
549
|
+
|
|
550
|
+
item = CallHierarchyItem.from_dict(raw_items[0])
|
|
551
|
+
try:
|
|
552
|
+
raw_calls = await client.request(
|
|
553
|
+
"callHierarchy/outgoingCalls", {"item": item.to_dict()}
|
|
554
|
+
)
|
|
555
|
+
calls = [CallHierarchyOutgoingCall.from_dict(c) for c in raw_calls]
|
|
556
|
+
return format_callees(name, calls)
|
|
557
|
+
except Exception as e:
|
|
558
|
+
return format_error(f"LSP error: {e}", None)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
async def handle_impl(
|
|
562
|
+
model: PythonModel,
|
|
563
|
+
positionals: list[str],
|
|
564
|
+
selectors: list[str],
|
|
565
|
+
) -> str:
|
|
566
|
+
if not positionals:
|
|
567
|
+
return format_error("impl requires a symbol name.", None)
|
|
568
|
+
name = positionals[0]
|
|
569
|
+
|
|
570
|
+
parsed_selectors = [s for s in (parse_selector(sel) for sel in selectors) if s is not None]
|
|
571
|
+
result = await resolve_with_fallback(model, name, parsed_selectors)
|
|
572
|
+
|
|
573
|
+
if result.is_ambiguous:
|
|
574
|
+
return format_disambiguation(name, result.entries)
|
|
575
|
+
if result.is_not_found:
|
|
576
|
+
return format_error(f"symbol '{name}' not found.", None)
|
|
577
|
+
entry = result.entry
|
|
578
|
+
|
|
579
|
+
client = model.lsp_client
|
|
580
|
+
if client is None:
|
|
581
|
+
return format_error("no workspace open.", None)
|
|
582
|
+
|
|
583
|
+
params = {
|
|
584
|
+
"textDocument": {"uri": entry.uri},
|
|
585
|
+
"position": {
|
|
586
|
+
"line": entry.selection_range.start.line,
|
|
587
|
+
"character": entry.selection_range.start.character,
|
|
588
|
+
},
|
|
589
|
+
}
|
|
590
|
+
try:
|
|
591
|
+
raw_locs = await client.request("textDocument/implementation", params)
|
|
592
|
+
locations = [Location.from_dict(loc) for loc in raw_locs]
|
|
593
|
+
return format_implementations(name, locations)
|
|
594
|
+
except Exception as e:
|
|
595
|
+
return format_error(f"LSP error: {e}", None)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def handle_map(model: PythonModel) -> str:
|
|
599
|
+
errors, warnings = model.total_diagnostics()
|
|
600
|
+
return format_workspace_map(
|
|
601
|
+
model.root_uri,
|
|
602
|
+
model.py_file_count,
|
|
603
|
+
model.symbol_index.size(),
|
|
604
|
+
errors,
|
|
605
|
+
warnings,
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def handle_unused(model: PythonModel, selectors: list[str]) -> str:
|
|
610
|
+
parsed_selectors = [s for s in (parse_selector(sel) for sel in selectors) if s is not None]
|
|
611
|
+
file_filter = next(
|
|
612
|
+
(s.value for s in parsed_selectors if s.selector_type == SelectorType.FILE), None
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
unused_patterns = ["unused", "never read", "never constructed", "never used", "dead_code"]
|
|
616
|
+
|
|
617
|
+
items: list[tuple[str, Diagnostic]] = []
|
|
618
|
+
for uri, diags in model.diagnostics.items():
|
|
619
|
+
if file_filter and file_filter not in uri:
|
|
620
|
+
continue
|
|
621
|
+
for diag in diags:
|
|
622
|
+
msg_lower = diag.message.lower()
|
|
623
|
+
if any(p in msg_lower for p in unused_patterns):
|
|
624
|
+
items.append((uri, diag))
|
|
625
|
+
|
|
626
|
+
items.sort(key=lambda x: (x[0], x[1].range.start.line))
|
|
627
|
+
return format_unused(items)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Verb registration for fcp-python."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fcp_core import VerbRegistry, VerbSpec
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def register_query_verbs(registry: VerbRegistry) -> None:
|
|
9
|
+
registry.register_many([
|
|
10
|
+
VerbSpec(verb="find", syntax="find QUERY [kind:KIND]", category="navigation"),
|
|
11
|
+
VerbSpec(verb="def", syntax="def SYMBOL [@selectors...]", category="navigation"),
|
|
12
|
+
VerbSpec(verb="refs", syntax="refs SYMBOL [@selectors...]", category="navigation"),
|
|
13
|
+
VerbSpec(verb="symbols", syntax="symbols PATH [kind:KIND]", category="navigation"),
|
|
14
|
+
VerbSpec(verb="diagnose", syntax="diagnose [PATH] [@all]", category="inspection"),
|
|
15
|
+
VerbSpec(verb="inspect", syntax="inspect SYMBOL [@selectors...]", category="inspection"),
|
|
16
|
+
VerbSpec(verb="callers", syntax="callers SYMBOL [@selectors...]", category="inspection"),
|
|
17
|
+
VerbSpec(verb="callees", syntax="callees SYMBOL [@selectors...]", category="inspection"),
|
|
18
|
+
VerbSpec(verb="impl", syntax="impl SYMBOL [@selectors...]", category="navigation"),
|
|
19
|
+
VerbSpec(verb="map", syntax="map", category="inspection"),
|
|
20
|
+
VerbSpec(verb="unused", syntax="unused [@file:PATH]", category="inspection"),
|
|
21
|
+
])
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def register_mutation_verbs(registry: VerbRegistry) -> None:
|
|
25
|
+
registry.register_many([
|
|
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
|
+
])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def register_session_verbs(registry: VerbRegistry) -> None:
|
|
33
|
+
registry.register_many([
|
|
34
|
+
VerbSpec(verb="open", syntax="open PATH", category="session"),
|
|
35
|
+
VerbSpec(verb="status", syntax="status", category="session"),
|
|
36
|
+
VerbSpec(verb="close", syntax="close", category="session"),
|
|
37
|
+
])
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""LSP client layer for fcp-python."""
|