cicada-mcp 0.1.4__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.

Potentially problematic release.


This version of cicada-mcp might be problematic. Click here for more details.

Files changed (48) hide show
  1. cicada/__init__.py +30 -0
  2. cicada/clean.py +297 -0
  3. cicada/command_logger.py +293 -0
  4. cicada/dead_code_analyzer.py +282 -0
  5. cicada/extractors/__init__.py +36 -0
  6. cicada/extractors/base.py +66 -0
  7. cicada/extractors/call.py +176 -0
  8. cicada/extractors/dependency.py +361 -0
  9. cicada/extractors/doc.py +179 -0
  10. cicada/extractors/function.py +246 -0
  11. cicada/extractors/module.py +123 -0
  12. cicada/extractors/spec.py +151 -0
  13. cicada/find_dead_code.py +270 -0
  14. cicada/formatter.py +918 -0
  15. cicada/git_helper.py +646 -0
  16. cicada/indexer.py +629 -0
  17. cicada/install.py +724 -0
  18. cicada/keyword_extractor.py +364 -0
  19. cicada/keyword_search.py +553 -0
  20. cicada/lightweight_keyword_extractor.py +298 -0
  21. cicada/mcp_server.py +1559 -0
  22. cicada/mcp_tools.py +291 -0
  23. cicada/parser.py +124 -0
  24. cicada/pr_finder.py +435 -0
  25. cicada/pr_indexer/__init__.py +20 -0
  26. cicada/pr_indexer/cli.py +62 -0
  27. cicada/pr_indexer/github_api_client.py +431 -0
  28. cicada/pr_indexer/indexer.py +297 -0
  29. cicada/pr_indexer/line_mapper.py +209 -0
  30. cicada/pr_indexer/pr_index_builder.py +253 -0
  31. cicada/setup.py +339 -0
  32. cicada/utils/__init__.py +52 -0
  33. cicada/utils/call_site_formatter.py +95 -0
  34. cicada/utils/function_grouper.py +57 -0
  35. cicada/utils/hash_utils.py +173 -0
  36. cicada/utils/index_utils.py +290 -0
  37. cicada/utils/path_utils.py +240 -0
  38. cicada/utils/signature_builder.py +106 -0
  39. cicada/utils/storage.py +111 -0
  40. cicada/utils/subprocess_runner.py +182 -0
  41. cicada/utils/text_utils.py +90 -0
  42. cicada/version_check.py +116 -0
  43. cicada_mcp-0.1.4.dist-info/METADATA +619 -0
  44. cicada_mcp-0.1.4.dist-info/RECORD +48 -0
  45. cicada_mcp-0.1.4.dist-info/WHEEL +5 -0
  46. cicada_mcp-0.1.4.dist-info/entry_points.txt +8 -0
  47. cicada_mcp-0.1.4.dist-info/licenses/LICENSE +21 -0
  48. cicada_mcp-0.1.4.dist-info/top_level.txt +1 -0
