thread_flow/incremental/extractors/rust.rs
1// SPDX-FileCopyrightText: 2025 Knitli Inc. <knitli@knit.li>
2// SPDX-License-Identifier: AGPL-3.0-or-later
3
4//! Rust dependency extractor using tree-sitter AST traversal.
5//!
6//! Extracts `use` declarations and `pub use` re-exports from Rust source files,
7//! producing [`RustImportInfo`] and [`ExportInfo`] records for the dependency
8//! graph. Supports:
9//!
10//! - Simple imports: `use std::collections::HashMap;`
11//! - Nested imports: `use std::collections::{HashMap, HashSet};`
12//! - Wildcard imports: `use module::*;`
13//! - Aliased imports: `use std::io::Result as IoResult;`
14//! - Crate-relative: `use crate::core::Engine;`
15//! - Super-relative: `use super::utils;`
16//! - Self-relative: `use self::types::Config;`
17//! - Re-exports: `pub use types::Config;`, `pub(crate) use internal::Helper;`
18//!
19//! # Examples
20//!
21//! ```rust,ignore
22//! use thread_flow::incremental::extractors::rust::RustDependencyExtractor;
23//! use std::path::Path;
24//!
25//! let extractor = RustDependencyExtractor::new();
26//! let source = "use std::collections::HashMap;\nuse crate::config::Settings;";
27//! let imports = extractor.extract_imports(source, Path::new("src/main.rs")).unwrap();
28//! assert_eq!(imports.len(), 2);
29//! ```
30//!
31//! # Performance
32//!
33//! Target: <5ms per file extraction. Tree-sitter parsing and AST traversal
34//! operate in a single pass without backtracking.
35
36use std::path::{Path, PathBuf};
37
38/// Errors that can occur during Rust dependency extraction.
39#[derive(Debug, thiserror::Error)]
40pub enum ExtractionError {
41 /// Tree-sitter failed to parse the Rust source file.
42 #[error("parse error: failed to parse Rust source")]
43 ParseError,
44
45 /// Module path could not be resolved to a local file path.
46 #[error("unresolved module: {module} from {source_file}: {reason}")]
47 ResolutionError {
48 /// The module path that could not be resolved.
49 module: String,
50 /// The source file containing the use statement.
51 source_file: PathBuf,
52 /// The reason resolution failed.
53 reason: String,
54 },
55}
56
57/// Visibility level of a Rust re-export (`pub use`).
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
59pub enum Visibility {
60 /// `pub use` -- visible to all.
61 Public,
62 /// `pub(crate) use` -- visible within the crate.
63 Crate,
64 /// `pub(super) use` -- visible to the parent module.
65 Super,
66 /// `pub(in path) use` -- visible to a specific path.
67 Restricted,
68}
69
70/// Information extracted from a single Rust `use` declaration.
71///
72/// Represents the parsed form of a `use` statement. The coordinator (Task 3.5)
73/// converts these into [`DependencyEdge`](crate::incremental::types::DependencyEdge)
74/// entries.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct RustImportInfo {
77 /// The module path as written in the source code, excluding the final
78 /// symbol(s).
79 ///
80 /// For `use std::collections::HashMap` this is `"std::collections"`.
81 /// For `use crate::config::Settings` this is `"crate::config"`.
82 /// For `use serde;` (bare crate import) this is `"serde"`.
83 pub module_path: String,
84
85 /// Specific symbols imported from the module.
86 ///
87 /// Contains `["HashMap"]` for `use std::collections::HashMap`.
88 /// Contains `["HashMap", "HashSet"]` for `use std::collections::{HashMap, HashSet}`.
89 /// Empty for bare imports like `use serde;` or wildcard imports.
90 pub symbols: Vec<String>,
91
92 /// Whether this is a wildcard import (`use module::*`).
93 pub is_wildcard: bool,
94
95 /// Aliases for imported names.
96 ///
97 /// Maps original name to alias. For `use std::io::Result as IoResult`,
98 /// contains `[("Result", "IoResult")]`.
99 pub aliases: Vec<(String, String)>,
100}
101
102/// Information extracted from a Rust `pub use` re-export.
103///
104/// Represents a single re-exported symbol. For `pub use types::{Config, Settings}`,
105/// two `ExportInfo` records are produced.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct ExportInfo {
108 /// The name of the re-exported symbol.
109 ///
110 /// For `pub use types::Config` this is `"Config"`.
111 /// For `pub use module::*` this is `"*"`.
112 pub symbol_name: String,
113
114 /// The source module path of the re-export.
115 ///
116 /// For `pub use types::Config` this is `"types"`.
117 pub module_path: String,
118
119 /// The visibility level of this re-export.
120 pub visibility: Visibility,
121}
122
123/// Extracts Rust import and export dependencies using tree-sitter AST traversal.
124///
125/// Uses tree-sitter's Rust grammar to parse `use` and `pub use` declarations
126/// without executing Rust code. Thread-safe and reusable across files.
127///
128/// # Architecture
129///
130/// The extractor operates in two phases:
131/// 1. **Parse**: Tree-sitter parses the source into an AST
132/// 2. **Walk**: Recursive traversal extracts `use_declaration` nodes and their
133/// nested structure (scoped identifiers, use lists, wildcards, aliases)
134///
135/// Module path resolution (converting `"crate::config"` to `"src/config.rs"`)
136/// is handled separately by [`resolve_module_path`](Self::resolve_module_path).
137pub struct RustDependencyExtractor {
138 _private: (),
139}
140
141impl RustDependencyExtractor {
142 /// Creates a new Rust dependency extractor.
143 pub fn new() -> Self {
144 Self { _private: () }
145 }
146
147 /// Parse Rust source code into a tree-sitter tree.
148 fn parse_source(source: &str) -> Result<tree_sitter::Tree, ExtractionError> {
149 let language = thread_language::parsers::language_rust();
150 let mut parser = tree_sitter::Parser::new();
151 parser
152 .set_language(&language)
153 .map_err(|_| ExtractionError::ParseError)?;
154 parser
155 .parse(source, None)
156 .ok_or(ExtractionError::ParseError)
157 }
158
159 /// Extracts all `use` declarations from Rust source code.
160 ///
161 /// Parses the source with tree-sitter and walks the AST to find all
162 /// `use_declaration` nodes. Both public and private `use` statements are
163 /// returned as imports (the caller may filter by visibility if needed).
164 ///
165 /// # Arguments
166 ///
167 /// * `source` - Rust source code to analyze.
168 /// * `_file_path` - Path of the source file (reserved for error context).
169 ///
170 /// # Returns
171 ///
172 /// A vector of [`RustImportInfo`] records, one per `use` declaration.
173 ///
174 /// # Errors
175 ///
176 /// Returns [`ExtractionError::ParseError`] if tree-sitter cannot parse
177 /// the source.
178 pub fn extract_imports(
179 &self,
180 source: &str,
181 _file_path: &Path,
182 ) -> Result<Vec<RustImportInfo>, ExtractionError> {
183 if source.is_empty() {
184 return Ok(Vec::new());
185 }
186
187 let tree = Self::parse_source(source)?;
188 let root = tree.root_node();
189 let src = source.as_bytes();
190 let mut imports = Vec::new();
191
192 self.walk_use_declarations(root, src, &mut imports);
193 self.walk_mod_declarations(root, src, &mut imports);
194
195 Ok(imports)
196 }
197
198 /// Extracts all `pub use` re-export declarations from Rust source code.
199 ///
200 /// Only public or restricted-visibility `use` statements are returned.
201 ///
202 /// # Arguments
203 ///
204 /// * `source` - Rust source code to analyze.
205 /// * `_file_path` - Path of the source file (reserved for error context).
206 ///
207 /// # Returns
208 ///
209 /// A vector of [`ExportInfo`] records, one per re-exported symbol.
210 /// For `pub use types::{Config, Settings}`, two records are returned.
211 ///
212 /// # Errors
213 ///
214 /// Returns [`ExtractionError::ParseError`] if tree-sitter cannot parse
215 /// the source.
216 pub fn extract_exports(
217 &self,
218 source: &str,
219 _file_path: &Path,
220 ) -> Result<Vec<ExportInfo>, ExtractionError> {
221 if source.is_empty() {
222 return Ok(Vec::new());
223 }
224
225 let tree = Self::parse_source(source)?;
226 let root = tree.root_node();
227 let src = source.as_bytes();
228 let mut exports = Vec::new();
229
230 self.walk_export_declarations(root, src, &mut exports);
231
232 Ok(exports)
233 }
234
235 /// Resolves a Rust module path to a filesystem path.
236 ///
237 /// Handles the three Rust-specific path prefixes:
238 /// - `crate::` - resolves from the `src/` root of the crate
239 /// - `super::` - resolves from the parent module directory
240 /// - `self::` - resolves from the current module directory
241 ///
242 /// External crate paths (e.g., `std::collections`) cannot be resolved
243 /// to local files and return an error.
244 ///
245 /// # Arguments
246 ///
247 /// * `source_file` - The file containing the `use` statement.
248 /// * `module_path` - The module path (e.g., `"crate::config"`, `"super::utils"`).
249 ///
250 /// # Returns
251 ///
252 /// The resolved filesystem path to the target module file (e.g., `src/config.rs`).
253 ///
254 /// # Errors
255 ///
256 /// Returns [`ExtractionError::ResolutionError`] if:
257 /// - The path is an external crate (no `crate::`, `super::`, or `self::` prefix)
258 /// - The source file has no parent directory for `super::` resolution
259 pub fn resolve_module_path(
260 &self,
261 source_file: &Path,
262 module_path: &str,
263 ) -> Result<PathBuf, ExtractionError> {
264 if let Some(rest) = module_path.strip_prefix("crate::") {
265 // crate:: resolves from src/ root
266 let relative = rest.replace("::", "/");
267 return Ok(PathBuf::from(format!("src/{relative}.rs")));
268 }
269
270 if let Some(rest) = module_path.strip_prefix("super::") {
271 // super:: resolves relative to the parent module
272 let super_dir = self.super_directory(source_file)?;
273 let relative = rest.replace("::", "/");
274 return Ok(super_dir.join(format!("{relative}.rs")));
275 }
276
277 if module_path == "super" {
278 // Bare `super` -- resolve to the parent module itself
279 let super_dir = self.super_directory(source_file)?;
280 return Ok(super_dir.join("mod.rs"));
281 }
282
283 if let Some(rest) = module_path.strip_prefix("self::") {
284 // self:: resolves from current module directory
285 let dir = self.module_directory(source_file)?;
286 let relative = rest.replace("::", "/");
287 return Ok(dir.join(format!("{relative}.rs")));
288 }
289
290 // Simple module name without prefix (e.g., `mod lib;` in main.rs)
291 // Resolves to sibling file (lib.rs) or directory module (lib/mod.rs)
292 if !module_path.contains("::") && !module_path.is_empty() {
293 let dir = self.module_directory(source_file)?;
294 // Return sibling file path (lib.rs)
295 // Note: Could also be lib/mod.rs, but we prefer the simpler form
296 return Ok(dir.join(format!("{module_path}.rs")));
297 }
298
299 // External crate -- cannot resolve to local file
300 Err(ExtractionError::ResolutionError {
301 module: module_path.to_string(),
302 source_file: source_file.to_path_buf(),
303 reason: "external crate path cannot be resolved to a local file".to_string(),
304 })
305 }
306
307 /// Extract [`DependencyEdge`] values from a Rust source file.
308 ///
309 /// Combines import extraction with path resolution to produce edges
310 /// suitable for the incremental dependency graph. Only resolvable
311 /// internal imports produce edges; external crates are silently skipped.
312 ///
313 /// # Errors
314 ///
315 /// Returns an error if the source file cannot be parsed.
316 pub fn extract_dependency_edges(
317 &self,
318 source: &str,
319 file_path: &Path,
320 ) -> Result<Vec<super::super::types::DependencyEdge>, ExtractionError> {
321 let imports = self.extract_imports(source, file_path)?;
322 let mut edges = Vec::new();
323
324 for import in &imports {
325 // Only create edges for resolvable module paths
326 // External crates are silently skipped per design spec
327 if let Ok(resolved) = self.resolve_module_path(file_path, &import.module_path) {
328 // Create symbol-level tracking if specific symbols are imported
329 let symbol = if !import.symbols.is_empty() && !import.is_wildcard {
330 // For now, track the first symbol (could be enhanced to create multiple edges)
331 Some(super::super::types::SymbolDependency {
332 from_symbol: import.symbols[0].clone(),
333 to_symbol: import.symbols[0].clone(),
334 kind: super::super::types::SymbolKind::Module,
335 strength: super::super::types::DependencyStrength::Strong,
336 })
337 } else {
338 None
339 };
340
341 let mut edge = super::super::types::DependencyEdge::new(
342 file_path.to_path_buf(),
343 resolved,
344 super::super::types::DependencyType::Import,
345 );
346 edge.symbol = symbol;
347
348 edges.push(edge);
349 }
350 }
351
352 Ok(edges)
353 }
354
355 /// Determine the module directory for a source file.
356 ///
357 /// For `mod.rs` or `lib.rs`, the module *is* the directory (these files
358 /// define the module that contains sibling files). So `self::` resolves
359 /// to the same directory and `super::` resolves to the parent directory.
360 ///
361 /// For regular files like `auth.rs`, the file is a leaf module. Its parent
362 /// module is the directory it lives in. So `self::` is meaningless (leaf
363 /// modules have no children), and `super::` resolves to the same directory
364 /// (siblings in the parent module).
365 fn module_directory(&self, source_file: &Path) -> Result<PathBuf, ExtractionError> {
366 source_file
367 .parent()
368 .map(|p| p.to_path_buf())
369 .ok_or_else(|| ExtractionError::ResolutionError {
370 module: String::new(),
371 source_file: source_file.to_path_buf(),
372 reason: "source file has no parent directory".to_string(),
373 })
374 }
375
376 /// Check if a source file is a module root (`mod.rs` or `lib.rs`).
377 ///
378 /// Module root files define a module that owns the directory, so `super::`
379 /// from these files goes up one directory level.
380 fn is_module_root(source_file: &Path) -> bool {
381 source_file
382 .file_name()
383 .map(|f| f == "mod.rs" || f == "lib.rs")
384 .unwrap_or(false)
385 }
386
387 /// Determine the directory that `super::` resolves to.
388 ///
389 /// - For `mod.rs`/`lib.rs`: `super::` goes to the parent directory.
390 /// - For regular files (e.g., `auth.rs`): `super::` stays in the same
391 /// directory (siblings in the parent module).
392 fn super_directory(&self, source_file: &Path) -> Result<PathBuf, ExtractionError> {
393 let dir = self.module_directory(source_file)?;
394 if Self::is_module_root(source_file) {
395 // mod.rs/lib.rs: super is the parent directory
396 dir.parent()
397 .map(|p| p.to_path_buf())
398 .ok_or_else(|| ExtractionError::ResolutionError {
399 module: String::new(),
400 source_file: source_file.to_path_buf(),
401 reason: "no parent directory for super resolution from module root".to_string(),
402 })
403 } else {
404 // Regular file: super is the same directory (parent module)
405 Ok(dir)
406 }
407 }
408
409 // =========================================================================
410 // Import extraction (private helpers)
411 // =========================================================================
412
413 /// Walk the AST looking for `use_declaration` nodes and extract import info.
414 fn walk_use_declarations(
415 &self,
416 node: tree_sitter::Node<'_>,
417 source: &[u8],
418 imports: &mut Vec<RustImportInfo>,
419 ) {
420 if node.kind() == "use_declaration" {
421 self.extract_use_declaration(node, source, imports);
422 return;
423 }
424
425 let mut cursor = node.walk();
426 for child in node.children(&mut cursor) {
427 self.walk_use_declarations(child, source, imports);
428 }
429 }
430
431 /// Walk the AST looking for `mod_item` nodes and extract module dependencies.
432 ///
433 /// Extracts `mod foo;` declarations which create module dependencies.
434 /// Note: This extracts declarations like `mod lib;`, not inline modules `mod lib { ... }`.
435 fn walk_mod_declarations(
436 &self,
437 node: tree_sitter::Node<'_>,
438 source: &[u8],
439 imports: &mut Vec<RustImportInfo>,
440 ) {
441 if node.kind() == "mod_item" {
442 self.extract_mod_declaration(node, source, imports);
443 return;
444 }
445
446 let mut cursor = node.walk();
447 for child in node.children(&mut cursor) {
448 self.walk_mod_declarations(child, source, imports);
449 }
450 }
451
452 /// Extract module dependency from a `mod_item` node.
453 ///
454 /// Handles: `mod foo;` (external module file)
455 /// Skips: `mod foo { ... }` (inline module - no file dependency)
456 fn extract_mod_declaration(
457 &self,
458 node: tree_sitter::Node<'_>,
459 source: &[u8],
460 imports: &mut Vec<RustImportInfo>,
461 ) {
462 // Check if this is an external module (has semicolon) vs inline (has block)
463 let has_block = node
464 .children(&mut node.walk())
465 .any(|c| c.kind() == "declaration_list");
466 if has_block {
467 // Inline module - no file dependency
468 return;
469 }
470
471 // Extract module name
472 let mut cursor = node.walk();
473 for child in node.children(&mut cursor) {
474 if child.kind() == "identifier" {
475 if let Ok(name) = child.utf8_text(source) {
476 // Create import info for module dependency
477 imports.push(RustImportInfo {
478 module_path: name.to_string(),
479 symbols: Vec::new(),
480 is_wildcard: false,
481 aliases: Vec::new(),
482 });
483 }
484 return;
485 }
486 }
487 }
488
489 /// Extract import info from a single `use_declaration` node.
490 ///
491 /// Tree-sitter Rust grammar for `use_declaration`:
492 /// ```text
493 /// use_declaration -> visibility_modifier? "use" use_clause ";"
494 /// use_clause -> scoped_identifier | identifier | use_as_clause
495 /// | scoped_use_list | use_wildcard | use_list
496 /// ```
497 fn extract_use_declaration(
498 &self,
499 node: tree_sitter::Node<'_>,
500 source: &[u8],
501 imports: &mut Vec<RustImportInfo>,
502 ) {
503 let mut cursor = node.walk();
504 for child in node.children(&mut cursor) {
505 match child.kind() {
506 "scoped_identifier" | "scoped_use_list" | "use_as_clause" | "use_wildcard"
507 | "use_list" | "identifier" => {
508 let mut info = RustImportInfo {
509 module_path: String::new(),
510 symbols: Vec::new(),
511 is_wildcard: false,
512 aliases: Vec::new(),
513 };
514 self.extract_use_clause(child, source, &mut info);
515 imports.push(info);
516 }
517 _ => {}
518 }
519 }
520 }
521
522 /// Extract use clause details into a [`RustImportInfo`].
523 ///
524 /// Dispatches based on the node kind to handle all Rust use syntax variants.
525 fn extract_use_clause(
526 &self,
527 node: tree_sitter::Node<'_>,
528 source: &[u8],
529 info: &mut RustImportInfo,
530 ) {
531 match node.kind() {
532 "identifier" => {
533 // Bare import: `use serde;`
534 info.module_path = self.node_text(node, source);
535 }
536 "scoped_identifier" => {
537 // `use std::collections::HashMap;`
538 // Split into path (all but last) and name (last identifier)
539 let full_path = self.node_text(node, source);
540 if let Some((path, symbol)) = full_path.rsplit_once("::") {
541 info.module_path = path.to_string();
542 info.symbols.push(symbol.to_string());
543 } else {
544 info.module_path = full_path;
545 }
546 }
547 "use_as_clause" => {
548 self.extract_use_as_clause(node, source, info);
549 }
550 "scoped_use_list" => {
551 self.extract_scoped_use_list(node, source, info);
552 }
553 "use_wildcard" => {
554 self.extract_use_wildcard(node, source, info);
555 }
556 "use_list" => {
557 self.extract_use_list(node, source, info);
558 }
559 _ => {}
560 }
561 }
562
563 /// Extract a `use_as_clause` node: `path as alias`.
564 fn extract_use_as_clause(
565 &self,
566 node: tree_sitter::Node<'_>,
567 source: &[u8],
568 info: &mut RustImportInfo,
569 ) {
570 let mut cursor = node.walk();
571 let children: Vec<_> = node
572 .children(&mut cursor)
573 .filter(|c| c.is_named())
574 .collect();
575
576 // Structure: use_as_clause -> path "as" alias
577 // Named children: [scoped_identifier|identifier, identifier(alias)]
578 if children.len() >= 2 {
579 let path_node = children[0];
580 let alias_node = children[children.len() - 1];
581
582 let full_path = self.node_text(path_node, source);
583 let alias = self.node_text(alias_node, source);
584
585 if let Some((path, symbol)) = full_path.rsplit_once("::") {
586 info.module_path = path.to_string();
587 info.symbols.push(symbol.to_string());
588 info.aliases.push((symbol.to_string(), alias));
589 } else {
590 // `use serde as s;`
591 info.module_path = full_path.clone();
592 info.aliases.push((full_path, alias));
593 }
594 }
595 }
596
597 /// Extract a `scoped_use_list` node: `path::{items}`.
598 fn extract_scoped_use_list(
599 &self,
600 node: tree_sitter::Node<'_>,
601 source: &[u8],
602 info: &mut RustImportInfo,
603 ) {
604 let mut cursor = node.walk();
605 for child in node.children(&mut cursor) {
606 match child.kind() {
607 "identifier" | "scoped_identifier" | "self" | "crate" | "super" => {
608 info.module_path = self.node_text(child, source);
609 }
610 "use_list" => {
611 self.extract_use_list(child, source, info);
612 }
613 _ => {}
614 }
615 }
616 }
617
618 /// Extract items from a `use_list` node: `{Item1, Item2, ...}`.
619 fn extract_use_list(
620 &self,
621 node: tree_sitter::Node<'_>,
622 source: &[u8],
623 info: &mut RustImportInfo,
624 ) {
625 let mut cursor = node.walk();
626 for child in node.children(&mut cursor) {
627 match child.kind() {
628 "identifier" => {
629 info.symbols.push(self.node_text(child, source));
630 }
631 "use_as_clause" => {
632 // `HashMap as Map` inside a use list
633 let mut inner_cursor = child.walk();
634 let named: Vec<_> = child
635 .children(&mut inner_cursor)
636 .filter(|c| c.is_named())
637 .collect();
638 if named.len() >= 2 {
639 let original = self.node_text(named[0], source);
640 let alias = self.node_text(named[named.len() - 1], source);
641 info.symbols.push(original.clone());
642 info.aliases.push((original, alias));
643 }
644 }
645 "self" => {
646 info.symbols.push("self".to_string());
647 }
648 "use_wildcard" => {
649 info.is_wildcard = true;
650 }
651 _ => {}
652 }
653 }
654 }
655
656 /// Extract a `use_wildcard` node: `path::*`.
657 fn extract_use_wildcard(
658 &self,
659 node: tree_sitter::Node<'_>,
660 source: &[u8],
661 info: &mut RustImportInfo,
662 ) {
663 info.is_wildcard = true;
664 let mut cursor = node.walk();
665 for child in node.children(&mut cursor) {
666 if child.kind() == "identifier" || child.kind() == "scoped_identifier" {
667 info.module_path = self.node_text(child, source);
668 }
669 }
670 }
671
672 // =========================================================================
673 // Export extraction (private helpers)
674 // =========================================================================
675
676 /// Walk the AST looking for `pub use` declarations and extract export info.
677 fn walk_export_declarations(
678 &self,
679 node: tree_sitter::Node<'_>,
680 source: &[u8],
681 exports: &mut Vec<ExportInfo>,
682 ) {
683 if node.kind() == "use_declaration" {
684 if let Some(vis) = self.get_visibility(node, source) {
685 self.extract_export_from_use(node, source, vis, exports);
686 }
687 return;
688 }
689
690 let mut cursor = node.walk();
691 for child in node.children(&mut cursor) {
692 self.walk_export_declarations(child, source, exports);
693 }
694 }
695
696 /// Check if a `use_declaration` has a visibility modifier.
697 /// Returns `Some(Visibility)` for pub/pub(crate)/pub(super)/pub(in ...).
698 fn get_visibility(&self, node: tree_sitter::Node<'_>, source: &[u8]) -> Option<Visibility> {
699 let mut cursor = node.walk();
700 for child in node.children(&mut cursor) {
701 if child.kind() == "visibility_modifier" {
702 let text = self.node_text(child, source);
703 return Some(self.parse_visibility(&text));
704 }
705 }
706 None
707 }
708
709 /// Parse a visibility modifier string into a [`Visibility`] enum value.
710 fn parse_visibility(&self, text: &str) -> Visibility {
711 let trimmed = text.trim();
712 if trimmed == "pub" {
713 Visibility::Public
714 } else if trimmed.starts_with("pub(crate)") {
715 Visibility::Crate
716 } else if trimmed.starts_with("pub(super)") {
717 Visibility::Super
718 } else if trimmed.starts_with("pub(in") {
719 Visibility::Restricted
720 } else {
721 Visibility::Public
722 }
723 }
724
725 /// Extract export info from a `pub use` declaration.
726 fn extract_export_from_use(
727 &self,
728 node: tree_sitter::Node<'_>,
729 source: &[u8],
730 visibility: Visibility,
731 exports: &mut Vec<ExportInfo>,
732 ) {
733 let mut cursor = node.walk();
734 for child in node.children(&mut cursor) {
735 match child.kind() {
736 "scoped_identifier" => {
737 let full = self.node_text(child, source);
738 if let Some((path, symbol)) = full.rsplit_once("::") {
739 exports.push(ExportInfo {
740 symbol_name: symbol.to_string(),
741 module_path: path.to_string(),
742 visibility,
743 });
744 }
745 }
746 "scoped_use_list" => {
747 let mut module_path = String::new();
748 let mut symbols = Vec::new();
749
750 let mut inner_cursor = child.walk();
751 for inner in child.children(&mut inner_cursor) {
752 match inner.kind() {
753 "identifier" | "scoped_identifier" => {
754 module_path = self.node_text(inner, source);
755 }
756 "use_list" => {
757 let mut list_cursor = inner.walk();
758 for item in inner.children(&mut list_cursor) {
759 if item.kind() == "identifier" {
760 symbols.push(self.node_text(item, source));
761 }
762 }
763 }
764 _ => {}
765 }
766 }
767
768 for sym in symbols {
769 exports.push(ExportInfo {
770 symbol_name: sym,
771 module_path: module_path.clone(),
772 visibility,
773 });
774 }
775 }
776 "use_wildcard" => {
777 let mut module_path = String::new();
778 let mut wc_cursor = child.walk();
779 for wc_child in child.children(&mut wc_cursor) {
780 if wc_child.kind() == "identifier" || wc_child.kind() == "scoped_identifier"
781 {
782 module_path = self.node_text(wc_child, source);
783 }
784 }
785 exports.push(ExportInfo {
786 symbol_name: "*".to_string(),
787 module_path,
788 visibility,
789 });
790 }
791 "use_as_clause" => {
792 let mut inner_cursor = child.walk();
793 let named: Vec<_> = child
794 .children(&mut inner_cursor)
795 .filter(|c| c.is_named())
796 .collect();
797 if !named.is_empty() {
798 let full = self.node_text(named[0], source);
799 if let Some((path, symbol)) = full.rsplit_once("::") {
800 exports.push(ExportInfo {
801 symbol_name: symbol.to_string(),
802 module_path: path.to_string(),
803 visibility,
804 });
805 }
806 }
807 }
808 "identifier" => {
809 let name = self.node_text(child, source);
810 exports.push(ExportInfo {
811 symbol_name: name.clone(),
812 module_path: name,
813 visibility,
814 });
815 }
816 _ => {}
817 }
818 }
819 }
820
821 // =========================================================================
822 // Utility helpers
823 // =========================================================================
824
825 /// Get the UTF-8 text of a tree-sitter node.
826 fn node_text(&self, node: tree_sitter::Node<'_>, source: &[u8]) -> String {
827 node.utf8_text(source).unwrap_or("").to_string()
828 }
829}
830
831impl Default for RustDependencyExtractor {
832 fn default() -> Self {
833 Self::new()
834 }
835}
836
837#[cfg(test)]
838mod tests {
839 use super::*;
840
841 /// Verify AST node kinds for Rust use declarations to validate grammar assumptions.
842 #[test]
843 fn verify_ast_structure() {
844 let source = "use std::collections::HashMap;";
845 let tree = RustDependencyExtractor::parse_source(source).unwrap();
846 let root = tree.root_node();
847 assert_eq!(root.kind(), "source_file");
848 let use_decl = root.child(0).unwrap();
849 assert_eq!(use_decl.kind(), "use_declaration");
850 }
851}