cicada-mcp 0.2.0__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. cicada/_version_hash.py +4 -0
  2. cicada/cli.py +6 -748
  3. cicada/commands.py +1255 -0
  4. cicada/dead_code/__init__.py +1 -0
  5. cicada/{find_dead_code.py → dead_code/finder.py} +2 -1
  6. cicada/dependency_analyzer.py +147 -0
  7. cicada/entry_utils.py +92 -0
  8. cicada/extractors/base.py +9 -9
  9. cicada/extractors/call.py +17 -20
  10. cicada/extractors/common.py +64 -0
  11. cicada/extractors/dependency.py +117 -235
  12. cicada/extractors/doc.py +2 -49
  13. cicada/extractors/function.py +10 -14
  14. cicada/extractors/keybert.py +228 -0
  15. cicada/extractors/keyword.py +191 -0
  16. cicada/extractors/module.py +6 -10
  17. cicada/extractors/spec.py +8 -56
  18. cicada/format/__init__.py +20 -0
  19. cicada/{ascii_art.py → format/ascii_art.py} +1 -1
  20. cicada/format/formatter.py +1145 -0
  21. cicada/git_helper.py +134 -7
  22. cicada/indexer.py +322 -89
  23. cicada/interactive_setup.py +251 -323
  24. cicada/interactive_setup_helpers.py +302 -0
  25. cicada/keyword_expander.py +437 -0
  26. cicada/keyword_search.py +208 -422
  27. cicada/keyword_test.py +383 -16
  28. cicada/mcp/__init__.py +10 -0
  29. cicada/mcp/entry.py +17 -0
  30. cicada/mcp/filter_utils.py +107 -0
  31. cicada/mcp/pattern_utils.py +118 -0
  32. cicada/{mcp_server.py → mcp/server.py} +819 -73
  33. cicada/mcp/tools.py +473 -0
  34. cicada/pr_finder.py +2 -3
  35. cicada/pr_indexer/indexer.py +3 -2
  36. cicada/setup.py +167 -35
  37. cicada/tier.py +225 -0
  38. cicada/utils/__init__.py +9 -2
  39. cicada/utils/fuzzy_match.py +54 -0
  40. cicada/utils/index_utils.py +9 -0
  41. cicada/utils/path_utils.py +18 -0
  42. cicada/utils/text_utils.py +52 -1
  43. cicada/utils/tree_utils.py +47 -0
  44. cicada/version_check.py +99 -0
  45. cicada/watch_manager.py +320 -0
  46. cicada/watcher.py +431 -0
  47. cicada_mcp-0.3.0.dist-info/METADATA +541 -0
  48. cicada_mcp-0.3.0.dist-info/RECORD +70 -0
  49. cicada_mcp-0.3.0.dist-info/entry_points.txt +4 -0
  50. cicada/formatter.py +0 -864
  51. cicada/keybert_extractor.py +0 -286
  52. cicada/lightweight_keyword_extractor.py +0 -290
  53. cicada/mcp_entry.py +0 -683
  54. cicada/mcp_tools.py +0 -291
  55. cicada_mcp-0.2.0.dist-info/METADATA +0 -735
  56. cicada_mcp-0.2.0.dist-info/RECORD +0 -53
  57. cicada_mcp-0.2.0.dist-info/entry_points.txt +0 -4
  58. /cicada/{dead_code_analyzer.py → dead_code/analyzer.py} +0 -0
  59. /cicada/{colors.py → format/colors.py} +0 -0
  60. {cicada_mcp-0.2.0.dist-info → cicada_mcp-0.3.0.dist-info}/WHEEL +0 -0
  61. {cicada_mcp-0.2.0.dist-info → cicada_mcp-0.3.0.dist-info}/licenses/LICENSE +0 -0
  62. {cicada_mcp-0.2.0.dist-info → cicada_mcp-0.3.0.dist-info}/top_level.txt +0 -0
@@ -4,53 +4,49 @@ Dependency extraction logic (alias, import, require, use).
4
4
  Author: Cursor(Auto)
