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.
- cicada/__init__.py +30 -0
- cicada/clean.py +297 -0
- cicada/command_logger.py +293 -0
- cicada/dead_code_analyzer.py +282 -0
- cicada/extractors/__init__.py +36 -0
- cicada/extractors/base.py +66 -0
- cicada/extractors/call.py +176 -0
- cicada/extractors/dependency.py +361 -0
- cicada/extractors/doc.py +179 -0
- cicada/extractors/function.py +246 -0
- cicada/extractors/module.py +123 -0
- cicada/extractors/spec.py +151 -0
- cicada/find_dead_code.py +270 -0
- cicada/formatter.py +918 -0
- cicada/git_helper.py +646 -0
- cicada/indexer.py +629 -0
- cicada/install.py +724 -0
- cicada/keyword_extractor.py +364 -0
- cicada/keyword_search.py +553 -0
- cicada/lightweight_keyword_extractor.py +298 -0
- cicada/mcp_server.py +1559 -0
- cicada/mcp_tools.py +291 -0
- cicada/parser.py +124 -0
- cicada/pr_finder.py +435 -0
- cicada/pr_indexer/__init__.py +20 -0
- cicada/pr_indexer/cli.py +62 -0
- cicada/pr_indexer/github_api_client.py +431 -0
- cicada/pr_indexer/indexer.py +297 -0
- cicada/pr_indexer/line_mapper.py +209 -0
- cicada/pr_indexer/pr_index_builder.py +253 -0
- cicada/setup.py +339 -0
- cicada/utils/__init__.py +52 -0
- cicada/utils/call_site_formatter.py +95 -0
- cicada/utils/function_grouper.py +57 -0
- cicada/utils/hash_utils.py +173 -0
- cicada/utils/index_utils.py +290 -0
- cicada/utils/path_utils.py +240 -0
- cicada/utils/signature_builder.py +106 -0
- cicada/utils/storage.py +111 -0
- cicada/utils/subprocess_runner.py +182 -0
- cicada/utils/text_utils.py +90 -0
- cicada/version_check.py +116 -0
- cicada_mcp-0.1.4.dist-info/METADATA +619 -0
- cicada_mcp-0.1.4.dist-info/RECORD +48 -0
- cicada_mcp-0.1.4.dist-info/WHEEL +5 -0
- cicada_mcp-0.1.4.dist-info/entry_points.txt +8 -0
- cicada_mcp-0.1.4.dist-info/licenses/LICENSE +21 -0
- 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()
|