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