rust-analyzer-db 0.2.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.
@@ -0,0 +1,640 @@
1
+ """Extracts Rust code entities using tree-sitter and computes static metrics.
2
+
3
+ Extracts: structs, enums, traits, impls, functions/methods, consts, statics,
4
+ mods, macros, type aliases, unions, use declarations, and extern crate
5
+ declarations. Also computes cyclomatic complexity, line counts, and extracts
6
+ generic parameters and lifetime annotations.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from typing import Optional
14
+
15
+ import tree_sitter_rust as tsrust
16
+ from tree_sitter import Language, Node, Parser
17
+
18
+ from .exceptions import ParseError
19
+ from .logging import get_logger
20
+
21
+ log = get_logger("extractor")
22
+
23
+ RUST_LANGUAGE = Language(tsrust.language())
24
+
25
+ # tree-sitter node types that represent top-level Rust items
26
+ TOP_LEVEL_KINDS: dict[str, str] = {
27
+ "struct_item": "struct",
28
+ "enum_item": "enum",
29
+ "trait_item": "trait",
30
+ "impl_item": "impl",
31
+ "function_item": "function",
32
+ "function_signature_item": "function_sig",
33
+ "const_item": "const",
34
+ "static_item": "static",
35
+ "mod_item": "mod",
36
+ "macro_definition": "macro",
37
+ "type_item": "type_alias",
38
+ "union_item": "union",
39
+ "use_declaration": "use",
40
+ "extern_crate_declaration": "extern_crate",
41
+ }
42
+
43
+ COMMENT_TYPES = frozenset({"line_comment", "block_comment"})
44
+ ATTRIBUTE_TYPES = frozenset({"attribute_item", "inner_attribute_item"})
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Data classes
49
+ # ---------------------------------------------------------------------------
50
+
51
+ @dataclass
52
+ class GenericParam:
53
+ """A single generic type parameter."""
54
+
55
+ name: str
56
+ bounds: list[str] = field(default_factory=list)
57
+ default: Optional[str] = None
58
+
59
+ def to_signature(self) -> str:
60
+ parts = [self.name]
61
+ if self.bounds:
62
+ parts.append(f": {', '.join(self.bounds)}")
63
+ if self.default:
64
+ parts.append(f"= {self.default}")
65
+ return "".join(parts)
66
+
67
+
68
+ @dataclass
69
+ class LifetimeParam:
70
+ """A lifetime parameter (e.g. ``'a``)."""
71
+
72
+ name: str
73
+ bounds: list[str] = field(default_factory=list)
74
+
75
+
76
+ @dataclass
77
+ class ComplexityMetrics:
78
+ """Static metrics computed from a function/method body."""
79
+
80
+ cyclomatic: int = 1
81
+ lines_of_code: int = 0
82
+ cognitive: int = 0
83
+ nesting_depth: int = 0
84
+ num_branches: int = 0
85
+ num_function_calls: int = 0
86
+
87
+
88
+ @dataclass
89
+ class ExtractedItem:
90
+ """A single extracted code entity."""
91
+
92
+ kind: str
93
+ name: str
94
+ start_line: int
95
+ end_line: int
96
+ source: str
97
+ target: Optional[str] = None
98
+ trait_name: Optional[str] = None
99
+ visibility: Optional[str] = None
100
+ signature: Optional[str] = None
101
+ doc: Optional[str] = None
102
+ attributes: Optional[str] = None
103
+ children: list[ExtractedItem] = field(default_factory=list)
104
+ body_node: Optional[Node] = None # transient, not persisted
105
+ # new fields
106
+ generic_params: list[GenericParam] = field(default_factory=list)
107
+ lifetime_params: list[LifetimeParam] = field(default_factory=list)
108
+ complexity: Optional[ComplexityMetrics] = None
109
+ is_pub: bool = False
110
+ is_const_fn: bool = False
111
+ is_async: bool = False
112
+ is_unsafe: bool = False
113
+
114
+
115
+ @dataclass
116
+ class UseDeclaration:
117
+ """A ``use`` statement."""
118
+
119
+ path: str
120
+ alias: Optional[str] = None
121
+ is_glob: bool = False
122
+ start_line: int = 0
123
+ end_line: int = 0
124
+
125
+
126
+ @dataclass
127
+ class ExternCrate:
128
+ """An ``extern crate`` declaration."""
129
+
130
+ name: str
131
+ alias: Optional[str] = None
132
+ start_line: int = 0
133
+ end_line: int = 0
134
+
135
+
136
+ @dataclass
137
+ class CallEdge:
138
+ """A detected function/method call in a body."""
139
+
140
+ callee_name: str
141
+ receiver: Optional[str]
142
+ line: int
143
+ is_method_call: bool
144
+
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # Helpers
148
+ # ---------------------------------------------------------------------------
149
+
150
+ def _text(node: Optional[Node], src: bytes) -> Optional[str]:
151
+ if node is None:
152
+ return None
153
+ return src[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
154
+
155
+
156
+ def _leading_metadata(node: Node, src: bytes) -> tuple[Optional[str], Optional[str]]:
157
+ """Walk backwards over preceding sibling comment/attribute nodes."""
158
+ doc_lines: list[str] = []
159
+ attr_lines: list[str] = []
160
+ collected: list[Node] = []
161
+ sib = node.prev_sibling
162
+ while sib is not None and (sib.type in COMMENT_TYPES or sib.type in ATTRIBUTE_TYPES):
163
+ collected.append(sib)
164
+ sib = sib.prev_sibling
165
+ collected.reverse()
166
+ for n in collected:
167
+ text = _text(n, src)
168
+ if text is None:
169
+ continue
170
+ if n.type in COMMENT_TYPES:
171
+ if text.startswith("///") or text.startswith("//!") or text.startswith("/**"):
172
+ doc_lines.append(text)
173
+ elif n.type in ATTRIBUTE_TYPES:
174
+ attr_lines.append(text)
175
+ doc = "\n".join(doc_lines) if doc_lines else None
176
+ attrs = "\n".join(attr_lines) if attr_lines else None
177
+ return doc, attrs
178
+
179
+
180
+ def _visibility(node: Node, src: bytes) -> Optional[str]:
181
+ for child in node.children:
182
+ if child.type == "visibility_modifier":
183
+ return _text(child, src)
184
+ return None
185
+
186
+
187
+ def _function_signature(node: Node, src: bytes) -> str:
188
+ body = node.child_by_field_name("body")
189
+ end = body.start_byte if body is not None else node.end_byte
190
+ sig = src[node.start_byte:end].decode("utf-8", errors="replace")
191
+ return sig.strip().rstrip("{").strip()
192
+
193
+
194
+ def _has_modifier(node: Node, modifier: str) -> bool:
195
+ """Check if a function node has a modifier like 'async', 'const', 'unsafe'."""
196
+ for child in node.children:
197
+ if child.type == modifier:
198
+ return True
199
+ if child.type == "function_modifiers":
200
+ mod_text = _text(child, None)
201
+ if mod_text and modifier in mod_text:
202
+ return True
203
+ return False
204
+
205
+
206
+ # ---------------------------------------------------------------------------
207
+ # Generic / lifetime extraction
208
+ # ---------------------------------------------------------------------------
209
+
210
+ def _extract_generic_params(node: Node, src: bytes) -> tuple[list[GenericParam], list[LifetimeParam]]:
211
+ """Extract generic type parameters and lifetime parameters from a node's
212
+ optional ``type_parameters`` child (the ``<...>`` block)."""
213
+ generics: list[GenericParam] = []
214
+ lifetimes: list[LifetimeParam] = []
215
+
216
+ params_node = node.child_by_field_name("type_parameters")
217
+ if params_node is None:
218
+ return generics, lifetimes
219
+
220
+ for child in params_node.named_children:
221
+ if child.type == "type_parameter":
222
+ name_node = child.child_by_field_name("name")
223
+ name = _text(name_node, src) or "?"
224
+ bounds: list[str] = []
225
+ default: Optional[str] = None
226
+ for c in child.named_children:
227
+ if c.type == "trait_bounds":
228
+ for bound in c.named_children:
229
+ bound_text = _text(bound, src)
230
+ if bound_text:
231
+ bounds.append(bound_text.strip())
232
+ elif c.type == "type_bound":
233
+ bound_text = _text(c, src)
234
+ if bound_text:
235
+ bounds.append(bound_text.strip())
236
+ elif c.type == "type":
237
+ default = _text(c, src)
238
+ generics.append(GenericParam(name=name, bounds=bounds, default=default))
239
+ elif child.type in ("lifetime", "lifetime_parameter"):
240
+ name = _text(child, src) or "'?"
241
+ lifetimes.append(LifetimeParam(name=name))
242
+ elif child.type == "const_parameter":
243
+ name_node = child.child_by_field_name("name")
244
+ name = _text(name_node, src) or "?"
245
+ generics.append(GenericParam(name=name))
246
+
247
+ return generics, lifetimes
248
+
249
+
250
+ # ---------------------------------------------------------------------------
251
+ # Complexity metrics
252
+ # ---------------------------------------------------------------------------
253
+
254
+ _BRANCH_NODES = frozenset({
255
+ "if_expression",
256
+ "if_statement",
257
+ "match_expression",
258
+ "match_arm",
259
+ "loop_expression",
260
+ "while_expression",
261
+ "for_expression",
262
+ "if_let_expression",
263
+ "while_let_expression",
264
+ })
265
+
266
+ _FUNCTION_CALL_NODES = frozenset({"call_expression", "method_call_expression"})
267
+
268
+
269
+ def _compute_complexity(body_node: Optional[Node], src: bytes) -> ComplexityMetrics:
270
+ """Compute cyclomatic complexity and other metrics for a function body."""
271
+ metrics = ComplexityMetrics()
272
+ if body_node is None:
273
+ return metrics
274
+
275
+ lines = src[body_node.start_byte:body_node.end_byte].decode("utf-8", errors="replace")
276
+ metrics.lines_of_code = lines.count("\n") + 1
277
+
278
+ max_depth = 0
279
+
280
+ def _walk(node: Node, depth: int) -> None:
281
+ nonlocal max_depth
282
+ if depth > max_depth:
283
+ max_depth = depth
284
+ if node.type in _BRANCH_NODES and node.type != "match_arm":
285
+ metrics.cyclomatic += 1
286
+ metrics.num_branches += 1
287
+ if node.type in _FUNCTION_CALL_NODES:
288
+ metrics.num_function_calls += 1
289
+ if node.type == "binary_expression":
290
+ op = node.child_by_field_name("operator")
291
+ if op is not None:
292
+ op_text = _text(op, src) or ""
293
+ if op_text in ("&&", "||"):
294
+ metrics.cyclomatic += 1
295
+ for child in node.children:
296
+ _walk(child, depth + 1)
297
+
298
+ _walk(body_node, 0)
299
+ metrics.nesting_depth = max_depth
300
+ # Cognitive complexity: penalize nesting on top of branching
301
+ metrics.cognitive = metrics.cyclomatic + max_depth
302
+ return metrics
303
+
304
+
305
+ # ---------------------------------------------------------------------------
306
+ # Item extraction
307
+ # ---------------------------------------------------------------------------
308
+
309
+ def _extract_function(node: Node, src: bytes, kind: str) -> ExtractedItem:
310
+ name_node = node.child_by_field_name("name")
311
+ name = _text(name_node, src) or "<anonymous>"
312
+ doc, attrs = _leading_metadata(node, src)
313
+ generics, lifetimes = _extract_generic_params(node, src)
314
+ body_node = node.child_by_field_name("body")
315
+ complexity = _compute_complexity(body_node, src)
316
+ return ExtractedItem(
317
+ kind=kind,
318
+ name=name,
319
+ start_line=node.start_point[0] + 1,
320
+ end_line=node.end_point[0] + 1,
321
+ source=_text(node, src) or "",
322
+ visibility=_visibility(node, src),
323
+ signature=_function_signature(node, src),
324
+ doc=doc,
325
+ attributes=attrs,
326
+ body_node=body_node,
327
+ generic_params=generics,
328
+ lifetime_params=lifetimes,
329
+ complexity=complexity,
330
+ is_pub=_visibility(node, src) is not None,
331
+ is_const_fn=_has_modifier(node, "const"),
332
+ is_async=_has_modifier(node, "async"),
333
+ is_unsafe=_has_modifier(node, "unsafe"),
334
+ )
335
+
336
+
337
+ def _extract_simple(node: Node, src: bytes, kind: str) -> ExtractedItem:
338
+ name_node = node.child_by_field_name("name")
339
+ name = _text(name_node, src) or "<anonymous>"
340
+ doc, attrs = _leading_metadata(node, src)
341
+ generics, lifetimes = _extract_generic_params(node, src)
342
+ return ExtractedItem(
343
+ kind=kind,
344
+ name=name,
345
+ start_line=node.start_point[0] + 1,
346
+ end_line=node.end_point[0] + 1,
347
+ source=_text(node, src) or "",
348
+ visibility=_visibility(node, src),
349
+ doc=doc,
350
+ attributes=attrs,
351
+ generic_params=generics,
352
+ lifetime_params=lifetimes,
353
+ is_pub=_visibility(node, src) is not None,
354
+ )
355
+
356
+
357
+ def _extract_impl(node: Node, src: bytes) -> ExtractedItem:
358
+ type_node = node.child_by_field_name("type")
359
+ trait_node = node.child_by_field_name("trait")
360
+ target = _text(type_node, src) or "<unknown>"
361
+ trait_name = _text(trait_node, src)
362
+ doc, attrs = _leading_metadata(node, src)
363
+ generics, lifetimes = _extract_generic_params(node, src)
364
+ display_name = f"impl {trait_name + ' for ' if trait_name else ''}{target}"
365
+ item = ExtractedItem(
366
+ kind="impl",
367
+ name=display_name,
368
+ start_line=node.start_point[0] + 1,
369
+ end_line=node.end_point[0] + 1,
370
+ source=_text(node, src) or "",
371
+ target=target,
372
+ trait_name=trait_name,
373
+ doc=doc,
374
+ attributes=attrs,
375
+ generic_params=generics,
376
+ lifetime_params=lifetimes,
377
+ )
378
+ body = node.child_by_field_name("body")
379
+ if body is not None:
380
+ for child in body.named_children:
381
+ if child.type in ("function_item", "function_signature_item"):
382
+ method = _extract_function(
383
+ child,
384
+ src,
385
+ "method" if child.type == "function_item" else "method_sig",
386
+ )
387
+ method.target = target
388
+ method.trait_name = trait_name
389
+ item.children.append(method)
390
+ elif child.type == "const_item":
391
+ c = _extract_simple(child, src, "assoc_const")
392
+ c.target = target
393
+ item.children.append(c)
394
+ elif child.type == "type_item":
395
+ t = _extract_simple(child, src, "assoc_type")
396
+ t.target = target
397
+ item.children.append(t)
398
+ return item
399
+
400
+
401
+ def _extract_trait(node: Node, src: bytes) -> ExtractedItem:
402
+ item = _extract_simple(node, src, "trait")
403
+ body = node.child_by_field_name("body")
404
+ if body is not None:
405
+ for child in body.named_children:
406
+ if child.type in ("function_item", "function_signature_item"):
407
+ method = _extract_function(
408
+ child,
409
+ src,
410
+ "method" if child.type == "function_item" else "method_sig",
411
+ )
412
+ method.target = item.name
413
+ item.children.append(method)
414
+ return item
415
+
416
+
417
+ def _extract_mod(node: Node, src: bytes) -> ExtractedItem:
418
+ item = _extract_simple(node, src, "mod")
419
+ body = node.child_by_field_name("body")
420
+ if body is not None:
421
+ for child in body.named_children:
422
+ item.children.extend(_extract_top_level_node(child, src))
423
+ return item
424
+
425
+
426
+ def _extract_top_level_node(node: Node, src: bytes) -> list[ExtractedItem]:
427
+ t = node.type
428
+ if t == "impl_item":
429
+ return [_extract_impl(node, src)]
430
+ if t == "trait_item":
431
+ return [_extract_trait(node, src)]
432
+ if t == "mod_item":
433
+ if node.child_by_field_name("body") is not None:
434
+ return [_extract_mod(node, src)]
435
+ return [_extract_simple(node, src, "mod_decl")]
436
+ if t in ("function_item", "function_signature_item"):
437
+ return [_extract_function(node, src, TOP_LEVEL_KINDS[t])]
438
+ if t in TOP_LEVEL_KINDS:
439
+ return [_extract_simple(node, src, TOP_LEVEL_KINDS[t])]
440
+ return []
441
+
442
+
443
+ # ---------------------------------------------------------------------------
444
+ # Use / extern crate extraction
445
+ # ---------------------------------------------------------------------------
446
+
447
+ def _use_path_text(node: Node, src: bytes) -> str:
448
+ """Recursively extract the full path text from a use_declaration's
449
+ scoped_identifier tree."""
450
+ if node.type == "scoped_identifier":
451
+ path_child = node.child_by_field_name("path")
452
+ name_child = node.child_by_field_name("name")
453
+ if path_child is not None and name_child is not None:
454
+ return _use_path_text(path_child, src) + "::" + (_text(name_child, src) or "")
455
+ return _text(node, src) or ""
456
+ return _text(node, src) or ""
457
+
458
+
459
+ def _extract_use_declarations(tree_root: Node, src: bytes) -> list[UseDeclaration]:
460
+ """Extract all ``use`` declarations from the file."""
461
+ results: list[UseDeclaration] = []
462
+
463
+ def _walk(node: Node) -> None:
464
+ if node.type == "use_declaration":
465
+ # The child is typically a scoped_identifier or use_wildcard
466
+ inner = None
467
+ for child in node.named_children:
468
+ if child.type in ("scoped_identifier", "use_wildcard", "use_as_clause", "identifier"):
469
+ inner = child
470
+ break
471
+ if inner is None:
472
+ for child in node.children:
473
+ if child.is_named:
474
+ inner = child
475
+ break
476
+
477
+ text = ""
478
+ is_glob = False
479
+ alias: Optional[str] = None
480
+
481
+ if inner is not None:
482
+ if inner.type == "use_as_clause":
483
+ alias_node = inner.child_by_field_name("alias")
484
+ alias = _text(alias_node, src)
485
+ # The path part is the first named child
486
+ for child in inner.named_children:
487
+ if child != alias_node:
488
+ text = _use_path_text(child, src)
489
+ break
490
+ elif inner.type == "use_wildcard":
491
+ text = _use_path_text(inner, src)
492
+ is_glob = True
493
+ else:
494
+ text = _use_path_text(inner, src)
495
+
496
+ results.append(UseDeclaration(
497
+ path=text,
498
+ alias=alias,
499
+ is_glob=is_glob,
500
+ start_line=node.start_point[0] + 1,
501
+ end_line=node.end_point[0] + 1,
502
+ ))
503
+ return
504
+ for child in node.children:
505
+ _walk(child)
506
+
507
+ _walk(tree_root)
508
+ return results
509
+
510
+
511
+ def _extract_extern_crates(tree_root: Node, src: bytes) -> list[ExternCrate]:
512
+ """Extract all ``extern crate`` declarations."""
513
+ results: list[ExternCrate] = []
514
+ for node in tree_root.named_children:
515
+ if node.type == "extern_crate_declaration":
516
+ name_node = node.child_by_field_name("name")
517
+ name = _text(name_node, src) or "?"
518
+ alias: Optional[str] = None
519
+ for child in node.named_children:
520
+ if child.type == "identifier" and child != name_node:
521
+ alias = _text(child, src)
522
+ results.append(ExternCrate(
523
+ name=name,
524
+ alias=alias,
525
+ start_line=node.start_point[0] + 1,
526
+ end_line=node.end_point[0] + 1,
527
+ ))
528
+ return results
529
+
530
+
531
+ # ---------------------------------------------------------------------------
532
+ # Call extraction
533
+ # ---------------------------------------------------------------------------
534
+
535
+ def _call_target_name(fn_node: Node, src: bytes) -> Optional[tuple[str, Optional[str], bool]]:
536
+ """Given the ``function`` child of a ``call_expression``, return
537
+ ``(callee_name, receiver, is_method_call)`` or ``None``."""
538
+ if fn_node.type == "identifier":
539
+ return _text(fn_node, src), None, False
540
+ if fn_node.type == "field_expression":
541
+ field_node = fn_node.child_by_field_name("field")
542
+ value = fn_node.child_by_field_name("value")
543
+ name = _text(field_node, src)
544
+ receiver = _text(value, src) if value is not None else None
545
+ if receiver and len(receiver) > 40:
546
+ receiver = receiver[:37] + "..."
547
+ return name, receiver, True
548
+ if fn_node.type == "scoped_identifier":
549
+ path = fn_node.child_by_field_name("path")
550
+ name_node = fn_node.child_by_field_name("name")
551
+ name = _text(name_node, src)
552
+ receiver = _text(path, src) if path is not None else None
553
+ return name, receiver, False
554
+ return None
555
+
556
+
557
+ def extract_calls(body_node: Node, src: bytes) -> list[CallEdge]:
558
+ """Walk a function/method body and collect all direct call_expression
559
+ targets, without recursing into nested function/closure definitions."""
560
+ edges: list[CallEdge] = []
561
+
562
+ def _walk(node: Node) -> None:
563
+ if node.type == "call_expression":
564
+ fn_node = node.child_by_field_name("function")
565
+ resolved = _call_target_name(fn_node, src) if fn_node is not None else None
566
+ if resolved is not None:
567
+ name, receiver, is_method = resolved
568
+ edges.append(CallEdge(
569
+ callee_name=name,
570
+ receiver=receiver,
571
+ line=node.start_point[0] + 1,
572
+ is_method_call=is_method,
573
+ ))
574
+ if node.type in TOP_LEVEL_KINDS:
575
+ return
576
+ for child in node.children:
577
+ _walk(child)
578
+
579
+ _walk(body_node)
580
+ return edges
581
+
582
+
583
+ # ---------------------------------------------------------------------------
584
+ # Public API
585
+ # ---------------------------------------------------------------------------
586
+
587
+ @dataclass
588
+ class FileExtraction:
589
+ """Complete extraction result for a single Rust source file."""
590
+
591
+ items: list[ExtractedItem]
592
+ use_declarations: list[UseDeclaration]
593
+ extern_crates: list[ExternCrate]
594
+ total_lines: int = 0
595
+
596
+
597
+ def extract_file(source_code: bytes) -> FileExtraction:
598
+ """Parse a Rust source file and extract all entities and metadata.
599
+
600
+ Returns a ``FileExtraction`` containing items, use declarations,
601
+ extern crate declarations, and total line count.
602
+
603
+ Raises:
604
+ ParseError: If tree-sitter fails to parse the source.
605
+ """
606
+ parser = Parser(RUST_LANGUAGE)
607
+ tree = parser.parse(source_code)
608
+
609
+ if tree.root_node.has_error:
610
+ error_nodes = _find_error_nodes(tree.root_node)
611
+ detail = "; ".join(
612
+ f"line {n.start_point[0]+1}: {n.type}"
613
+ for n in error_nodes[:3]
614
+ )
615
+ log.warning("Parse tree has errors: %s", detail)
616
+
617
+ items: list[ExtractedItem] = []
618
+ for child in tree.root_node.named_children:
619
+ items.extend(_extract_top_level_node(child, source_code))
620
+
621
+ uses = _extract_use_declarations(tree.root_node, source_code)
622
+ externs = _extract_extern_crates(tree.root_node, source_code)
623
+ total_lines = source_code.count(b"\n") + 1
624
+
625
+ return FileExtraction(
626
+ items=items,
627
+ use_declarations=uses,
628
+ extern_crates=externs,
629
+ total_lines=total_lines,
630
+ )
631
+
632
+
633
+ def _find_error_nodes(node: Node) -> list[Node]:
634
+ """Collect all ERROR/MISSING nodes in the tree."""
635
+ errors: list[Node] = []
636
+ if node.type in ("ERROR", "MISSING"):
637
+ errors.append(node)
638
+ for child in node.children:
639
+ errors.extend(_find_error_nodes(child))
640
+ return errors