cicada/formatter.py ADDED
@@ -0,0 +1,918 @@
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 json
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import Optional, Dict, Any
13
+ import argparse
14
+
15
+ from cicada.utils import FunctionGrouper, CallSiteFormatter, SignatureBuilder
16
+
17
+
18
+ class ModuleFormatter:
19
+ """Formats Cicada module data in various output formats."""
20
+
21
+ @staticmethod
22
+ def _group_call_sites_by_caller(call_sites):
23
+ return CallSiteFormatter.group_by_caller(call_sites)
24
+
25
+ @staticmethod
26
+ def format_module_markdown(
27
+ module_name: str, data: Dict[str, Any], private_functions: str = "exclude"
28
+ ) -> str:
29
+ """
30
+ Format module data as Markdown.
31
+
32
+ Args:
33
+ module_name: The name of the module
34
+ data: The module data dictionary from the index
35
+ private_functions: How to handle private functions: 'exclude' (hide), 'include' (show all), or 'only' (show only private)
36
+
37
+ Returns:
38
+ Formatted Markdown string
39
+ """
40
+ # Group functions by type (def = public, defp = private)
41
+ public_funcs = [f for f in data["functions"] if f["type"] == "def"]
42
+ private_funcs = [f for f in data["functions"] if f["type"] == "defp"]
43
+
44
+ # Group by name/arity to deduplicate function clauses
45
+ public_grouped = FunctionGrouper.group_by_name_arity(public_funcs)
46
+ private_grouped = FunctionGrouper.group_by_name_arity(private_funcs)
47
+
48
+ # Count unique functions, not function clauses
49
+ public_count = len(public_grouped)
50
+ private_count = len(private_grouped)
51
+
52
+ # Build the markdown output - compact format
53
+ lines = [
54
+ module_name,
55
+ "",
56
+ f"{data['file']}:{data['line']} • {public_count} public • {private_count} private",
57
+ ]
58
+
59
+ # Add moduledoc if present (first paragraph only for brevity)
60
+ if data.get("moduledoc"):
61
+ doc = data["moduledoc"].strip()
62
+ # Get first paragraph (up to double newline or first 200 chars)
63
+ first_para = doc.split("\n\n")[0].strip()
64
+ if len(first_para) > 200:
65
+ first_para = first_para[:200] + "..."
66
+ lines.extend(["", first_para])
67
+
68
+ # Show public functions (unless private_functions == "only")
69
+ if public_grouped and private_functions != "only":
70
+ lines.extend(["", "Public:", ""])
71
+ # Sort by line number instead of function name
72
+ for (_, _), clauses in sorted(
73
+ public_grouped.items(), key=lambda x: x[1][0]["line"]
74
+ ):
75
+ # Use the first clause for display (they all have same name/arity)
76
+ func = clauses[0]
77
+ func_sig = SignatureBuilder.build(func)
78
+ lines.append(f"{func['line']:>5}: {func_sig}")
79
+
80
+ # Show private functions (if private_functions == "include" or "only")
81
+ if private_grouped and private_functions in ["include", "only"]:
82
+ lines.extend(["", "Private:", ""])
83
+ # Sort by line number instead of function name
84
+ for (_, _), clauses in sorted(
85
+ private_grouped.items(), key=lambda x: x[1][0]["line"]
86
+ ):
87
+ # Use the first clause for display (they all have same name/arity)
88
+ func = clauses[0]
89
+ func_sig = SignatureBuilder.build(func)
90
+ lines.append(f"{func['line']:>5}: {func_sig}")
91
+
92
+ # Check if there are no functions to display based on the filter
93
+ has_functions_to_show = (private_functions != "only" and public_grouped) or (
94
+ private_functions in ["include", "only"] and private_grouped
95
+ )
96
+
97
+ if not has_functions_to_show:
98
+ if private_functions == "only" and not private_grouped:
99
+ lines.extend(["", "*No private functions found*"])
100
+ elif not data["functions"]:
101
+ lines.extend(["", "*No functions found*"])
102
+
103
+ return "\n".join(lines)
104
+
105
+ @staticmethod
106
+ def format_module_json(
107
+ module_name: str, data: Dict[str, Any], private_functions: str = "exclude"
108
+ ) -> str:
109
+ """
110
+ Format module data as JSON.
111
+
112
+ Args:
113
+ module_name: The name of the module
114
+ data: The module data dictionary from the index
115
+ private_functions: How to handle private functions: 'exclude' (hide), 'include' (show all), or 'only' (show only private)
116
+
117
+ Returns:
118
+ Formatted JSON string
119
+ """
120
+ # Filter functions based on private_functions parameter
121
+ if private_functions == "exclude":
122
+ # Only public functions
123
+ filtered_funcs = [f for f in data["functions"] if f["type"] == "def"]
124
+ elif private_functions == "only":
125
+ # Only private functions
126
+ filtered_funcs = [f for f in data["functions"] if f["type"] == "defp"]
127
+ else: # "include"
128
+ # All functions
129
+ filtered_funcs = data["functions"]
130
+
131
+ # Group functions by name/arity to deduplicate function clauses
132
+ grouped = FunctionGrouper.group_by_name_arity(filtered_funcs)
133
+
134
+ # Compact function format - one entry per unique name/arity
135
+ functions = [
136
+ {
137
+ "signature": SignatureBuilder.build(clauses[0]),
138
+ "line": clauses[0]["line"],
139
+ "type": clauses[0]["type"],
140
+ }
141
+ for (_, _), clauses in sorted(grouped.items())
142
+ ]
143
+
144
+ result = {
145
+ "module": module_name,
146
+ "location": f"{data['file']}:{data['line']}",
147
+ "moduledoc": data.get("moduledoc"),
148
+ "counts": {
149
+ "public": data["public_functions"],
150
+ "private": data["private_functions"],
151
+ },
152
+ "functions": functions,
153
+ }
154
+ return json.dumps(result, indent=2)
155
+
156
+ @staticmethod
157
+ def format_error_markdown(module_name: str, total_modules: int) -> str:
158
+ """
159
+ Format error message as Markdown.
160
+
161
+ Args:
162
+ module_name: The queried module name
163
+ total_modules: Total number of modules in the index
164
+
165
+ Returns:
166
+ Formatted Markdown error message
167
+ """
168
+ return f"""# Module Not Found
169
+
170
+ **Query:** `{module_name}`
171
+
172
+ The module `{module_name}` was not found in the index.
173
+
174
+ ## Suggestions
175
+
176
+ - Verify the exact module name as it appears in the code
177
+ - Check that the module is part of the indexed codebase
178
+ - Total modules available in index: **{total_modules}**
179
+
180
+ ## Note
181
+
182
+ Module names are case-sensitive and must match exactly (e.g., `MyApp.User`, not `myapp.user`).
183
+ """
184
+
185
+ @staticmethod
186
+ def format_error_json(module_name: str, total_modules: int) -> str:
187
+ """
188
+ Format error message as JSON.
189
+
190
+ Args:
191
+ module_name: The queried module name
192
+ total_modules: Total number of modules in the index
193
+
194
+ Returns:
195
+ Formatted JSON error message
196
+ """
197
+ error_result = {
198
+ "error": "Module not found",
199
+ "query": module_name,
200
+ "hint": "Use the exact module name as it appears in the code",
201
+ "total_modules_available": total_modules,
202
+ }
203
+ return json.dumps(error_result, indent=2)
204
+
205
+ @staticmethod
206
+ def format_function_results_markdown(
207
+ function_name: str, results: list[Dict[str, Any]]
208
+ ) -> str:
209
+ """
210
+ Format function search results as Markdown.
211
+
212
+ Args:
213
+ function_name: The searched function name
214
+ results: List of function matches with module context
215
+
216
+ Returns:
217
+ Formatted Markdown string
218
+ """
219
+ if not results:
220
+ return f"""# Function Not Found
221
+
222
+ **Query:** `{function_name}`
223
+
224
+ No functions matching `{function_name}` were found in the index.
225
+
226
+ ## Suggestions
227
+
228
+ - Verify the function name spelling
229
+ - Try searching without arity (e.g., 'create_user' instead of 'create_user/2')
230
+ - Check that the function is part of the indexed codebase
231
+ """
232
+
233
+ # Group results by (module, name, arity) to consolidate function clauses
234
+ grouped_results = {}
235
+ for result in results:
236
+ key = (
237
+ result["module"],
238
+ result["function"]["name"],
239
+ result["function"]["arity"],
240
+ )
241
+ if key not in grouped_results:
242
+ grouped_results[key] = result
243
+ # If there are multiple clauses, we just keep the first one for display
244
+ # (they all have the same module/name/arity/doc/examples)
245
+
246
+ # Convert back to list
247
+ consolidated_results = list(grouped_results.values())
248
+
249
+ # For single results (e.g., MFA search), use simpler header
250
+ if len(consolidated_results) == 1:
251
+ lines = [
252
+ f"{consolidated_results[0]['module']}.{consolidated_results[0]['function']['name']}/{consolidated_results[0]['function']['arity']}"
253
+ ]
254
+ else:
255
+ lines = [
256
+ f"Functions matching {function_name}",
257
+ f"",
258
+ f"Found {len(consolidated_results)} match(es):",
259
+ ]
260
+
261
+ for result in consolidated_results:
262
+ module_name = result["module"]
263
+ func = result["function"]
264
+ file_path = result["file"]
265
+
266
+ # No indentation for single results
267
+ indent = ""
268
+
269
+ # Add signature first (right after file path)
270
+ sig = SignatureBuilder.build(func)
271
+
272
+ # Skip the section header for single results
273
+ if len(consolidated_results) == 1:
274
+ lines.extend(["", f"{file_path}:{func['line']}", f"{indent}{sig}"])
275
+ else:
276
+ lines.extend(
277
+ [
278
+ "",
279
+ "---",
280
+ "",
281
+ f"{module_name}.{func['name']}/{func['arity']}",
282
+ ]
283
+ )
284
+ lines.append(f"{file_path}:{func['line']} • {func['type']}")
285
+ lines.extend(["", "Signature:", "", f"{sig}"])
286
+
287
+ # Add documentation if present
288
+ if func.get("doc"):
289
+ if len(consolidated_results) == 1:
290
+ lines.extend(
291
+ ["", f"{indent}Documentation:", "", f"{indent}{func['doc']}"]
292
+ )
293
+ else:
294
+ lines.extend(["", "Documentation:", "", func["doc"]])
295
+
296
+ # Add examples if present
297
+ if func.get("examples"):
298
+ if len(consolidated_results) == 1:
299
+ lines.extend(
300
+ ["", f"{indent}Examples:", "", f"{indent}{func['examples']}"]
301
+ )
302
+ else:
303
+ lines.extend(["", "Examples:", "", func["examples"]])
304
+
305
+ # Add guards if present (on separate line for idiomatic Elixir style)
306
+ if func.get("guards"):
307
+ guards_str = ", ".join(func["guards"])
308
+ if len(results) == 1:
309
+ lines.append(f" Guards: when {guards_str}")
310
+ else:
311
+ lines.extend(["", f"**Guards:** `when {guards_str}`"])
312
+
313
+ # Add call sites
314
+ call_sites = result.get("call_sites", [])
315
+ call_sites_with_examples = result.get("call_sites_with_examples", [])
316
+
317
+ if call_sites:
318
+ # Check if we have usage examples (code lines)
319
+ has_examples = len(call_sites_with_examples) > 0
320
+
321
+ if has_examples:
322
+ # Separate into code and test call sites WITH examples
323
+ code_sites_with_examples = [
324
+ s
325
+ for s in call_sites_with_examples
326
+ if "test" not in s["file"].lower()
327
+ ]
328
+ test_sites_with_examples = [
329
+ s
330
+ for s in call_sites_with_examples
331
+ if "test" in s["file"].lower()
332
+ ]
333
+
334
+ lines.append(f"{indent}Usage Examples:")
335
+
336
+ if code_sites_with_examples:
337
+ # Group code sites by caller
338
+ grouped_code = CallSiteFormatter.group_by_caller(
339
+ code_sites_with_examples
340
+ )
341
+ code_count = sum(len(site["lines"]) for site in grouped_code)
342
+ lines.append(f"{indent}Code ({code_count}):")
343
+ for site in grouped_code:
344
+ # Format calling location with function if available
345
+ calling_func = site.get("calling_function")
346
+ if calling_func:
347
+ caller = f"{site['calling_module']}.{calling_func['name']}/{calling_func['arity']}"
348
+ else:
349
+ caller = site["calling_module"]
350
+
351
+ # Show consolidated line numbers only if multiple lines
352
+ if len(site["lines"]) > 1:
353
+ line_list = ", ".join(
354
+ f":{line}" for line in site["lines"]
355
+ )
356
+ lines.append(
357
+ f"{indent}- {caller} at {site['file']}{line_list}"
358
+ )
359
+ else:
360
+ lines.append(f"{indent}- {caller} at {site['file']}")
361
+
362
+ # Add the actual code lines if available
363
+ if site.get("code_lines"):
364
+ for code_entry in site["code_lines"]:
365
+ # Properly indent each line of the code block
366
+ code_lines = code_entry["code"].split("\n")
367
+ for code_line in code_lines:
368
+ lines.append(f"{indent} {code_line}")
369
+
370
+ if test_sites_with_examples:
371
+ if code_sites_with_examples:
372
+ lines.append("") # Blank line between sections
373
+ # Group test sites by caller
374
+ grouped_test = CallSiteFormatter.group_by_caller(
375
+ test_sites_with_examples
376
+ )
377
+ test_count = sum(len(site["lines"]) for site in grouped_test)
378
+ lines.append(f"{indent}Test ({test_count}):")
379
+ for site in grouped_test:
380
+ # Format calling location with function if available
381
+ calling_func = site.get("calling_function")
382
+ if calling_func:
383
+ caller = f"{site['calling_module']}.{calling_func['name']}/{calling_func['arity']}"
384
+ else:
385
+ caller = site["calling_module"]
386
+
387
+ # Show consolidated line numbers only if multiple lines
388
+ if len(site["lines"]) > 1:
389
+ line_list = ", ".join(
390
+ f":{line}" for line in site["lines"]
391
+ )
392
+ lines.append(
393
+ f"{indent}- {caller} at {site['file']}{line_list}"
394
+ )
395
+ else:
396
+ lines.append(f"{indent}- {caller} at {site['file']}")
397
+
398
+ # Add the actual code lines if available
399
+ if site.get("code_lines"):
400
+ for code_entry in site["code_lines"]:
401
+ # Properly indent each line of the code block
402
+ code_lines = code_entry["code"].split("\n")
403
+ for code_line in code_lines:
404
+ lines.append(f"{indent} {code_line}")
405
+
406
+ # Now show the remaining call sites (those without code examples)
407
+ # Create a set of call sites that were shown with examples
408
+ shown_call_lines = set()
409
+ for site in call_sites_with_examples:
410
+ shown_call_lines.add((site["file"], site["line"]))
411
+
412
+ # Filter to get call sites not yet shown
413
+ remaining_call_sites = [
414
+ site
415
+ for site in call_sites
416
+ if (site["file"], site["line"]) not in shown_call_lines
417
+ ]
418
+
419
+ if remaining_call_sites:
420
+ # Separate into code and test
421
+ remaining_code = [
422
+ s
423
+ for s in remaining_call_sites
424
+ if "test" not in s["file"].lower()
425
+ ]
426
+ remaining_test = [
427
+ s
428
+ for s in remaining_call_sites
429
+ if "test" in s["file"].lower()
430
+ ]
431
+
432
+ lines.append("")
433
+ lines.append(f"{indent}Other Call Sites:")
434
+
435
+ if remaining_code:
436
+ grouped_remaining_code = CallSiteFormatter.group_by_caller(
437
+ remaining_code
438
+ )
439
+ remaining_code_count = sum(
440
+ len(site["lines"]) for site in grouped_remaining_code
441
+ )
442
+ lines.append(f"{indent}Code ({remaining_code_count}):")
443
+ for site in grouped_remaining_code:
444
+ calling_func = site.get("calling_function")
445
+ if calling_func:
446
+ caller = f"{site['calling_module']}.{calling_func['name']}/{calling_func['arity']}"
447
+ else:
448
+ caller = site["calling_module"]
449
+
450
+ line_list = ", ".join(
451
+ f":{line}" for line in site["lines"]
452
+ )
453
+ lines.append(
454
+ f"{indent}- {caller} at {site['file']}{line_list}"
455
+ )
456
+
457
+ if remaining_test:
458
+ if remaining_code:
459
+ lines.append("")
460
+ grouped_remaining_test = CallSiteFormatter.group_by_caller(
461
+ remaining_test
462
+ )
463
+ remaining_test_count = sum(
464
+ len(site["lines"]) for site in grouped_remaining_test
465
+ )
466
+ lines.append(f"{indent}Test ({remaining_test_count}):")
467
+ for site in grouped_remaining_test:
468
+ calling_func = site.get("calling_function")
469
+ if calling_func:
470
+ caller = f"{site['calling_module']}.{calling_func['name']}/{calling_func['arity']}"
471
+ else:
472
+ caller = site["calling_module"]
473
+
474
+ line_list = ", ".join(
475
+ f":{line}" for line in site["lines"]
476
+ )
477
+ lines.append(
478
+ f"{indent}- {caller} at {site['file']}{line_list}"
479
+ )
480
+ else:
481
+ # Separate into code and test call sites
482
+ code_sites = [
483
+ s for s in call_sites if "test" not in s["file"].lower()
484
+ ]
485
+ test_sites = [s for s in call_sites if "test" in s["file"].lower()]
486
+
487
+ call_count = len(call_sites)
488
+ lines.append("")
489
+ lines.append(f"{indent}Called {call_count} times:")
490
+ lines.append("")
491
+
492
+ if code_sites:
493
+ # Group code sites by caller
494
+ grouped_code = CallSiteFormatter.group_by_caller(code_sites)
495
+ code_count = sum(len(site["lines"]) for site in grouped_code)
496
+ lines.append(f"{indent}Code ({code_count}):")
497
+ for site in grouped_code:
498
+ # Format calling location with function if available
499
+ calling_func = site.get("calling_function")
500
+ if calling_func:
501
+ caller = f"{site['calling_module']}.{calling_func['name']}/{calling_func['arity']}"
502
+ else:
503
+ caller = site["calling_module"]
504
+
505
+ # Show consolidated line numbers
506
+ line_list = ", ".join(f":{line}" for line in site["lines"])
507
+ lines.append(
508
+ f"{indent}- {caller} at {site['file']}{line_list}"
509
+ )
510
+
511
+ if test_sites:
512
+ if code_sites:
513
+ lines.append("") # Blank line between sections
514
+ # Group test sites by caller
515
+ grouped_test = CallSiteFormatter.group_by_caller(test_sites)
516
+ test_count = sum(len(site["lines"]) for site in grouped_test)
517
+ lines.append(f"{indent}Test ({test_count}):")
518
+ for site in grouped_test:
519
+ # Format calling location with function if available
520
+ calling_func = site.get("calling_function")
521
+ if calling_func:
522
+ caller = f"{site['calling_module']}.{calling_func['name']}/{calling_func['arity']}"
523
+ else:
524
+ caller = site["calling_module"]
525
+
526
+ # Show consolidated line numbers
527
+ line_list = ", ".join(f":{line}" for line in site["lines"])
528
+ lines.append(
529
+ f"{indent}- {caller} at {site['file']}{line_list}"
530
+ )
531
+ lines.append("")
532
+ else:
533
+ lines.extend([f"{indent}*No call sites found*"])
534
+
535
+ return "\n".join(lines)
536
+
537
+ @staticmethod
538
+ def format_function_results_json(
539
+ function_name: str, results: list[Dict[str, Any]]
540
+ ) -> str:
541
+ """
542
+ Format function search results as JSON.
543
+
544
+ Args:
545
+ function_name: The searched function name
546
+ results: List of function matches with module context
547
+
548
+ Returns:
549
+ Formatted JSON string
550
+ """
551
+ if not results:
552
+ error_result = {
553
+ "error": "Function not found",
554
+ "query": function_name,
555
+ "hint": "Verify the function name spelling or try without arity",
556
+ }
557
+ return json.dumps(error_result, indent=2)
558
+
559
+ formatted_results = []
560
+ for result in results:
561
+ func_entry = {
562
+ "module": result["module"],
563
+ "moduledoc": result.get("moduledoc"),
564
+ "function": result["function"]["name"],
565
+ "arity": result["function"]["arity"],
566
+ "full_name": f"{result['module']}.{result['function']['name']}/{result['function']['arity']}",
567
+ "signature": SignatureBuilder.build(result["function"]),
568
+ "location": f"{result['file']}:{result['function']['line']}",
569
+ "type": result["function"]["type"],
570
+ "doc": result["function"].get("doc"),
571
+ "call_sites": result.get("call_sites", []),
572
+ }
573
+
574
+ # Add examples if present
575
+ if result["function"].get("examples"):
576
+ func_entry["examples"] = result["function"]["examples"]
577
+
578
+ # Add return_type if present
579
+ if result["function"].get("return_type"):
580
+ func_entry["return_type"] = result["function"]["return_type"]
581
+
582
+ # Add guards if present
583
+ if result["function"].get("guards"):
584
+ func_entry["guards"] = result["function"]["guards"]
585
+
586
+ formatted_results.append(func_entry)
587
+
588
+ output = {
589
+ "query": function_name,
590
+ "total_matches": len(results),
591
+ "results": formatted_results,
592
+ }
593
+ return json.dumps(output, indent=2)
594
+
595
+ @staticmethod
596
+ def format_module_usage_markdown(
597
+ module_name: str, usage_results: Dict[str, Any]
598
+ ) -> str:
599
+ """
600
+ Format module usage results as Markdown.
601
+
602
+ Args:
603
+ module_name: The module being searched for
604
+ usage_results: Dictionary with usage category keys
605
+
606
+ Returns:
607
+ Formatted Markdown string
608
+ """
609
+ aliases = usage_results.get("aliases", [])
610
+ imports = usage_results.get("imports", [])
611
+ requires = usage_results.get("requires", [])
612
+ uses = usage_results.get("uses", [])
613
+ value_mentions = usage_results.get("value_mentions", [])
614
+ function_calls = usage_results.get("function_calls", [])
615
+
616
+ lines = [f"# Usage of `{module_name}`", ""]
617
+
618
+ # Show aliases section
619
+ if aliases:
620
+ lines.extend([f"## Aliases ({len(aliases)} module(s)):", ""])
621
+ for imp in aliases:
622
+ alias_info = (
623
+ f" as `{imp['alias_name']}`"
624
+ if imp["alias_name"] != module_name.split(".")[-1]
625
+ else ""
626
+ )
627
+ lines.append(
628
+ f"- `{imp['importing_module']}` {alias_info} — `{imp['file']}`"
629
+ )
630
+ lines.append("")
631
+
632
+ # Show imports section
633
+ if imports:
634
+ lines.extend([f"## Imports ({len(imports)} module(s)):", ""])
635
+ for imp in imports:
636
+ lines.append(f"- `{imp['importing_module']}` — `{imp['file']}`")
637
+ lines.append("")
638
+
639
+ # Show requires section
640
+ if requires:
641
+ lines.extend([f"## Requires ({len(requires)} module(s)):", ""])
642
+ for req in requires:
643
+ lines.append(f"- `{req['importing_module']}` — `{req['file']}`")
644
+ lines.append("")
645
+
646
+ # Show uses section
647
+ if uses:
648
+ lines.extend([f"## Uses ({len(uses)} module(s)):", ""])
649
+ for use in uses:
650
+ lines.append(f"- `{use['importing_module']}` — `{use['file']}`")
651
+ lines.append("")
652
+
653
+ # Show value mentions section
654
+ if value_mentions:
655
+ lines.extend([f"## As Value ({len(value_mentions)} module(s)):", ""])
656
+ for vm in value_mentions:
657
+ lines.append(f"- `{vm['importing_module']}` — `{vm['file']}`")
658
+ lines.append("")
659
+
660
+ # Show function calls section
661
+ if function_calls:
662
+ # Count total calls
663
+ total_calls = sum(len(fc["calls"]) for fc in function_calls)
664
+ lines.extend(
665
+ [
666
+ f"## Function Calls ({len(function_calls)} module(s), {total_calls} function(s)):",
667
+ "",
668
+ ]
669
+ )
670
+
671
+ for fc in function_calls:
672
+ lines.append(f"### `{fc['calling_module']}`")
673
+ lines.append(f" `{fc['file']}`")
674
+ lines.append("")
675
+
676
+ for call in fc["calls"]:
677
+ alias_info = (
678
+ f" (via `{call['alias_used']}`)" if call["alias_used"] else ""
679
+ )
680
+ # Show unique line numbers for this function
681
+ line_list = ", ".join(f":{line}" for line in sorted(call["lines"]))
682
+ lines.append(
683
+ f" - `{call['function']}/{call['arity']}`{alias_info} — {line_list}"
684
+ )
685
+
686
+ lines.append("")
687
+
688
+ # Show message if no usage found at all
689
+ if not any([aliases, imports, requires, uses, value_mentions, function_calls]):
690
+ lines.extend(["*No usage found for this module*"])
691
+
692
+ return "\n".join(lines)
693
+
694
+ @staticmethod
695
+ def format_module_usage_json(
696
+ module_name: str, usage_results: Dict[str, Any]
697
+ ) -> str:
698
+ """
699
+ Format module usage results as JSON.
700
+
701
+ Args:
702
+ module_name: The module being searched for
703
+ usage_results: Dictionary with usage category keys
704
+
705
+ Returns:
706
+ Formatted JSON string
707
+ """
708
+ output = {
709
+ "module": module_name,
710
+ "aliases": usage_results.get("aliases", []),
711
+ "imports": usage_results.get("imports", []),
712
+ "requires": usage_results.get("requires", []),
713
+ "uses": usage_results.get("uses", []),
714
+ "value_mentions": usage_results.get("value_mentions", []),
715
+ "function_calls": usage_results.get("function_calls", []),
716
+ "summary": {
717
+ "aliased_by": len(usage_results.get("aliases", [])),
718
+ "imported_by": len(usage_results.get("imports", [])),
719
+ "required_by": len(usage_results.get("requires", [])),
720
+ "used_by": len(usage_results.get("uses", [])),
721
+ "mentioned_as_value_by": len(usage_results.get("value_mentions", [])),
722
+ "called_by": len(usage_results.get("function_calls", [])),
723
+ },
724
+ }
725
+ return json.dumps(output, indent=2)
726
+
727
+ @staticmethod
728
+ def format_keyword_search_results_markdown(
729
+ _keywords: list[str], results: list[Dict[str, Any]]
730
+ ) -> str:
731
+ """
732
+ Format keyword search results as Markdown.
733
+
734
+ Args:
735
+ keywords: The search keywords
736
+ results: List of search result dictionaries
737
+
738
+ Returns:
739
+ Formatted Markdown string
740
+ """
741
+ lines: list[str] = []
742
+
743
+ for _, result in enumerate(results, 1):
744
+ _result_type = result["type"]
745
+ name = result["name"]
746
+ file_path = result["file"]
747
+ line = result["line"]
748
+ score = result["score"]
749
+ _confidence = result["confidence"]
750
+ matched_keywords = result["matched_keywords"]
751
+
752
+ # Result header - clean format like other tools
753
+ lines.append(name)
754
+
755
+ # Location and score - clean format
756
+ lines.append(
757
+ f"{file_path}:{line} • Score: {score:.4f} • Matched: {', '.join(matched_keywords) if matched_keywords else 'None'}"
758
+ )
759
+
760
+ # Documentation snippet - clean format with code blocks
761
+ doc = result.get("doc")
762
+ if doc:
763
+ # Trim long docs
764
+ doc_lines = doc.strip().split("\n")
765
+ if len(doc_lines) > 3:
766
+ preview = "\n".join(doc_lines[:3])
767
+ lines.extend(
768
+ [
769
+ "",
770
+ "Documentation:",
771
+ "",
772
+ "```",
773
+ f"{preview}",
774
+ "... (trimmed)",
775
+ "```",
776
+ ]
777
+ )
778
+ else:
779
+ lines.extend(["", "Documentation:", "", "```", doc.strip(), "```"])
780
+
781
+ lines.append("") # Empty line between results
782
+
783
+ return "\n".join(lines)
784
+
785
+
786
+ class JSONFormatter:
787
+ """Formats JSON data with customizable options."""
788
+
789
+ def __init__(self, indent: int | None = 2, sort_keys: bool = False):
790
+ """
791
+ Initialize the formatter.
792
+
793
+ Args:
794
+ indent: Number of spaces for indentation (default: 2)
795
+ sort_keys: Whether to sort dictionary keys alphabetically (default: False)
796
+ """
797
+ self.indent = indent
798
+ self.sort_keys = sort_keys
799
+
800
+ def format_string(self, json_string: str) -> str:
801
+ """
802
+ Format a JSON string.
803
+
804
+ Args:
805
+ json_string: Raw JSON string to format
806
+
807
+ Returns:
808
+ Formatted JSON string
809
+
810
+ Raises:
811
+ ValueError: If the input is not valid JSON
812
+ """
813
+ try:
814
+ data = json.loads(json_string)
815
+ return json.dumps(data, indent=self.indent, sort_keys=self.sort_keys)
816
+ except json.JSONDecodeError as e:
817
+ raise ValueError(f"Invalid JSON: {e}")
818
+
819
+ def format_file(self, input_path: Path, output_path: Optional[Path] = None) -> str:
820
+ """
821
+ Format a JSON file.
822
+
823
+ Args:
824
+ input_path: Path to the input JSON file
825
+ output_path: Optional path to write formatted output (default: stdout)
826
+
827
+ Returns:
828
+ Formatted JSON string
829
+
830
+ Raises:
831
+ FileNotFoundError: If the input file doesn't exist
832
+ ValueError: If the input file contains invalid JSON
833
+ """
834
+ if not input_path.exists():
835
+ raise FileNotFoundError(f"Input file not found: {input_path}")
836
+
837
+ # Read the input file
838
+ with open(input_path, "r") as f:
839
+ json_string = f.read()
840
+
841
+ # Format the JSON
842
+ formatted = self.format_string(json_string)
843
+
844
+ # Write to output file if specified, otherwise return for stdout
845
+ if output_path:
846
+ with open(output_path, "w") as f:
847
+ _ = f.write(formatted)
848
+ _ = f.write("\n") # Add trailing newline
849
+ print(f"Formatted JSON written to: {output_path}", file=sys.stderr)
850
+
851
+ return formatted
852
+
853
+ def format_dict(self, data: dict) -> str:
854
+ """
855
+ Format a Python dictionary as JSON.
856
+
857
+ Args:
858
+ data: Dictionary to format
859
+
860
+ Returns:
861
+ Formatted JSON string
862
+ """
863
+ return json.dumps(data, indent=self.indent, sort_keys=self.sort_keys)
864
+
865
+
866
+ def main():
867
+ """Main entry point for the formatter CLI."""
868
+ parser = argparse.ArgumentParser(
869
+ description="Pretty print JSON files with customizable formatting"
870
+ )
871
+ _ = parser.add_argument("input", type=Path, help="Input JSON file to format")
872
+ _ = parser.add_argument(
873
+ "-o", "--output", type=Path, help="Output file (default: print to stdout)"
874
+ )
875
+ _ = parser.add_argument(
876
+ "-i",
877
+ "--indent",
878
+ type=int,
879
+ default=2,
880
+ help="Number of spaces for indentation (default: 2)",
881
+ )
882
+ _ = parser.add_argument(
883
+ "-s",
884
+ "--sort-keys",
885
+ action="store_true",
886
+ help="Sort dictionary keys alphabetically",
887
+ )
888
+ _ = parser.add_argument(
889
+ "--compact", action="store_true", help="Use compact formatting (no indentation)"
890
+ )
891
+
892
+ args = parser.parse_args()
893
+
894
+ # Create formatter with specified options
895
+ indent = None if args.compact else args.indent
896
+ formatter = JSONFormatter(indent=indent, sort_keys=args.sort_keys)
897
+
898
+ try:
899
+ # Format the file
900
+ formatted = formatter.format_file(args.input, args.output)
901
+
902
+ # Print to stdout if no output file specified
903
+ if not args.output:
904
+ print(formatted)
905
+
906
+ except FileNotFoundError as e:
907
+ print(f"Error: {e}", file=sys.stderr)
908
+ sys.exit(1)
909
+ except ValueError as e:
910
+ print(f"Error: {e}", file=sys.stderr)
911
+ sys.exit(1)
912
+ except Exception as e:
913
+ print(f"Unexpected error: {e}", file=sys.stderr)
914
+ sys.exit(1)
915
+
916
+
917
+ if __name__ == "__main__":
918
+ main()