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