5
5
  """
6
6
 
7
+ from cicada.utils import extract_text_from_node
8
+
9
+ from .common import _find_nodes_recursive
10
+
7
11
 
8
12
  def extract_aliases(node, source_code: bytes) -> dict:
9
13
  """Extract all alias declarations from a module body."""
10
- aliases = {}
14
+ aliases = []
11
15
  _find_aliases_recursive(node, source_code, aliases)
12
- return aliases
13
16
 
17
+ result = {}
18
+ for alias in aliases:
19
+ if alias:
20
+ result.update(alias)
21
+ return result
14
22
 
15
- def _find_aliases_recursive(node, source_code: bytes, aliases: dict):
16
- """Recursively find alias declarations."""
17
- if node.type == "call":
18
- target = None
19
- arguments = None
20
23
 
21
- for child in node.children:
22
- if child.type == "identifier":
23
- target = child
24
- elif child.type == "arguments":
25
- arguments = child
24
+ def _parse_alias_call(node, source_code: bytes) -> dict | None:
25
+ """Parse an alias call and return the alias information."""
26
+ target = None
27
+ arguments = None
26
28
 
27
- if target and arguments:
28
- target_text = source_code[target.start_byte : target.end_byte].decode("utf-8")
29
+ for child in node.children:
30
+ if child.type == "identifier":
31
+ target = child
32
+ elif child.type == "arguments":
33
+ arguments = child
29
34
 
30
- if target_text == "alias":
31
- # Parse the alias
32
- alias_info = _parse_alias(arguments, source_code)
33
- if alias_info:
34
- # alias_info is a dict of {short_name: full_name}
35
- aliases.update(alias_info)
35
+ if target and arguments:
36
+ target_text = extract_text_from_node(target, source_code)
36
37
 
37
- # Recursively search children, but skip function bodies
38
- for child in node.children:
39
- if child.type == "call":
40
- is_function_def = False
41
- for call_child in child.children:
42
- if call_child.type == "identifier":
43
- target_text = source_code[call_child.start_byte : call_child.end_byte].decode(
44
- "utf-8"
45
- )
46
- if target_text in ["def", "defp", "defmodule"]:
47
- is_function_def = True
48
- break
38
+ if target_text == "alias":
39
+ # Parse the alias
40
+ alias_info = _parse_alias(arguments, source_code)
41
+ if alias_info:
42
+ # alias_info is a dict of {short_name: full_name}
43
+ return alias_info
44
+ return None
49
45
 
50
- if is_function_def:
51
- continue
52
46
 
53
- _find_aliases_recursive(child, source_code, aliases)
47
+ def _find_aliases_recursive(node, source_code: bytes, aliases: list):
48
+ """Recursively find alias declarations."""
49
+ _find_nodes_recursive(node, source_code, aliases, "call", _parse_alias_call)
54
50
 
55
51
 
56
52
  def _parse_alias(arguments_node, source_code: bytes) -> dict | None:
@@ -67,7 +63,7 @@ def _parse_alias(arguments_node, source_code: bytes) -> dict | None:
67
63
  for arg_child in arguments_node.children:
68
64
  # Simple alias: alias MyApp.User
69
65
  if arg_child.type == "alias":
70
- full_name = source_code[arg_child.start_byte : arg_child.end_byte].decode("utf-8")
66
+ full_name = extract_text_from_node(arg_child, source_code)
71
67
  # Get the last part as the short name
72
68
  short_name = full_name.split(".")[-1]
73
69
  result[short_name] = full_name
@@ -80,9 +76,7 @@ def _parse_alias(arguments_node, source_code: bytes) -> dict | None:
80
76
 
81
77
  for dot_child in arg_child.children:
82
78
  if dot_child.type == "alias":
83
- module_prefix = source_code[dot_child.start_byte : dot_child.end_byte].decode(
84
- "utf-8"
85
- )
79
+ module_prefix = extract_text_from_node(dot_child, source_code)
86
80
  elif dot_child.type == "tuple":
87
81
  tuple_node = dot_child
88
82
 
@@ -90,9 +84,7 @@ def _parse_alias(arguments_node, source_code: bytes) -> dict | None:
90
84
  # Extract each alias from the tuple
91
85
  for tuple_child in tuple_node.children:
92
86
  if tuple_child.type == "alias":
93
- short_name = source_code[
94
- tuple_child.start_byte : tuple_child.end_byte
95
- ].decode("utf-8")
87
+ short_name = extract_text_from_node(tuple_child, source_code)
96
88
  full_name = f"{module_prefix}.{short_name}"
97
89
  result[short_name] = full_name
98
90
 
@@ -106,22 +98,16 @@ def _parse_alias(arguments_node, source_code: bytes) -> dict | None:
106
98
  for pair_child in kw_child.children:
107
99
  if pair_child.type == "keyword":
108
100
  # Get keyword text (e.g., "as:")
109
- key_text = source_code[
110
- pair_child.start_byte : pair_child.end_byte
111
- ].decode("utf-8")
101
+ key_text = extract_text_from_node(pair_child, source_code)
112
102
  elif pair_child.type == "alias":
113
- alias_name = source_code[
114
- pair_child.start_byte : pair_child.end_byte
115
- ].decode("utf-8")
103
+ alias_name = extract_text_from_node(pair_child, source_code)
116
104
 
117
105
  # If we found 'as:', update the result to use custom name
118
106
  if key_text and "as" in key_text and alias_name:
119
107
  # Get the full module name from previous arg
120
108
  for prev_arg in arguments_node.children:
121
109
  if prev_arg.type == "alias":
122
- full_name = source_code[
123
- prev_arg.start_byte : prev_arg.end_byte
124
- ].decode("utf-8")
110
+ full_name = extract_text_from_node(prev_arg, source_code)
125
111
  # Remove the default short name and add custom one
126
112
  result.clear()
127
113
  result[alias_name] = full_name
@@ -132,153 +118,67 @@ def _parse_alias(arguments_node, source_code: bytes) -> dict | None:
132
118
 
133
119
  def extract_imports(node, source_code: bytes) -> list:
134
120
  """Extract all import declarations from a module body."""
121
+
135
122
  imports = []
136
- _find_imports_recursive(node, source_code, imports)
137
- return imports
138
123
 
124
+ _find_declarations_recursive(node, source_code, imports, "import")
139
125
 
140
- def _find_imports_recursive(node, source_code: bytes, imports: list):
141
- """Recursively find import declarations."""
142
- if node.type == "call":
143
- target = None
144
- arguments = None
126
+ return imports
145
127
 
146
- for child in node.children:
147
- if child.type == "identifier":
148
- target = child
149
- elif child.type == "arguments":
150
- arguments = child
151
-
152
- if target and arguments:
153
- target_text = source_code[target.start_byte : target.end_byte].decode("utf-8")
154
-
155
- if target_text == "import":
156
- # Parse the import - imports are simpler than aliases
157
- # import MyModule or import MyModule, only: [func: 1]
158
- for arg_child in arguments.children:
159
- if arg_child.type == "alias":
160
- module_name = source_code[arg_child.start_byte : arg_child.end_byte].decode(
161
- "utf-8"
162
- )
163
- imports.append(module_name)
164
-
165
- # Recursively search children, but skip function bodies
166
- for child in node.children:
167
- if child.type == "call":
168
- is_function_def = False
169
- for call_child in child.children:
170
- if call_child.type == "identifier":
171
- target_text = source_code[call_child.start_byte : call_child.end_byte].decode(
172
- "utf-8"
173
- )
174
- if target_text in ["def", "defp", "defmodule"]:
175
- is_function_def = True
176
- break
177
128
 
178
- if is_function_def:
179
- continue
129
+ def _parse_declaration_call(node, source_code: bytes, declaration_name: str) -> str | None:
130
+ """Parse a declaration call and return the module name."""
131
+ target = None
132
+ arguments = None
180
133
 
181
- _find_imports_recursive(child, source_code, imports)
134
+ for child in node.children:
135
+ if child.type == "identifier":
136
+ target = child
137
+ elif child.type == "arguments":
138
+ arguments = child
139
+
140
+ if target and arguments:
141
+ target_text = extract_text_from_node(target, source_code)
142
+
143
+ if target_text == declaration_name:
144
+ # Parse the declaration
145
+ for arg_child in arguments.children:
146
+ if arg_child.type == "alias":
147
+ return extract_text_from_node(arg_child, source_code)
148
+ return None
149
+
150
+
151
+ def _find_declarations_recursive(
152
+ node, source_code: bytes, declarations: list, declaration_name: str
153
+ ):
154
+ """Recursively find declarations."""
155
+ _find_nodes_recursive(
156
+ node,
157
+ source_code,
158
+ declarations,
159
+ "call",
160
+ lambda n, s: _parse_declaration_call(n, s, declaration_name),
161
+ )
182
162
 
183
163
 
184
164
  def extract_requires(node, source_code: bytes) -> list:
185
165
  """Extract all require declarations from a module body."""
186
- requires = []
187
- _find_requires_recursive(node, source_code, requires)
188
- return requires
189
-
190
-
191
- def _find_requires_recursive(node, source_code: bytes, requires: list):
192
- """Recursively find require declarations."""
193
- if node.type == "call":
194
- target = None
195
- arguments = None
196
-
197
- for child in node.children:
198
- if child.type == "identifier":
199
- target = child
200
- elif child.type == "arguments":
201
- arguments = child
202
-
203
- if target and arguments:
204
- target_text = source_code[target.start_byte : target.end_byte].decode("utf-8")
205
166
 
206
- if target_text == "require":
207
- # Parse the require
208
- for arg_child in arguments.children:
209
- if arg_child.type == "alias":
210
- module_name = source_code[arg_child.start_byte : arg_child.end_byte].decode(
211
- "utf-8"
212
- )
213
- requires.append(module_name)
214
-
215
- # Recursively search children, but skip function bodies
216
- for child in node.children:
217
- if child.type == "call":
218
- is_function_def = False
219
- for call_child in child.children:
220
- if call_child.type == "identifier":
221
- target_text = source_code[call_child.start_byte : call_child.end_byte].decode(
222
- "utf-8"
223
- )
224
- if target_text in ["def", "defp", "defmodule"]:
225
- is_function_def = True
226
- break
167
+ requires = []
227
168
 
228
- if is_function_def:
229
- continue
169
+ _find_declarations_recursive(node, source_code, requires, "require")
230
170
 
231
- _find_requires_recursive(child, source_code, requires)
171
+ return requires
232
172
 
233
173
 
234
174
  def extract_uses(node, source_code: bytes) -> list:
235
175
  """Extract all use declarations from a module body."""
236
- uses = []
237
- _find_uses_recursive(node, source_code, uses)
238
- return uses
239
-
240
-
241
- def _find_uses_recursive(node, source_code: bytes, uses: list):
242
- """Recursively find use declarations."""
243
- if node.type == "call":
244
- target = None
245
- arguments = None
246
-
247
- for child in node.children:
248
- if child.type == "identifier":
249
- target = child
250
- elif child.type == "arguments":
251
- arguments = child
252
-
253
- if target and arguments:
254
- target_text = source_code[target.start_byte : target.end_byte].decode("utf-8")
255
176
 
256
- if target_text == "use":
257
- # Parse the use
258
- for arg_child in arguments.children:
259
- if arg_child.type == "alias":
260
- module_name = source_code[arg_child.start_byte : arg_child.end_byte].decode(
261
- "utf-8"
262
- )
263
- uses.append(module_name)
264
-
265
- # Recursively search children, but skip function bodies
266
- for child in node.children:
267
- if child.type == "call":
268
- is_function_def = False
269
- for call_child in child.children:
270
- if call_child.type == "identifier":
271
- target_text = source_code[call_child.start_byte : call_child.end_byte].decode(
272
- "utf-8"
273
- )
274
- if target_text in ["def", "defp", "defmodule"]:
275
- is_function_def = True
276
- break
177
+ uses = []
277
178
 
278
- if is_function_def:
279
- continue
179
+ _find_declarations_recursive(node, source_code, uses, "use")
280
180
 
281
- _find_uses_recursive(child, source_code, uses)
181
+ return uses
282
182
 
283
183
 
284
184
  def extract_behaviours(node, source_code: bytes) -> list:
@@ -288,62 +188,44 @@ def extract_behaviours(node, source_code: bytes) -> list:
288
188
  return behaviours
289
189
 
290
190
 
191
+ def _parse_behaviour_call(node, source_code: bytes) -> str | None:
192
+ """Parse a behaviour call and return the module name."""
193
+ # Check if this is an @ operator with behaviour
194
+ is_at_operator = False
195
+ behaviour_call = None
196
+
197
+ for child in node.children:
198
+ if child.type == "@":
199
+ is_at_operator = True
200
+ elif child.type == "call" and is_at_operator:
201
+ behaviour_call = child
202
+ break
203
+
204
+ if behaviour_call:
205
+ # Check if the call is "behaviour"
206
+ identifier_text = None
207
+ arguments_node = None
208
+
209
+ for child in behaviour_call.children:
210
+ if child.type == "identifier":
211
+ identifier_text = extract_text_from_node(child, source_code)
212
+ elif child.type == "arguments":
213
+ arguments_node = child
214
+
215
+ if identifier_text == "behaviour" and arguments_node:
216
+ # Extract the behaviour module name
217
+ for arg_child in arguments_node.children:
218
+ if arg_child.type == "alias":
219
+ # @behaviour ModuleName
220
+ return extract_text_from_node(arg_child, source_code)
221
+ elif arg_child.type == "atom":
222
+ # @behaviour :module_name
223
+ atom_text = extract_text_from_node(arg_child, source_code)
224
+ # Remove leading colon and convert to module format if needed
225
+ return atom_text.lstrip(":")
226
+ return None
227
+
228
+
291
229
  def _find_behaviours_recursive(node, source_code: bytes, behaviours: list):
292
230
  """Recursively find @behaviour declarations."""
293
- if node.type == "unary_operator":
294
- # Check if this is an @ operator with behaviour
295
- is_at_operator = False
296
- behaviour_call = None
297
-
298
- for child in node.children:
299
- if child.type == "@":
300
- is_at_operator = True
301
- elif child.type == "call" and is_at_operator:
302
- behaviour_call = child
303
- break
304
-
305
- if behaviour_call:
306
- # Check if the call is "behaviour"
307
- identifier_text = None
308
- arguments_node = None
309
-
310
- for child in behaviour_call.children:
311
- if child.type == "identifier":
312
- identifier_text = source_code[child.start_byte : child.end_byte].decode("utf-8")
313
- elif child.type == "arguments":
314
- arguments_node = child
315
-
316
- if identifier_text == "behaviour" and arguments_node:
317
- # Extract the behaviour module name
318
- for arg_child in arguments_node.children:
319
- if arg_child.type == "alias":
320
- # @behaviour ModuleName
321
- module_name = source_code[arg_child.start_byte : arg_child.end_byte].decode(
322
- "utf-8"
323
- )
324
- behaviours.append(module_name)
325
- elif arg_child.type == "atom":
326
- # @behaviour :module_name
327
- atom_text = source_code[arg_child.start_byte : arg_child.end_byte].decode(
328
- "utf-8"
329
- )
330
- # Remove leading colon and convert to module format if needed
331
- behaviours.append(atom_text.lstrip(":"))
332
-
333
- # Recursively search children, but skip function bodies
334
- for child in node.children:
335
- if child.type == "call":
336
- is_function_def = False
337
- for call_child in child.children:
338
- if call_child.type == "identifier":
339
- target_text = source_code[call_child.start_byte : call_child.end_byte].decode(
340
- "utf-8"
341
- )
342
- if target_text in ["def", "defp", "defmodule"]:
343
- is_function_def = True
344
- break
345
-
346
- if is_function_def:
347
- continue
348
-
349
- _find_behaviours_recursive(child, source_code, behaviours)
231
+ _find_nodes_recursive(node, source_code, behaviours, "unary_operator", _parse_behaviour_call)
cicada/extractors/doc.py CHANGED
@@ -5,63 +5,16 @@ Documentation extraction logic.
5
5
  import textwrap
6
6
 
7
7
  from .base import extract_string_from_arguments
8
+ from .common import _find_attribute_recursive
8
9
 
9
10
 
10
11
  def extract_docs(node, source_code: bytes) -> dict:
11
12
  """Extract all @doc attributes from a module body."""
12
13
  docs = {}
13
- _find_docs_recursive(node, source_code, docs)
14
+ _find_attribute_recursive(node, source_code, docs, "doc", _parse_doc)
14
15
  return docs
15
16
 
16
17
 
17
- def _find_docs_recursive(node, source_code: bytes, docs: dict):
18
- """Recursively find @doc declarations."""
19
- # Look for unary_operator nodes (which represent @ attributes)
20
- if node.type == "unary_operator":
21
- operator = None
22
- operand = None
23
-
24
- for child in node.children:
25
- if child.type == "@":
26
- operator = child
27
- elif child.type == "call":
28
- operand = child
29
-
30
- if operator and operand:
31
- # Check if this is a doc attribute
32
- for call_child in operand.children:
33
- if call_child.type == "identifier":
34
- attr_name = source_code[call_child.start_byte : call_child.end_byte].decode(
35
- "utf-8"
36
- )
37
-
38
- if attr_name == "doc":
39
- # Extract the doc definition
40
- doc_info = _parse_doc(operand, source_code, node.start_point[0] + 1)
41
- if doc_info:
42
- # Store the entire doc_info dict (includes text and examples)
43
- docs[doc_info["line"]] = doc_info
44
-
45
- # Recursively search children
46
- for child in node.children:
47
- # Don't recurse into nested defmodule or function definitions
48
- if child.type == "call":
49
- is_defmodule_or_def = False
50
- for call_child in child.children:
51
- if call_child.type == "identifier":
52
- target_text = source_code[call_child.start_byte : call_child.end_byte].decode(
53
- "utf-8"
54
- )
55
- if target_text in ["defmodule", "def", "defp"]:
56
- is_defmodule_or_def = True
57
- break
58
-
59
- if is_defmodule_or_def:
60
- continue
61
-
62
- _find_docs_recursive(child, source_code, docs)
63
-
64
-
65
18
  def _parse_doc(doc_node, source_code: bytes, line: int) -> dict | None:
66
19
  """Parse a @doc attribute to extract its text and examples."""
67
20
  # @doc is represented as: doc("text") or doc(false)
@@ -4,6 +4,8 @@ Function extraction logic.
4
4
  Author: Cursor(Auto)
5
5
  """
