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 @@
|
|
|
1
|
+
"""Dead code analysis module - finds and analyzes unused code in Elixir projects."""
|
|
@@ -11,9 +11,10 @@ import argparse
|
|
|
11
11
|
import json
|
|
12
12
|
import sys
|
|
13
13
|
|
|
14
|
-
from cicada.dead_code_analyzer import DeadCodeAnalyzer
|
|
15
14
|
from cicada.utils import get_index_path, load_index
|
|
16
15
|
|
|
16
|
+
from .analyzer import DeadCodeAnalyzer
|
|
17
|
+
|
|
17
18
|
|
|
18
19
|
def format_markdown(results: dict) -> str:
|
|
19
20
|
"""
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dependency analysis for Elixir modules and functions.
|
|
3
|
+
|
|
4
|
+
This module processes already-extracted AST data (aliases, imports, uses, calls)
|
|
5
|
+
to produce clean dependency information.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _resolve_module_alias(module_name: str, aliases: dict) -> str:
|
|
10
|
+
"""
|
|
11
|
+
Resolve a module name using the alias mapping.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
module_name: Short or full module name
|
|
15
|
+
aliases: Dict mapping short names to full names
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
Full module name (resolved if aliased, otherwise unchanged)
|
|
19
|
+
"""
|
|
20
|
+
return aliases.get(module_name, module_name)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def extract_module_dependencies(module_data: dict) -> dict:
|
|
24
|
+
"""
|
|
25
|
+
Extract module-level dependencies from parsed module data.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
module_data: Dictionary containing module information with:
|
|
29
|
+
- aliases: Dict mapping short names to full module names
|
|
30
|
+
- imports: List of imported module names
|
|
31
|
+
- uses: List of used module names
|
|
32
|
+
- requires: List of required module names (optional)
|
|
33
|
+
- behaviours: List of behaviour module names (optional)
|
|
34
|
+
- calls: List of function calls with module, function, arity, line
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Dictionary with:
|
|
38
|
+
- modules: Set of module names this module depends on
|
|
39
|
+
- has_dynamic_calls: Boolean indicating if there are unresolved calls
|
|
40
|
+
"""
|
|
41
|
+
dependencies = set()
|
|
42
|
+
aliases = module_data.get("aliases", {})
|
|
43
|
+
|
|
44
|
+
# Add dependencies from various sources
|
|
45
|
+
# Note: we use aliases.values() to get full names, not short names
|
|
46
|
+
for _source_key, extract_values in [
|
|
47
|
+
("aliases", lambda: aliases.values()),
|
|
48
|
+
("imports", lambda: module_data.get("imports", [])),
|
|
49
|
+
("uses", lambda: module_data.get("uses", [])),
|
|
50
|
+
("requires", lambda: module_data.get("requires", [])),
|
|
51
|
+
("behaviours", lambda: module_data.get("behaviours", [])),
|
|
52
|
+
]:
|
|
53
|
+
dependencies.update(extract_values())
|
|
54
|
+
|
|
55
|
+
# Add dependencies from function calls (with alias resolution)
|
|
56
|
+
for call in module_data.get("calls", []):
|
|
57
|
+
module_name = call.get("module")
|
|
58
|
+
if module_name:
|
|
59
|
+
resolved_module = _resolve_module_alias(module_name, aliases)
|
|
60
|
+
# Exclude Kernel module (too noisy)
|
|
61
|
+
if resolved_module != "Kernel":
|
|
62
|
+
dependencies.add(resolved_module)
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
"modules": sorted(dependencies),
|
|
66
|
+
"has_dynamic_calls": False, # Could be enhanced to detect apply() etc.
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def extract_function_dependencies(
|
|
71
|
+
module_data: dict,
|
|
72
|
+
function_data: dict,
|
|
73
|
+
all_module_calls: list,
|
|
74
|
+
function_end_line: int,
|
|
75
|
+
) -> list:
|
|
76
|
+
"""
|
|
77
|
+
Extract function-level dependencies from function calls.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
module_data: Dictionary containing module information (for alias resolution)
|
|
81
|
+
function_data: Dictionary containing function information (name, arity, line)
|
|
82
|
+
all_module_calls: List of ALL calls in the module
|
|
83
|
+
function_end_line: The line where the function ends
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
List of dictionaries, each containing:
|
|
87
|
+
- module: Module name (resolved from aliases)
|
|
88
|
+
- function: Function name
|
|
89
|
+
- arity: Function arity
|
|
90
|
+
- line: Line number where called
|
|
91
|
+
"""
|
|
92
|
+
module_name = module_data.get("module")
|
|
93
|
+
aliases = module_data.get("aliases", {})
|
|
94
|
+
function_start_line = function_data.get("line")
|
|
95
|
+
|
|
96
|
+
# Filter calls to only those within this function's line range
|
|
97
|
+
function_calls = [
|
|
98
|
+
call
|
|
99
|
+
for call in all_module_calls
|
|
100
|
+
if function_start_line is not None
|
|
101
|
+
and call.get("line") is not None
|
|
102
|
+
and function_start_line <= call["line"] <= function_end_line
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
dependencies = []
|
|
106
|
+
for call in function_calls:
|
|
107
|
+
# Resolve module name (external calls use aliases, local calls use current module)
|
|
108
|
+
call_module = call.get("module")
|
|
109
|
+
resolved_module = (
|
|
110
|
+
_resolve_module_alias(call_module, aliases) if call_module else module_name
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
dependencies.append(
|
|
114
|
+
{
|
|
115
|
+
"module": resolved_module,
|
|
116
|
+
"function": call.get("function"),
|
|
117
|
+
"arity": call.get("arity"),
|
|
118
|
+
"line": call.get("line"),
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
return dependencies
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def calculate_function_end_line(function_data: dict, next_function_line: int | None) -> int:
|
|
126
|
+
"""
|
|
127
|
+
Calculate the end line of a function.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
function_data: Dictionary containing function information
|
|
131
|
+
next_function_line: Line number of the next function, or None if this is the last function
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Estimated end line of the function
|
|
135
|
+
"""
|
|
136
|
+
function_line = function_data.get("line")
|
|
137
|
+
|
|
138
|
+
if next_function_line:
|
|
139
|
+
# Function ends just before the next function
|
|
140
|
+
return next_function_line - 1
|
|
141
|
+
elif function_line is not None:
|
|
142
|
+
# Last function - use a large number as end line
|
|
143
|
+
# This is a heuristic; ideally we'd get the actual end line from the AST
|
|
144
|
+
return function_line + 10000
|
|
145
|
+
else:
|
|
146
|
+
# If no line info, return a large number
|
|
147
|
+
return 99999999
|
cicada/entry_utils.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from collections.abc import Callable, Sequence
|
|
5
|
+
|
|
6
|
+
from cicada import commands as _commands_module
|
|
7
|
+
|
|
8
|
+
KNOWN_SUBCOMMANDS_SET = getattr(_commands_module, "KNOWN_SUBCOMMANDS_SET", frozenset())
|
|
9
|
+
get_argument_parser = _commands_module.get_argument_parser
|
|
10
|
+
handle_command = _commands_module.handle_command
|
|
11
|
+
|
|
12
|
+
DefaultResolver = Callable[[], str | None] | str | None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def prepare_argv(
|
|
16
|
+
argv: Sequence[str],
|
|
17
|
+
*,
|
|
18
|
+
default_on_unknown: str | None,
|
|
19
|
+
default_on_none: DefaultResolver,
|
|
20
|
+
default_on_unknown_args: Sequence[str] | None = None,
|
|
21
|
+
default_on_none_args: Sequence[str] | None = None,
|
|
22
|
+
) -> list[str]:
|
|
23
|
+
"""
|
|
24
|
+
Normalize argv so both entry points share identical subcommand routing.
|
|
25
|
+
|
|
26
|
+
- If the first argument is an unknown token (and not a flag), inject the default subcommand and any associated args.
|
|
27
|
+
- If no arguments are provided, append the default-on-none subcommand (with optional extra args).
|
|
28
|
+
"""
|
|
29
|
+
normalized = list(argv)
|
|
30
|
+
|
|
31
|
+
if len(normalized) > 1:
|
|
32
|
+
first_arg = normalized[1]
|
|
33
|
+
if (
|
|
34
|
+
default_on_unknown
|
|
35
|
+
and first_arg not in KNOWN_SUBCOMMANDS_SET
|
|
36
|
+
and not first_arg.startswith("-")
|
|
37
|
+
):
|
|
38
|
+
extras = list(default_on_unknown_args or ())
|
|
39
|
+
normalized[1:1] = [default_on_unknown, *extras]
|
|
40
|
+
elif len(normalized) == 1:
|
|
41
|
+
default_command = _resolve_default(default_on_none)
|
|
42
|
+
if default_command:
|
|
43
|
+
extras = list(default_on_none_args or ())
|
|
44
|
+
normalized.append(default_command)
|
|
45
|
+
if extras:
|
|
46
|
+
normalized.extend(extras)
|
|
47
|
+
|
|
48
|
+
return normalized
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def run_cli(
|
|
52
|
+
*,
|
|
53
|
+
prog_name: str,
|
|
54
|
+
version_prog_name: str,
|
|
55
|
+
default_on_unknown: str | None,
|
|
56
|
+
default_on_none: DefaultResolver,
|
|
57
|
+
default_on_unknown_args: Sequence[str] | None = None,
|
|
58
|
+
default_on_none_args: Sequence[str] | None = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Shared entry-point runner for cicada and cicada-mcp."""
|
|
61
|
+
argv = list(sys.argv)
|
|
62
|
+
_maybe_print_version(argv, version_prog_name)
|
|
63
|
+
|
|
64
|
+
normalized = prepare_argv(
|
|
65
|
+
argv,
|
|
66
|
+
default_on_unknown=default_on_unknown,
|
|
67
|
+
default_on_none=default_on_none,
|
|
68
|
+
default_on_unknown_args=default_on_unknown_args,
|
|
69
|
+
default_on_none_args=default_on_none_args,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
parser = get_argument_parser()
|
|
73
|
+
parser.prog = prog_name
|
|
74
|
+
args = parser.parse_args(normalized[1:])
|
|
75
|
+
|
|
76
|
+
if not handle_command(args):
|
|
77
|
+
parser.print_help()
|
|
78
|
+
sys.exit(1)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _resolve_default(spec: DefaultResolver) -> str | None:
|
|
82
|
+
if callable(spec):
|
|
83
|
+
return spec()
|
|
84
|
+
return spec
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _maybe_print_version(argv: Sequence[str], prog_name: str) -> None:
|
|
88
|
+
if len(argv) > 1 and argv[1] in ("--version", "-v"):
|
|
89
|
+
from cicada.version_check import get_version_string
|
|
90
|
+
|
|
91
|
+
print(f"{prog_name} {get_version_string()}")
|
|
92
|
+
sys.exit(0)
|
cicada/extractors/base.py
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
Shared utilities for extractors.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
from cicada.utils import extract_text_from_node
|
|
6
|
+
|
|
5
7
|
|
|
6
8
|
def extract_string_from_arguments(arguments_node, source_code: bytes) -> str | None:
|
|
7
9
|
"""Extract string value from function arguments."""
|
|
@@ -12,9 +14,7 @@ def extract_string_from_arguments(arguments_node, source_code: bytes) -> str | N
|
|
|
12
14
|
string_content = []
|
|
13
15
|
for string_child in child.children:
|
|
14
16
|
if string_child.type == "quoted_content":
|
|
15
|
-
content =
|
|
16
|
-
"utf-8"
|
|
17
|
-
)
|
|
17
|
+
content = extract_text_from_node(string_child, source_code)
|
|
18
18
|
string_content.append(content)
|
|
19
19
|
|
|
20
20
|
if string_content:
|
|
@@ -22,7 +22,7 @@ def extract_string_from_arguments(arguments_node, source_code: bytes) -> str | N
|
|
|
22
22
|
|
|
23
23
|
# Handle false (for @moduledoc false)
|
|
24
24
|
elif child.type == "boolean" or child.type == "atom":
|
|
25
|
-
value =
|
|
25
|
+
value = extract_text_from_node(child, source_code)
|
|
26
26
|
if value == "false":
|
|
27
27
|
return None
|
|
28
28
|
|
|
@@ -33,28 +33,28 @@ def get_param_name(node, source_code: bytes) -> str | None:
|
|
|
33
33
|
"""Get parameter name from a parameter node."""
|
|
34
34
|
# Handle simple identifier: my_arg
|
|
35
35
|
if node.type == "identifier":
|
|
36
|
-
return
|
|
36
|
+
return extract_text_from_node(node, source_code)
|
|
37
37
|
|
|
38
38
|
# Handle pattern match with default: my_arg \\ default_value
|
|
39
39
|
elif node.type == "binary_operator":
|
|
40
40
|
for child in node.children:
|
|
41
41
|
if child.type == "identifier":
|
|
42
|
-
return
|
|
42
|
+
return extract_text_from_node(child, source_code)
|
|
43
43
|
|
|
44
44
|
# Handle destructuring: {key, value} or [head | tail]
|
|
45
45
|
elif node.type in ["tuple", "list", "map"]:
|
|
46
46
|
# For complex patterns, return the whole pattern as string
|
|
47
|
-
return
|
|
47
|
+
return extract_text_from_node(node, source_code)
|
|
48
48
|
|
|
49
49
|
# Handle call patterns (e.g., %Struct{} = arg)
|
|
50
50
|
elif node.type == "call":
|
|
51
51
|
# Try to find the actual variable name
|
|
52
52
|
for child in node.children:
|
|
53
53
|
if child.type == "identifier":
|
|
54
|
-
return
|
|
54
|
+
return extract_text_from_node(child, source_code)
|
|
55
55
|
|
|
56
56
|
# Fallback: return the whole node as string
|
|
57
|
-
return
|
|
57
|
+
return extract_text_from_node(node, source_code)
|
|
58
58
|
|
|
59
59
|
|
|
60
60
|
def count_arguments(arguments_node) -> int:
|
cicada/extractors/call.py
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
Function call and value mention extraction logic.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
from cicada.utils import extract_text_from_node, is_function_definition_call
|
|
6
|
+
|
|
5
7
|
from .base import count_arguments
|
|
6
8
|
|
|
7
9
|
|
|
@@ -14,17 +16,18 @@ def extract_function_calls(node, source_code: bytes) -> list:
|
|
|
14
16
|
|
|
15
17
|
def _find_function_calls_recursive(node, source_code: bytes, calls: list):
|
|
16
18
|
"""Recursively find function calls."""
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
# Skip module attributes (@spec, @doc, @moduledoc, @type, etc.)
|
|
20
|
+
# These are wrapped in unary_operator nodes with @ token
|
|
21
|
+
if node.type == "unary_operator":
|
|
22
|
+
# Check if this is a module attribute (starts with @)
|
|
20
23
|
for child in node.children:
|
|
21
|
-
if child.type == "
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
is_function_def = True
|
|
25
|
-
break
|
|
24
|
+
if child.type == "@":
|
|
25
|
+
# This is a module attribute, skip the entire subtree
|
|
26
|
+
return
|
|
26
27
|
|
|
27
|
-
|
|
28
|
+
if node.type == "call":
|
|
29
|
+
# Check if this is a function definition (def/defp)
|
|
30
|
+
if is_function_definition_call(node, source_code):
|
|
28
31
|
# Skip the arguments (which contain the function signature)
|
|
29
32
|
# but still process the do_block to find calls within the function body
|
|
30
33
|
for child in node.children:
|
|
@@ -65,16 +68,12 @@ def _parse_function_call(call_node, source_code: bytes) -> dict | None:
|
|
|
65
68
|
# Extract module and function from dot
|
|
66
69
|
for dot_child in child.children:
|
|
67
70
|
if dot_child.type == "alias":
|
|
68
|
-
module_name =
|
|
69
|
-
"utf-8"
|
|
70
|
-
)
|
|
71
|
+
module_name = extract_text_from_node(dot_child, source_code)
|
|
71
72
|
elif dot_child.type == "identifier":
|
|
72
|
-
function_name =
|
|
73
|
-
"utf-8"
|
|
74
|
-
)
|
|
73
|
+
function_name = extract_text_from_node(dot_child, source_code)
|
|
75
74
|
elif child.type == "identifier" and not has_dot:
|
|
76
75
|
# Local function call
|
|
77
|
-
function_name =
|
|
76
|
+
function_name = extract_text_from_node(child, source_code)
|
|
78
77
|
elif child.type == "arguments":
|
|
79
78
|
arguments_node = child
|
|
80
79
|
|
|
@@ -132,7 +131,7 @@ def _find_value_mentions_recursive(node, source_code: bytes, value_mentions: lis
|
|
|
132
131
|
# Skip if parent is a specific call type
|
|
133
132
|
|
|
134
133
|
# Get the module name
|
|
135
|
-
module_name =
|
|
134
|
+
module_name = extract_text_from_node(node, source_code)
|
|
136
135
|
|
|
137
136
|
# We need to check if this alias is part of a call with dot notation
|
|
138
137
|
# If it has a dot parent, it's a module function call, not a value mention
|
|
@@ -147,9 +146,7 @@ def _find_value_mentions_recursive(node, source_code: bytes, value_mentions: lis
|
|
|
147
146
|
# Check if this is alias/import/require/use/defmodule
|
|
148
147
|
for child in current.children:
|
|
149
148
|
if child.type == "identifier":
|
|
150
|
-
func_text =
|
|
151
|
-
"utf-8"
|
|
152
|
-
)
|
|
149
|
+
func_text = extract_text_from_node(child, source_code)
|
|
153
150
|
if func_text in [
|
|
154
151
|
"alias",
|
|
155
152
|
"import",
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from cicada.utils import extract_text_from_node, is_function_definition_call
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def _find_nodes_recursive(node, source_code: bytes, results: list, node_type: str, parse_function):
|
|
5
|
+
"""Recursively find nodes of a specific type and parse them."""
|
|
6
|
+
if node.type == node_type:
|
|
7
|
+
result = parse_function(node, source_code)
|
|
8
|
+
if result:
|
|
9
|
+
if isinstance(result, list):
|
|
10
|
+
results.extend(result)
|
|
11
|
+
else:
|
|
12
|
+
results.append(result)
|
|
13
|
+
|
|
14
|
+
# Recursively search children, but skip function bodies
|
|
15
|
+
for child in node.children:
|
|
16
|
+
if child.type == "call" and is_function_definition_call(child, source_code):
|
|
17
|
+
continue
|
|
18
|
+
|
|
19
|
+
_find_nodes_recursive(child, source_code, results, node_type, parse_function)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _find_attribute_recursive(
|
|
23
|
+
node, source_code: bytes, attributes: dict, attribute_name: str, parse_function
|
|
24
|
+
):
|
|
25
|
+
"""Recursively find attribute declarations."""
|
|
26
|
+
# Look for unary_operator nodes (which represent @ attributes)
|
|
27
|
+
if node.type == "unary_operator":
|
|
28
|
+
operator = None
|
|
29
|
+
operand = None
|
|
30
|
+
|
|
31
|
+
for child in node.children:
|
|
32
|
+
if child.type == "@":
|
|
33
|
+
operator = child
|
|
34
|
+
elif child.type == "call":
|
|
35
|
+
operand = child
|
|
36
|
+
|
|
37
|
+
if operator and operand:
|
|
38
|
+
# Check if this is a doc attribute
|
|
39
|
+
for call_child in operand.children:
|
|
40
|
+
if call_child.type == "identifier":
|
|
41
|
+
attr_name = extract_text_from_node(call_child, source_code)
|
|
42
|
+
|
|
43
|
+
if attr_name == attribute_name:
|
|
44
|
+
# Extract the doc definition
|
|
45
|
+
if attribute_name == "spec":
|
|
46
|
+
attribute_info = parse_function(operand, source_code)
|
|
47
|
+
else:
|
|
48
|
+
attribute_info = parse_function(
|
|
49
|
+
operand, source_code, node.start_point[0] + 1
|
|
50
|
+
)
|
|
51
|
+
if attribute_info:
|
|
52
|
+
if attribute_name == "doc":
|
|
53
|
+
attributes[attribute_info["line"]] = attribute_info
|
|
54
|
+
elif attribute_name == "spec":
|
|
55
|
+
key = f"{attribute_info['name']}/{attribute_info['arity']}"
|
|
56
|
+
attributes[key] = attribute_info
|
|
57
|
+
|
|
58
|
+
# Recursively search children
|
|
59
|
+
for child in node.children:
|
|
60
|
+
# Don't recurse into nested defmodule or function definitions
|
|
61
|
+
if child.type == "call" and is_function_definition_call(child, source_code):
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
_find_attribute_recursive(child, source_code, attributes, attribute_name, parse_function)
|