cicada-mcp 0.2.0__py3-none-any.whl → 0.3.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.
Files changed (62) hide show
  1. cicada/_version_hash.py +4 -0
  2. cicada/cli.py +6 -748
  3. cicada/commands.py +1255 -0
  4. cicada/dead_code/__init__.py +1 -0
  5. cicada/{find_dead_code.py → dead_code/finder.py} +2 -1
  6. cicada/dependency_analyzer.py +147 -0
  7. cicada/entry_utils.py +92 -0
  8. cicada/extractors/base.py +9 -9
  9. cicada/extractors/call.py +17 -20
  10. cicada/extractors/common.py +64 -0
  11. cicada/extractors/dependency.py +117 -235
  12. cicada/extractors/doc.py +2 -49
  13. cicada/extractors/function.py +10 -14
  14. cicada/extractors/keybert.py +228 -0
  15. cicada/extractors/keyword.py +191 -0
  16. cicada/extractors/module.py +6 -10
  17. cicada/extractors/spec.py +8 -56
  18. cicada/format/__init__.py +20 -0
  19. cicada/{ascii_art.py → format/ascii_art.py} +1 -1
  20. cicada/format/formatter.py +1145 -0
  21. cicada/git_helper.py +134 -7
  22. cicada/indexer.py +322 -89
  23. cicada/interactive_setup.py +251 -323
  24. cicada/interactive_setup_helpers.py +302 -0
  25. cicada/keyword_expander.py +437 -0
  26. cicada/keyword_search.py +208 -422
  27. cicada/keyword_test.py +383 -16
  28. cicada/mcp/__init__.py +10 -0
  29. cicada/mcp/entry.py +17 -0
  30. cicada/mcp/filter_utils.py +107 -0
  31. cicada/mcp/pattern_utils.py +118 -0
  32. cicada/{mcp_server.py → mcp/server.py} +819 -73
  33. cicada/mcp/tools.py +473 -0
  34. cicada/pr_finder.py +2 -3
  35. cicada/pr_indexer/indexer.py +3 -2
  36. cicada/setup.py +167 -35
  37. cicada/tier.py +225 -0
  38. cicada/utils/__init__.py +9 -2
  39. cicada/utils/fuzzy_match.py +54 -0
  40. cicada/utils/index_utils.py +9 -0
  41. cicada/utils/path_utils.py +18 -0
  42. cicada/utils/text_utils.py +52 -1
  43. cicada/utils/tree_utils.py +47 -0
  44. cicada/version_check.py +99 -0
  45. cicada/watch_manager.py +320 -0
  46. cicada/watcher.py +431 -0
  47. cicada_mcp-0.3.0.dist-info/METADATA +541 -0
  48. cicada_mcp-0.3.0.dist-info/RECORD +70 -0
  49. cicada_mcp-0.3.0.dist-info/entry_points.txt +4 -0
  50. cicada/formatter.py +0 -864
  51. cicada/keybert_extractor.py +0 -286
  52. cicada/lightweight_keyword_extractor.py +0 -290
  53. cicada/mcp_entry.py +0 -683
  54. cicada/mcp_tools.py +0 -291
  55. cicada_mcp-0.2.0.dist-info/METADATA +0 -735
  56. cicada_mcp-0.2.0.dist-info/RECORD +0 -53
  57. cicada_mcp-0.2.0.dist-info/entry_points.txt +0 -4
  58. /cicada/{dead_code_analyzer.py → dead_code/analyzer.py} +0 -0
  59. /cicada/{colors.py → format/colors.py} +0 -0
  60. {cicada_mcp-0.2.0.dist-info → cicada_mcp-0.3.0.dist-info}/WHEEL +0 -0
  61. {cicada_mcp-0.2.0.dist-info → cicada_mcp-0.3.0.dist-info}/licenses/LICENSE +0 -0
  62. {cicada_mcp-0.2.0.dist-info → cicada_mcp-0.3.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1145 @@
1
+ #!/usr/bin/env python
2
+ """
3
+ Formatter Module - Formats module search results in various formats.
4
+
5
+ This module provides formatting utilities for Cicada MCP server responses,
6
+ supporting both Markdown and JSON output formats.
7
+ """
8
+
9
+ import argparse
10
+ import json
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from cicada.utils import (
16
+ CallSiteFormatter,
17
+ FunctionGrouper,
18
+ SignatureBuilder,
19
+ find_similar_names,
20
+ )
21
+
22
+
23
+ class ModuleFormatter:
24
+ """Formats Cicada module data in various output formats."""
25
+
26
+ @staticmethod
27
+ def _group_call_sites_by_caller(call_sites):
28
+ return CallSiteFormatter.group_by_caller(call_sites)
29
+
30
+ @staticmethod
31
+ def _find_similar_names(
32
+ query: str,
33
+ candidate_names: list[str],
34
+ max_suggestions: int = 5,
35
+ threshold: float = 0.4,
36
+ ) -> list[tuple[str, float]]:
37
+ """
38
+ Proxy to the shared fuzzy-matching helper so tests can exercise the logic in isolation.
39
+ """
40
+ if not candidate_names:
41
+ return []
42
+ return find_similar_names(
43
+ query=query,
44
+ candidates=candidate_names,
45
+ max_suggestions=max_suggestions,
46
+ threshold=threshold,
47
+ )
48
+
49
+ @staticmethod
50
+ def _format_pr_context(
51
+ pr_info: dict | None, file_path: str, function_name: str | None = None
52
+ ) -> list[str]:
53
+ """
54
+ Format PR context information with suggestions when unavailable.
55
+
56
+ Args:
57
+ pr_info: Optional PR context (number, title, author, comment_count)
58
+ file_path: Path to the file
59
+ function_name: Optional function name for more specific suggestions
60
+
61
+ Returns:
62
+ List of formatted lines to append to output. The first line is always
63
+ an empty string (for spacing), followed by either:
64
+ - PR context lines (if pr_info provided): PR title, author, comment count
65
+ - Suggestion lines (if no pr_info): Instructions on how to get context
66
+ """
67
+ lines = []
68
+ if pr_info:
69
+ lines.append("")
70
+ lines.append(
71
+ f"📝 Last modified: PR #{pr_info['number']} \"{pr_info['title']}\" by @{pr_info['author']}"
72
+ )
73
+ if pr_info["comment_count"] > 0:
74
+ lines.append(
75
+ f"💬 {pr_info['comment_count']} review comment(s) • Use: get_file_pr_history(\"{file_path}\")"
76
+ )
77
+ else:
78
+ # Suggest how to get context when PR info unavailable
79
+ lines.append("")
80
+ lines.append("💭 Want to know why this code exists?")
81
+ lines.append(" • Build PR index: Ask user to run 'cicada index-pr'")
82
+ if function_name:
83
+ lines.append(
84
+ f' • Check git history: get_commit_history("{file_path}", function_name="{function_name}")'
85
+ )
86
+ else:
87
+ lines.append(f' • Check git history: get_commit_history("{file_path}")')
88
+ return lines
89
+
90
+ @staticmethod
91
+ def format_module_markdown(
92
+ module_name: str,
93
+ data: dict[str, Any],
94
+ private_functions: str = "exclude",
95
+ pr_info: dict | None = None,
96
+ staleness_info: dict | None = None,
97
+ ) -> str:
98
+ """
99
+ Format module data as Markdown.
100
+
101
+ Args:
102
+ module_name: The name of the module
103
+ data: The module data dictionary from the index
104
+ private_functions: How to handle private functions: 'exclude' (hide), 'include' (show all), or 'only' (show only private)
105
+ pr_info: Optional PR context (number, title, comment_count)
106
+ staleness_info: Optional staleness info (is_stale, age_str)
107
+
108
+ Returns:
109
+ Formatted Markdown string
110
+ """
111
+ # Group functions by type (def = public, defp = private)
112
+ public_funcs = [f for f in data["functions"] if f["type"] == "def"]
113
+ private_funcs = [f for f in data["functions"] if f["type"] == "defp"]
114
+
115
+ # Group by name/arity to deduplicate function clauses
116
+ public_grouped = FunctionGrouper.group_by_name_arity(public_funcs)
117
+ private_grouped = FunctionGrouper.group_by_name_arity(private_funcs)
118
+
119
+ # Count unique functions, not function clauses
120
+ public_count = len(public_grouped)
121
+ private_count = len(private_grouped)
122
+
123
+ # Build the markdown output - compact format
124
+ lines = [
125
+ module_name,
126
+ "",
127
+ f"{data['file']}:{data['line']} • {public_count} public • {private_count} private",
128
+ ]
129
+
130
+ # Add staleness warning if applicable
131
+ if staleness_info and staleness_info.get("is_stale"):
132
+ lines.append("")
133
+ lines.append(
134
+ f"⚠️ Index may be stale (index is {staleness_info['age_str']} old, files have been modified)"
135
+ )
136
+ lines.append(" Please ask the user to run: cicada index")
137
+ lines.append("")
138
+ lines.append(" 💭 Recent changes might be in merged PRs:")
139
+ lines.append(f" get_file_pr_history(\"{data['file']}\")")
140
+
141
+ # Add PR context if available
142
+ lines.extend(ModuleFormatter._format_pr_context(pr_info, data["file"]))
143
+
144
+ # Add moduledoc if present (first paragraph only for brevity)
145
+ if data.get("moduledoc"):
146
+ doc = data["moduledoc"].strip()
147
+ # Get first paragraph (up to double newline or first 200 chars)
148
+ first_para = doc.split("\n\n")[0].strip()
149
+ if len(first_para) > 200:
150
+ first_para = first_para[:200] + "..."
151
+ lines.extend(["", first_para])
152
+
153
+ # Show public functions (unless private_functions == "only")
154
+ if public_grouped and private_functions != "only":
155
+ lines.extend(["", "Public:", ""])
156
+ # Sort by line number instead of function name
157
+ for (_, _), clauses in sorted(public_grouped.items(), key=lambda x: x[1][0]["line"]):
158
+ # Use the first clause for display (they all have same name/arity)
159
+ func = clauses[0]
160
+ func_sig = SignatureBuilder.build(func)
161
+ lines.append(f"{func['line']:>5}: {func_sig}")
162
+
163
+ # Show private functions (if private_functions == "include" or "only")
164
+ if private_grouped and private_functions in ["include", "only"]:
165
+ lines.extend(["", "Private:", ""])
166
+ # Sort by line number instead of function name
167
+ for (_, _), clauses in sorted(private_grouped.items(), key=lambda x: x[1][0]["line"]):
168
+ # Use the first clause for display (they all have same name/arity)
169
+ func = clauses[0]
170
+ func_sig = SignatureBuilder.build(func)
171
+ lines.append(f"{func['line']:>5}: {func_sig}")
172
+
173
+ # Check if there are no functions to display based on the filter
174
+ has_functions_to_show = (private_functions != "only" and public_grouped) or (
175
+ private_functions in ["include", "only"] and private_grouped
176
+ )
177
+
178
+ if not has_functions_to_show:
179
+ if private_functions == "only" and not private_grouped:
180
+ lines.extend(["", "*No private functions found*"])
181
+ elif not data["functions"]:
182
+ lines.extend(["", "*No functions found*"])
183
+
184
+ return "\n".join(lines)
185
+
186
+ @staticmethod
187
+ def format_module_json(
188
+ module_name: str, data: dict[str, Any], private_functions: str = "exclude"
189
+ ) -> str:
190
+ """
191
+ Format module data as JSON.
192
+
193
+ Args:
194
+ module_name: The name of the module
195
+ data: The module data dictionary from the index
196
+ private_functions: How to handle private functions: 'exclude' (hide), 'include' (show all), or 'only' (show only private)
197
+
198
+ Returns:
199
+ Formatted JSON string
200
+ """
201
+ # Filter functions based on private_functions parameter
202
+ if private_functions == "exclude":
203
+ # Only public functions
204
+ filtered_funcs = [f for f in data["functions"] if f["type"] == "def"]
205
+ elif private_functions == "only":
206
+ # Only private functions
207
+ filtered_funcs = [f for f in data["functions"] if f["type"] == "defp"]
208
+ else: # "include"
209
+ # All functions
210
+ filtered_funcs = data["functions"]
211
+
212
+ # Group functions by name/arity to deduplicate function clauses
213
+ grouped = FunctionGrouper.group_by_name_arity(filtered_funcs)
214
+
215
+ # Compact function format - one entry per unique name/arity
216
+ functions = [
217
+ {
218
+ "signature": SignatureBuilder.build(clauses[0]),
219
+ "line": clauses[0]["line"],
220
+ "type": clauses[0]["type"],
221
+ }
222
+ for (_, _), clauses in sorted(grouped.items())
223
+ ]
224
+
225
+ result = {
226
+ "module": module_name,
227
+ "location": f"{data['file']}:{data['line']}",
228
+ "moduledoc": data.get("moduledoc"),
229
+ "counts": {
230
+ "public": data["public_functions"],
231
+ "private": data["private_functions"],
232
+ },
233
+ "functions": functions,
234
+ }
235
+ return json.dumps(result, indent=2)
236
+
237
+ @staticmethod
238
+ def format_error_markdown(
239
+ module_name: str, total_modules: int, suggestions: list[str] | None = None
240
+ ) -> str:
241
+ """
242
+ Format error message as Markdown with suggestions.
243
+
244
+ Args:
245
+ module_name: The queried module name
246
+ total_modules: Total number of modules in the index
247
+ suggestions: Optional list of suggested similar module names (pre-computed)
248
+
249
+ Returns:
250
+ Formatted Markdown error message
251
+ """
252
+ lines = [
253
+ "❌ Module Not Found",
254
+ "",
255
+ f"**Query:** `{module_name}`",
256
+ "",
257
+ ]
258
+
259
+ # Add "did you mean" suggestions if provided
260
+ if suggestions:
261
+ lines.append("## Did you mean?")
262
+ lines.append("")
263
+ for name in suggestions:
264
+ lines.append(f" • `{name}`")
265
+ lines.append("")
266
+
267
+ # Add alternative search strategies
268
+ lines.extend(
269
+ [
270
+ "## Try:",
271
+ "",
272
+ ]
273
+ )
274
+
275
+ # Add wildcard and semantic search suggestions if module_name is valid
276
+ if module_name and module_name.strip():
277
+ last_component = module_name.split(".")[-1] if "." in module_name else module_name
278
+ if last_component and last_component.strip():
279
+ lines.append(f" • Wildcard search: search_module('*{last_component}*')")
280
+ lines.append(
281
+ f" • Semantic search: search_by_features(['{last_component.lower()}'])"
282
+ )
283
+
284
+ lines.extend(
285
+ [
286
+ " • Check exact spelling and capitalization (module names are case-sensitive)",
287
+ "",
288
+ f"Total modules in index: **{total_modules}**",
289
+ ]
290
+ )
291
+
292
+ return "\n".join(lines)
293
+
294
+ @staticmethod
295
+ def format_error_json(module_name: str, total_modules: int) -> str:
296
+ """
297
+ Format error message as JSON.
298
+
299
+ Args:
300
+ module_name: The queried module name
301
+ total_modules: Total number of modules in the index
302
+
303
+ Returns:
304
+ Formatted JSON error message
305
+ """
306
+ error_result = {
307
+ "error": "Module not found",
308
+ "query": module_name,
309
+ "hint": "Use the exact module name as it appears in the code",
310
+ "total_modules_available": total_modules,
311
+ }
312
+ return json.dumps(error_result, indent=2)
313
+
314
+ @staticmethod
315
+ def _format_remaining_code_sites(remaining_code, indent):
316
+ lines = []
317
+ grouped_remaining_code = CallSiteFormatter.group_by_caller(remaining_code)
318
+ remaining_code_count = sum(len(site["lines"]) for site in grouped_remaining_code)
319
+ lines.append(f"{indent}Code ({remaining_code_count}):")
320
+ for site in grouped_remaining_code:
321
+ calling_func = site.get("calling_function")
322
+ if calling_func:
323
+ caller = f"{site['calling_module']}.{calling_func['name']}/{calling_func['arity']}"
324
+ else:
325
+ caller = site["calling_module"]
326
+
327
+ line_list = ", ".join(f":{line}" for line in site["lines"])
328
+ lines.append(f"{indent}- {caller} at {site['file']}{line_list}")
329
+ return lines
330
+
331
+ @staticmethod
332
+ def _format_test_sites_without_examples(test_sites, code_sites, indent):
333
+ lines = []
334
+ if code_sites:
335
+ lines.append("") # Blank line between sections
336
+ # Group test sites by caller
337
+ grouped_test = CallSiteFormatter.group_by_caller(test_sites)
338
+ test_count = sum(len(site["lines"]) for site in grouped_test)
339
+ lines.append(f"{indent}Test ({test_count}):")
340
+ for site in grouped_test:
341
+ # Format calling location with function if available
342
+ calling_func = site.get("calling_function")
343
+ if calling_func:
344
+ caller = f"{site['calling_module']}.{calling_func['name']}/{calling_func['arity']}"
345
+ else:
346
+ caller = site["calling_module"]
347
+
348
+ # Show consolidated line numbers
349
+ line_list = ", ".join(f":{line}" for line in site["lines"])
350
+ lines.append(f"{indent}- {caller} at {site['file']}{line_list}")
351
+ return lines
352
+
353
+ @staticmethod
354
+ def _format_code_sites_without_examples(code_sites, indent):
355
+ lines = []
356
+ # Group code sites by caller
357
+ grouped_code = CallSiteFormatter.group_by_caller(code_sites)
358
+ code_count = sum(len(site["lines"]) for site in grouped_code)
359
+ lines.append(f"{indent}Code ({code_count}):")
360
+ for site in grouped_code:
361
+ # Format calling location with function if available
362
+ calling_func = site.get("calling_function")
363
+ if calling_func:
364
+ caller = f"{site['calling_module']}.{calling_func['name']}/{calling_func['arity']}"
365
+ else:
366
+ caller = site["calling_module"]
367
+
368
+ # Show consolidated line numbers
369
+ line_list = ", ".join(f":{line}" for line in site["lines"])
370
+ lines.append(f"{indent}- {caller} at {site['file']}{line_list}")
371
+ return lines
372
+
373
+ @staticmethod
374
+ def _format_remaining_test_sites(remaining_test, remaining_code, indent):
375
+ lines = []
376
+ if remaining_code:
377
+ lines.append("")
378
+ grouped_remaining_test = CallSiteFormatter.group_by_caller(remaining_test)
379
+ remaining_test_count = sum(len(site["lines"]) for site in grouped_remaining_test)
380
+ lines.append(f"{indent}Test ({remaining_test_count}):")
381
+ for site in grouped_remaining_test:
382
+ calling_func = site.get("calling_function")
383
+ if calling_func:
384
+ caller = f"{site['calling_module']}.{calling_func['name']}/{calling_func['arity']}"
385
+ else:
386
+ caller = site["calling_module"]
387
+
388
+ line_list = ", ".join(f":{line}" for line in site["lines"])
389
+ lines.append(f"{indent}- {caller} at {site['file']}{line_list}")
390
+ return lines
391
+
392
+ @staticmethod
393
+ def _format_grouped_test_sites(grouped_test, indent):
394
+ lines = []
395
+ for site in grouped_test:
396
+ # Format calling location with function if available
397
+ calling_func = site.get("calling_function")
398
+ if calling_func:
399
+ caller = f"{site['calling_module']}.{calling_func['name']}/{calling_func['arity']}"
400
+ else:
401
+ caller = site["calling_module"]
402
+
403
+ # Show consolidated line numbers only if multiple lines
404
+ if len(site["lines"]) > 1:
405
+ line_list = ", ".join(f":{line}" for line in site["lines"])
406
+ lines.append(f"{indent}- {caller} at {site['file']}{line_list}")
407
+ else:
408
+ lines.append(f"{indent}- {caller} at {site['file']}")
409
+
410
+ # Add the actual code lines if available
411
+ if site.get("code_lines"):
412
+ for code_entry in site["code_lines"]:
413
+ # Properly indent each line of the code block
414
+ code_lines = code_entry["code"].split("\n")
415
+ for code_line in code_lines:
416
+ lines.append(f"{indent} {code_line}")
417
+ return lines
418
+
419
+ @staticmethod
420
+ def _format_grouped_code_sites(grouped_code, indent):
421
+ lines = []
422
+ for site in grouped_code:
423
+ # Format calling location with function if available
424
+ calling_func = site.get("calling_function")
425
+ if calling_func:
426
+ caller = f"{site['calling_module']}.{calling_func['name']}/{calling_func['arity']}"
427
+ else:
428
+ caller = site["calling_module"]
429
+
430
+ # Show consolidated line numbers only if multiple lines
431
+ if len(site["lines"]) > 1:
432
+ line_list = ", ".join(f":{line}" for line in site["lines"])
433
+ lines.append(f"{indent}- {caller} at {site['file']}{line_list}")
434
+ else:
435
+ lines.append(f"{indent}- {caller} at {site['file']}")
436
+
437
+ # Add the actual code lines if available
438
+ if site.get("code_lines"):
439
+ for code_entry in site["code_lines"]:
440
+ # Properly indent each line of the code block
441
+ code_lines = code_entry["code"].split("\n")
442
+ for code_line in code_lines:
443
+ lines.append(f"{indent} {code_line}")
444
+ return lines
445
+
446
+ @staticmethod
447
+ def _format_remaining_call_sites(call_sites, call_sites_with_examples, indent):
448
+ lines = []
449
+ # Create a set of call sites that were shown with examples
450
+ shown_call_lines = set()
451
+ for site in call_sites_with_examples:
452
+ shown_call_lines.add((site["file"], site["line"]))
453
+
454
+ # Filter to get call sites not yet shown
455
+ remaining_call_sites = [
456
+ site for site in call_sites if (site["file"], site["line"]) not in shown_call_lines
457
+ ]
458
+
459
+ if remaining_call_sites:
460
+ # Separate into code and test
461
+ remaining_code = [s for s in remaining_call_sites if "test" not in s["file"].lower()]
462
+ remaining_test = [s for s in remaining_call_sites if "test" in s["file"].lower()]
463
+
464
+ lines.append("")
465
+ lines.append(f"{indent}Other Call Sites:")
466
+
467
+ if remaining_code:
468
+ lines.extend(ModuleFormatter._format_remaining_code_sites(remaining_code, indent))
469
+
470
+ if remaining_test:
471
+ lines.extend(
472
+ ModuleFormatter._format_remaining_test_sites(
473
+ remaining_test, remaining_code, indent
474
+ )
475
+ )
476
+ return lines
477
+
478
+ @staticmethod
479
+ def _format_test_sites_with_examples(
480
+ test_sites_with_examples, code_sites_with_examples, indent
481
+ ):
482
+ lines = []
483
+ if code_sites_with_examples:
484
+ lines.append("") # Blank line between sections
485
+ # Group test sites by caller
486
+ grouped_test = CallSiteFormatter.group_by_caller(test_sites_with_examples)
487
+ test_count = sum(len(site["lines"]) for site in grouped_test)
488
+ lines.append(f"{indent}Test ({test_count}):")
489
+ lines.extend(ModuleFormatter._format_grouped_test_sites(grouped_test, indent))
490
+ return lines
491
+
492
+ @staticmethod
493
+ def _format_code_sites_with_examples(code_sites_with_examples, indent):
494
+ lines = []
495
+ # Group code sites by caller
496
+ grouped_code = CallSiteFormatter.group_by_caller(code_sites_with_examples)
497
+ code_count = sum(len(site["lines"]) for site in grouped_code)
498
+ lines.append(f"{indent}Code ({code_count}):")
499
+ lines.extend(ModuleFormatter._format_grouped_code_sites(grouped_code, indent))
500
+ return lines
501
+
502
+ @staticmethod
503
+ def _format_call_sites_without_examples(call_sites, indent):
504
+ lines = []
505
+ # Separate into code and test call sites
506
+ code_sites = [s for s in call_sites if "test" not in s["file"].lower()]
507
+ test_sites = [s for s in call_sites if "test" in s["file"].lower()]
508
+
509
+ call_count = len(call_sites)
510
+ lines.append("")
511
+ lines.append(f"{indent}Called {call_count} times:")
512
+ lines.append("")
513
+
514
+ if code_sites:
515
+ lines.extend(ModuleFormatter._format_code_sites_without_examples(code_sites, indent))
516
+
517
+ if test_sites:
518
+ lines.extend(
519
+ ModuleFormatter._format_test_sites_without_examples(test_sites, code_sites, indent)
520
+ )
521
+ lines.append("")
522
+ return lines
523
+
524
+ @staticmethod
525
+ def _format_call_sites_with_examples(call_sites, call_sites_with_examples, indent):
526
+ lines = []
527
+ # Separate into code and test call sites WITH examples
528
+ code_sites_with_examples = [
529
+ s for s in call_sites_with_examples if "test" not in s["file"].lower()
530
+ ]
531
+ test_sites_with_examples = [
532
+ s for s in call_sites_with_examples if "test" in s["file"].lower()
533
+ ]
534
+
535
+ lines.append(f"{indent}Usage Examples:")
536
+
537
+ if code_sites_with_examples:
538
+ lines.extend(
539
+ ModuleFormatter._format_code_sites_with_examples(code_sites_with_examples, indent)
540
+ )
541
+
542
+ if test_sites_with_examples:
543
+ lines.extend(
544
+ ModuleFormatter._format_test_sites_with_examples(
545
+ test_sites_with_examples, code_sites_with_examples, indent
546
+ )
547
+ )
548
+
549
+ lines.extend(
550
+ ModuleFormatter._format_remaining_call_sites(
551
+ call_sites, call_sites_with_examples, indent
552
+ )
553
+ )
554
+ return lines
555
+
556
+ @staticmethod
557
+ def _format_call_sites(call_sites, call_sites_with_examples, indent):
558
+ lines = []
559
+ # Check if we have usage examples (code lines)
560
+ has_examples = len(call_sites_with_examples) > 0
561
+
562
+ if has_examples:
563
+ lines.extend(
564
+ ModuleFormatter._format_call_sites_with_examples(
565
+ call_sites, call_sites_with_examples, indent
566
+ )
567
+ )
568
+ else:
569
+ lines.extend(ModuleFormatter._format_call_sites_without_examples(call_sites, indent))
570
+ return lines
571
+
572
+ @staticmethod
573
+ def format_function_results_markdown(
574
+ function_name: str,
575
+ results: list[dict[str, Any]],
576
+ staleness_info: dict | None = None,
577
+ show_relationships: bool = True,
578
+ ) -> str:
579
+ """
580
+ Format function search results as Markdown.
581
+
582
+ Args:
583
+ function_name: The searched function name
584
+ results: List of function matches with module context
585
+ staleness_info: Optional staleness info (is_stale, age_str)
586
+ show_relationships: Whether to show relationship information (what this calls / what calls this)
587
+
588
+ Returns:
589
+ Formatted Markdown string
590
+ """
591
+ if not results:
592
+ # Extract just the function name without module/arity for suggestions
593
+ func_only = function_name.split(".")[-1].split("/")[0]
594
+
595
+ # Build error message
596
+ error_parts = []
597
+
598
+ # Add staleness warning if applicable
599
+ if staleness_info and staleness_info.get("is_stale"):
600
+ error_parts.append(
601
+ f"⚠️ Index may be stale (index is {staleness_info['age_str']} old, files have been modified)\n"
602
+ f" Please ask the user to run: cicada index\n"
603
+ )
604
+
605
+ error_parts.append(
606
+ f"""❌ Function Not Found
607
+
608
+ **Query:** `{function_name}`
609
+
610
+ ## Try:
611
+
612
+ • Search without arity: `{func_only}` (if you used /{'{arity}'})
613
+ • Search without module: `{func_only}` (searches all modules)
614
+ • Wildcard search: `*{func_only}*` or `{func_only}*`
615
+ • Semantic search: search_by_features(['{func_only.lower()}'])
616
+ • Check spelling (function names are case-sensitive)
617
+
618
+ 💡 Tip: If you're exploring code, try search_by_features first to discover functions by what they do.
619
+
620
+ ## Was this function recently removed?
621
+
622
+ 💭 If this function was deleted:
623
+ • Check recent PRs: get_file_pr_history("<file_path>")
624
+ • Search git history for the function name
625
+ • Find what replaced it: search_by_features(['<concept>'])
626
+ """
627
+ )
628
+
629
+ return "\n".join(error_parts)
630
+
631
+ # Group results by (module, name, arity) to consolidate function clauses
632
+ grouped_results = {}
633
+ for result in results:
634
+ key = (
635
+ result["module"],
636
+ result["function"]["name"],
637
+ result["function"]["arity"],
638
+ )
639
+ if key not in grouped_results:
640
+ grouped_results[key] = result
641
+ # If there are multiple clauses, we just keep the first one for display
642
+ # (they all have the same module/name/arity/doc/examples)
643
+
644
+ # Convert back to list
645
+ consolidated_results = list(grouped_results.values())
646
+
647
+ # Add staleness warning at the top if applicable
648
+ if staleness_info and staleness_info.get("is_stale"):
649
+ lines = [
650
+ f"⚠️ Index may be stale (index is {staleness_info['age_str']} old, files have been modified)",
651
+ " Please ask the user to run: cicada index",
652
+ "",
653
+ " 💭 Recent changes might be in merged PRs - use get_file_pr_history() for specific files",
654
+ "",
655
+ ]
656
+ else:
657
+ lines = []
658
+
659
+ # For single results (e.g., MFA search), use simpler header
660
+ if len(consolidated_results) == 1:
661
+ lines.append("---")
662
+ else:
663
+ lines.extend(
664
+ [
665
+ f"Functions matching {function_name}",
666
+ "",
667
+ f"Found {len(consolidated_results)} match(es):",
668
+ ]
669
+ )
670
+
671
+ for result in consolidated_results:
672
+ module_name = result["module"]
673
+ func = result["function"]
674
+ file_path = result["file"]
675
+ pr_info = result.get("pr_info")
676
+
677
+ # No indentation for single results
678
+ indent = ""
679
+
680
+ # Add signature first (right after file path)
681
+ sig = SignatureBuilder.build(func)
682
+
683
+ # Skip the section header for single results
684
+ if len(consolidated_results) == 1:
685
+ lines.extend(
686
+ [
687
+ f"{file_path}:{func['line']}",
688
+ f"{module_name}.{func['name']}/{func['arity']}",
689
+ f"Type: {sig}",
690
+ ]
691
+ )
692
+
693
+ # Add PR context for single results
694
+ lines.extend(ModuleFormatter._format_pr_context(pr_info, file_path, func["name"]))
695
+ else:
696
+ lines.extend(
697
+ [
698
+ "",
699
+ "---",
700
+ "",
701
+ f"{module_name}.{func['name']}/{func['arity']}",
702
+ ]
703
+ )
704
+ lines.append(f"{file_path}:{func['line']} • {func['type']}")
705
+ lines.extend(["", "Signature:", "", f"{sig}"])
706
+
707
+ # Add PR context for multi-result format
708
+ pr_lines = ModuleFormatter._format_pr_context(pr_info, file_path)
709
+ # For multi-result, adjust comment count message to be more concise
710
+ if pr_info and pr_info.get("comment_count", 0) > 0 and len(pr_lines) > 2:
711
+ # Replace the last line with shorter version for multi-result display
712
+ pr_lines[-1] = f"💬 {pr_info['comment_count']} review comment(s) available"
713
+ lines.extend(pr_lines)
714
+
715
+ # Add documentation if present
716
+ if func.get("doc"):
717
+ if len(consolidated_results) == 1:
718
+ lines.extend(['Documentation: """', func["doc"], '"""'])
719
+ else:
720
+ lines.extend(["", "Documentation:", "", func["doc"]])
721
+
722
+ # Add examples if present
723
+ if func.get("examples"):
724
+ if len(consolidated_results) == 1:
725
+ lines.extend(["", f"{indent}Examples:", "", f"{indent}{func['examples']}"])
726
+ else:
727
+ lines.extend(["", "Examples:", "", func["examples"]])
728
+
729
+ # Add guards if present (on separate line for idiomatic Elixir style)
730
+ if func.get("guards"):
731
+ guards_str = ", ".join(func["guards"])
732
+ if len(results) == 1:
733
+ lines.append(f" Guards: when {guards_str}")
734
+ else:
735
+ lines.extend(["", f"**Guards:** `when {guards_str}`"])
736
+
737
+ # Add relationship information if enabled
738
+ if show_relationships:
739
+ dependencies = result.get("dependencies", [])
740
+ if dependencies:
741
+ lines.append("")
742
+ lines.append(f"{indent}📞 Calls these functions:")
743
+ for dep in dependencies[:5]: # Limit to 5 for brevity
744
+ dep_module = dep.get("module", "?")
745
+ dep_func = dep.get("function", "?")
746
+ dep_arity = dep.get("arity", "?")
747
+ dep_line = dep.get("line", "?")
748
+ lines.append(
749
+ f"{indent} • {dep_module}.{dep_func}/{dep_arity} (line {dep_line})"
750
+ )
751
+ if len(dependencies) > 5:
752
+ lines.append(f"{indent} ... and {len(dependencies) - 5} more")
753
+
754
+ # Add call sites
755
+ call_sites = result.get("call_sites", [])
756
+ call_sites_with_examples = result.get("call_sites_with_examples", [])
757
+
758
+ if call_sites:
759
+ lines.extend(
760
+ ModuleFormatter._format_call_sites(call_sites, call_sites_with_examples, indent)
761
+ )
762
+ else:
763
+ lines.append(f"{indent}*No call sites found*")
764
+ lines.append("")
765
+ lines.append(f"{indent}💭 Possible reasons:")
766
+ lines.append(f"{indent} • Dead code → Use find_dead_code() to verify")
767
+ lines.append(f"{indent} • Public API → Not called internally but used by clients")
768
+ lines.append(f"{indent} • New code → Check when added with get_commit_history()")
769
+
770
+ # Smart suggestion based on available data
771
+ if pr_info:
772
+ if pr_info.get("comment_count", 0) > 0:
773
+ lines.append(
774
+ f"{indent} • {pr_info['comment_count']} PR review comments exist → get_file_pr_history(\"{file_path}\")"
775
+ )
776
+ else:
777
+ lines.append(
778
+ f"{indent} • Added in PR #{pr_info['number']} → get_file_pr_history(\"{file_path}\")"
779
+ )
780
+
781
+ # Add closing separator for single results
782
+ if len(consolidated_results) == 1:
783
+ lines.append("---")
784
+
785
+ return "\n".join(lines)
786
+
787
+ @staticmethod
788
+ def format_function_results_json(function_name: str, results: list[dict[str, Any]]) -> str:
789
+ """
790
+ Format function search results as JSON.
791
+
792
+ Args:
793
+ function_name: The searched function name
794
+ results: List of function matches with module context
795
+
796
+ Returns:
797
+ Formatted JSON string
798
+ """
799
+ if not results:
800
+ error_result = {
801
+ "error": "Function not found",
802
+ "query": function_name,
803
+ "hint": "Verify the function name spelling or try without arity",
804
+ }
805
+ return json.dumps(error_result, indent=2)
806
+
807
+ formatted_results = []
808
+ for result in results:
809
+ func_entry = {
810
+ "module": result["module"],
811
+ "moduledoc": result.get("moduledoc"),
812
+ "function": result["function"]["name"],
813
+ "arity": result["function"]["arity"],
814
+ "full_name": f"{result['module']}.{result['function']['name']}/{result['function']['arity']}",
815
+ "signature": SignatureBuilder.build(result["function"]),
816
+ "location": f"{result['file']}:{result['function']['line']}",
817
+ "type": result["function"]["type"],
818
+ "doc": result["function"].get("doc"),
819
+ "call_sites": result.get("call_sites", []),
820
+ }
821
+
822
+ # Add examples if present
823
+ if result["function"].get("examples"):
824
+ func_entry["examples"] = result["function"]["examples"]
825
+
826
+ # Add return_type if present
827
+ if result["function"].get("return_type"):
828
+ func_entry["return_type"] = result["function"]["return_type"]
829
+
830
+ # Add guards if present
831
+ if result["function"].get("guards"):
832
+ func_entry["guards"] = result["function"]["guards"]
833
+
834
+ formatted_results.append(func_entry)
835
+
836
+ output = {
837
+ "query": function_name,
838
+ "total_matches": len(results),
839
+ "results": formatted_results,
840
+ }
841
+ return json.dumps(output, indent=2)
842
+
843
+ @staticmethod
844
+ def format_module_usage_markdown(module_name: str, usage_results: dict[str, Any]) -> str:
845
+ """
846
+ Format module usage results as Markdown.
847
+
848
+ Args:
849
+ module_name: The module being searched for
850
+ usage_results: Dictionary with usage category keys
851
+
852
+ Returns:
853
+ Formatted Markdown string
854
+ """
855
+ aliases = usage_results.get("aliases", [])
856
+ imports = usage_results.get("imports", [])
857
+ requires = usage_results.get("requires", [])
858
+ uses = usage_results.get("uses", [])
859
+ value_mentions = usage_results.get("value_mentions", [])
860
+ function_calls = usage_results.get("function_calls", [])
861
+
862
+ lines = [f"# Usage of `{module_name}`", ""]
863
+
864
+ # Show aliases section
865
+ if aliases:
866
+ lines.extend([f"## Aliases ({len(aliases)} module(s)):", ""])
867
+ for imp in aliases:
868
+ alias_info = (
869
+ f" as `{imp['alias_name']}`"
870
+ if imp["alias_name"] != module_name.split(".")[-1]
871
+ else ""
872
+ )
873
+ lines.append(f"- `{imp['importing_module']}` {alias_info} — `{imp['file']}`")
874
+ lines.append("")
875
+
876
+ # Show imports section
877
+ if imports:
878
+ lines.extend([f"## Imports ({len(imports)} module(s)):", ""])
879
+ for imp in imports:
880
+ lines.append(f"- `{imp['importing_module']}` — `{imp['file']}`")
881
+ lines.append("")
882
+
883
+ # Show requires section
884
+ if requires:
885
+ lines.extend([f"## Requires ({len(requires)} module(s)):", ""])
886
+ for req in requires:
887
+ lines.append(f"- `{req['importing_module']}` — `{req['file']}`")
888
+ lines.append("")
889
+
890
+ # Show uses section
891
+ if uses:
892
+ lines.extend([f"## Uses ({len(uses)} module(s)):", ""])
893
+ for use in uses:
894
+ lines.append(f"- `{use['importing_module']}` — `{use['file']}`")
895
+ lines.append("")
896
+
897
+ # Show value mentions section
898
+ if value_mentions:
899
+ lines.extend([f"## As Value ({len(value_mentions)} module(s)):", ""])
900
+ for vm in value_mentions:
901
+ lines.append(f"- `{vm['importing_module']}` — `{vm['file']}`")
902
+ lines.append("")
903
+
904
+ # Show function calls section
905
+ if function_calls:
906
+ # Count total calls
907
+ total_calls = sum(len(fc["calls"]) for fc in function_calls)
908
+ lines.extend(
909
+ [
910
+ f"## Function Calls ({len(function_calls)} module(s), {total_calls} function(s)):",
911
+ "",
912
+ ]
913
+ )
914
+
915
+ for fc in function_calls:
916
+ lines.append(f"### `{fc['calling_module']}`")
917
+ lines.append(f" `{fc['file']}`")
918
+ lines.append("")
919
+
920
+ for call in fc["calls"]:
921
+ alias_info = f" (via `{call['alias_used']}`)" if call["alias_used"] else ""
922
+ # Show unique line numbers for this function
923
+ line_list = ", ".join(f":{line}" for line in sorted(call["lines"]))
924
+ lines.append(
925
+ f" - `{call['function']}/{call['arity']}`{alias_info} — {line_list}"
926
+ )
927
+
928
+ lines.append("")
929
+
930
+ # Show message if no usage found at all
931
+ if not any([aliases, imports, requires, uses, value_mentions, function_calls]):
932
+ lines.extend(["*No usage found for this module*"])
933
+
934
+ return "\n".join(lines)
935
+
936
+ @staticmethod
937
+ def format_module_usage_json(module_name: str, usage_results: dict[str, Any]) -> str:
938
+ """
939
+ Format module usage results as JSON.
940
+
941
+ Args:
942
+ module_name: The module being searched for
943
+ usage_results: Dictionary with usage category keys
944
+
945
+ Returns:
946
+ Formatted JSON string
947
+ """
948
+ output = {
949
+ "module": module_name,
950
+ "aliases": usage_results.get("aliases", []),
951
+ "imports": usage_results.get("imports", []),
952
+ "requires": usage_results.get("requires", []),
953
+ "uses": usage_results.get("uses", []),
954
+ "value_mentions": usage_results.get("value_mentions", []),
955
+ "function_calls": usage_results.get("function_calls", []),
956
+ "summary": {
957
+ "aliased_by": len(usage_results.get("aliases", [])),
958
+ "imported_by": len(usage_results.get("imports", [])),
959
+ "required_by": len(usage_results.get("requires", [])),
960
+ "used_by": len(usage_results.get("uses", [])),
961
+ "mentioned_as_value_by": len(usage_results.get("value_mentions", [])),
962
+ "called_by": len(usage_results.get("function_calls", [])),
963
+ },
964
+ }
965
+ return json.dumps(output, indent=2)
966
+
967
+ @staticmethod
968
+ def format_keyword_search_results_markdown(
969
+ _keywords: list[str], results: list[dict[str, Any]], show_scores: bool = True
970
+ ) -> str:
971
+ """
972
+ Format keyword search results as Markdown.
973
+
974
+ Args:
975
+ keywords: The search keywords
976
+ results: List of search result dictionaries
977
+ show_scores: Whether to show relevance scores. Defaults to True.
978
+
979
+ Returns:
980
+ Formatted Markdown string
981
+ """
982
+ lines: list[str] = []
983
+
984
+ for _, result in enumerate(results, 1):
985
+ result_type = result["type"]
986
+ name = result["name"]
987
+ file_path = result["file"]
988
+ line = result["line"]
989
+ score = result["score"]
990
+ _confidence = result["confidence"]
991
+ matched_keywords = result["matched_keywords"]
992
+
993
+ # Compact format with type indication
994
+ type_label = "Module" if result_type == "module" else "Function"
995
+ lines.append(f"{type_label}: {name}")
996
+ if show_scores:
997
+ lines.append(f"Score: {score:.4f}")
998
+ lines.append(f"Path: {file_path}:{line}")
999
+ lines.append(f"Matched: {', '.join(matched_keywords) if matched_keywords else 'None'}")
1000
+
1001
+ # First line of documentation only
1002
+ doc = result.get("doc")
1003
+ if doc:
1004
+ doc_lines = doc.strip().split("\n")
1005
+ first_line = doc_lines[0] if doc_lines else ""
1006
+ lines.append(f'Doc: "{first_line}"')
1007
+
1008
+ lines.append("---") # Separator between results
1009
+
1010
+ return "\n".join(lines)
1011
+
1012
+
1013
+ class JSONFormatter:
1014
+ """Formats JSON data with customizable options."""
1015
+
1016
+ def __init__(self, indent: int | None = 2, sort_keys: bool = False):
1017
+ """
1018
+ Initialize the formatter.
1019
+
1020
+ Args:
1021
+ indent: Number of spaces for indentation (default: 2)
1022
+ sort_keys: Whether to sort dictionary keys alphabetically (default: False)
1023
+ """
1024
+ self.indent = indent
1025
+ self.sort_keys = sort_keys
1026
+
1027
+ def format_string(self, json_string: str) -> str:
1028
+ """
1029
+ Format a JSON string.
1030
+
1031
+ Args:
1032
+ json_string: Raw JSON string to format
1033
+
1034
+ Returns:
1035
+ Formatted JSON string
1036
+
1037
+ Raises:
1038
+ ValueError: If the input is not valid JSON
1039
+ """
1040
+ try:
1041
+ data = json.loads(json_string)
1042
+ return json.dumps(data, indent=self.indent, sort_keys=self.sort_keys)
1043
+ except json.JSONDecodeError as e:
1044
+ raise ValueError(f"Invalid JSON: {e}") from e
1045
+
1046
+ def format_file(self, input_path: Path, output_path: Path | None = None) -> str:
1047
+ """
1048
+ Format a JSON file.
1049
+
1050
+ Args:
1051
+ input_path: Path to the input JSON file
1052
+ output_path: Optional path to write formatted output (default: stdout)
1053
+
1054
+ Returns:
1055
+ Formatted JSON string
1056
+
1057
+ Raises:
1058
+ FileNotFoundError: If the input file doesn't exist
1059
+ ValueError: If the input file contains invalid JSON
1060
+ """
1061
+ if not input_path.exists():
1062
+ raise FileNotFoundError(f"Input file not found: {input_path}")
1063
+
1064
+ # Read the input file
1065
+ with open(input_path) as f:
1066
+ json_string = f.read()
1067
+
1068
+ # Format the JSON
1069
+ formatted = self.format_string(json_string)
1070
+
1071
+ # Write to output file if specified, otherwise return for stdout
1072
+ if output_path:
1073
+ with open(output_path, "w") as f:
1074
+ _ = f.write(formatted)
1075
+ _ = f.write("\n") # Add trailing newline
1076
+ print(f"Formatted JSON written to: {output_path}", file=sys.stderr)
1077
+
1078
+ return formatted
1079
+
1080
+ def format_dict(self, data: dict) -> str:
1081
+ """
1082
+ Format a Python dictionary as JSON.
1083
+
1084
+ Args:
1085
+ data: Dictionary to format
1086
+
1087
+ Returns:
1088
+ Formatted JSON string
1089
+ """
1090
+ return json.dumps(data, indent=self.indent, sort_keys=self.sort_keys)
1091
+
1092
+
1093
+ def main():
1094
+ """Main entry point for the formatter CLI."""
1095
+ parser = argparse.ArgumentParser(
1096
+ description="Pretty print JSON files with customizable formatting"
1097
+ )
1098
+ _ = parser.add_argument("input", type=Path, help="Input JSON file to format")
1099
+ _ = parser.add_argument(
1100
+ "-o", "--output", type=Path, help="Output file (default: print to stdout)"
1101
+ )
1102
+ _ = parser.add_argument(
1103
+ "-i",
1104
+ "--indent",
1105
+ type=int,
1106
+ default=2,
1107
+ help="Number of spaces for indentation (default: 2)",
1108
+ )
1109
+ _ = parser.add_argument(
1110
+ "-s",
1111
+ "--sort-keys",
1112
+ action="store_true",
1113
+ help="Sort dictionary keys alphabetically",
1114
+ )
1115
+ _ = parser.add_argument(
1116
+ "--compact", action="store_true", help="Use compact formatting (no indentation)"
1117
+ )
1118
+
1119
+ args = parser.parse_args()
1120
+
1121
+ # Create formatter with specified options
1122
+ indent = None if args.compact else args.indent
1123
+ formatter = JSONFormatter(indent=indent, sort_keys=args.sort_keys)
1124
+
1125
+ try:
1126
+ # Format the file
1127
+ formatted = formatter.format_file(args.input, args.output)
1128
+
1129
+ # Print to stdout if no output file specified
1130
+ if not args.output:
1131
+ print(formatted)
1132
+
1133
+ except FileNotFoundError as e:
1134
+ print(f"Error: {e}", file=sys.stderr)
1135
+ sys.exit(1)
1136
+ except ValueError as e:
1137
+ print(f"Error: {e}", file=sys.stderr)
1138
+ sys.exit(1)
1139
+ except Exception as e:
1140
+ print(f"Unexpected error: {e}", file=sys.stderr)
1141
+ sys.exit(1)
1142
+
1143
+
1144
+ if __name__ == "__main__":
1145
+ main()