fcp-python 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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."""