6
6
 
7
+ from cicada.utils import extract_text_from_node
8
+
7
9
  from .base import get_param_name
8
10
 
9
11
 
@@ -46,7 +48,7 @@ def _extract_impl_from_prev_sibling(node, source_code: bytes):
46
48
 
47
49
  for child in impl_call.children:
48
50
  if child.type == "identifier":
49
- identifier_text = source_code[child.start_byte : child.end_byte].decode("utf-8")
51
+ identifier_text = extract_text_from_node(child, source_code)
50
52
  elif child.type == "arguments":
51
53
  arguments_node = child
52
54
 
@@ -58,11 +60,11 @@ def _extract_impl_from_prev_sibling(node, source_code: bytes):
58
60
  for arg_child in arguments_node.children:
59
61
  if arg_child.type == "boolean":
60
62
  # @impl true or @impl false
61
- bool_text = source_code[arg_child.start_byte : arg_child.end_byte].decode("utf-8")
63
+ bool_text = extract_text_from_node(arg_child, source_code)
62
64
  return bool_text == "true"
63
65
  elif arg_child.type == "alias":
64
66
  # @impl ModuleName
65
- module_name = source_code[arg_child.start_byte : arg_child.end_byte].decode("utf-8")
67
+ module_name = extract_text_from_node(arg_child, source_code)
66
68
  return module_name
67
69
 
68
70
  # @impl without arguments defaults to true
@@ -90,7 +92,7 @@ def _find_functions_recursive(node, source_code: bytes, functions: list):
90
92
 
91
93
  # Check if this is a def or defp call
92
94
  if target and arguments:
93
- target_text = source_code[target.start_byte : target.end_byte].decode("utf-8")
95
+ target_text = extract_text_from_node(target, source_code)
94
96
 
95
97
  if target_text in ["def", "defp"]:
96
98
  # Check if previous sibling is @impl
@@ -133,9 +135,7 @@ def _parse_function_definition(
133
135
  # Extract function name from call target
134
136
  for call_child in arg_child.children:
135
137
  if call_child.type == "identifier":
136
- func_name = source_code[call_child.start_byte : call_child.end_byte].decode(
137
- "utf-8"
138
- )
138
+ func_name = extract_text_from_node(call_child, source_code)
139
139
  elif call_child.type == "arguments":
140
140
  arg_names = _extract_argument_names(call_child, source_code)
141
141
  arity = len(arg_names)
@@ -148,16 +148,14 @@ def _parse_function_definition(
148
148
  # Extract function name and args from the call
149
149
  for call_child in op_child.children:
150
150
  if call_child.type == "identifier":
151
- func_name = source_code[
152
- call_child.start_byte : call_child.end_byte
153
- ].decode("utf-8")
151
+ func_name = extract_text_from_node(call_child, source_code)
154
152
  elif call_child.type == "arguments":
155
153
  arg_names = _extract_argument_names(call_child, source_code)
156
154
  arity = len(arg_names)
157
155
  break
158
156
  break
159
157
  elif arg_child.type == "identifier":
160
- func_name = source_code[arg_child.start_byte : arg_child.end_byte].decode("utf-8")
158
+ func_name = extract_text_from_node(arg_child, source_code)
161
159
  arity = 0
162
160
  arg_names = []
163
161
  break
@@ -209,9 +207,7 @@ def _extract_guards(arguments_node, source_code: bytes) -> list[str]:
209
207
  elif has_when:
210
208
  # This is the guard expression node (comes after 'when')
211
209
  # It's typically a binary_operator (like n < 0)
212
- guard_expr = source_code[op_child.start_byte : op_child.end_byte].decode(
213
- "utf-8"
214
- )
210
+ guard_expr = extract_text_from_node(op_child, source_code)
215
211
  guards.append(guard_expr)
216
212
  break
217
213