@@ -136,6 +136,110 @@ def add_symbols(self, entity: Entity) -> None:
136136 def is_dependency (self , file_path : str ) -> bool :
137137 return "venv" in file_path
138138
139+ def _module_parts (self , file_path : Path , root : Path ) -> Optional [list [str ]]:
140+ """Dotted module path components for ``file_path`` relative to ``root``."""
141+ try :
142+ rel = file_path .relative_to (root )
143+ except ValueError :
144+ return None
145+ parts = list (rel .with_suffix ('' ).parts )
146+ if parts and parts [- 1 ] == '__init__' :
147+ parts = parts [:- 1 ]
148+ return parts
149+
150+ def build_import_index (self , files : dict [Path , File ], root : Path ) -> object :
151+ """Index in-repo files by dotted module name.
152+
153+ Two maps: ``exact`` keyed by the full dotted path from ``root`` and
154+ ``suffix`` keyed by every trailing sub-path (first file wins). The
155+ suffix map tolerates ``src/``/``lib/`` layouts where the import name
156+ (``matplotlib.axes``) differs from the path-from-root
157+ (``lib.matplotlib.axes``).
158+ """
159+ exact : dict [str , File ] = {}
160+ suffix : dict [str , File ] = {}
161+ for fpath , file in files .items ():
162+ if self .is_dependency (str (fpath )):
163+ continue
164+ parts = self ._module_parts (fpath , root )
165+ if not parts :
166+ continue
167+ exact .setdefault ('.' .join (parts ), file )
168+ for i in range (len (parts )):
169+ suffix .setdefault ('.' .join (parts [i :]), file )
170+ return {'exact' : exact , 'suffix' : suffix }
171+
172+ def _resolve_dotted (self , dotted : str , index : dict ) -> Optional [File ]:
173+ if not dotted :
174+ return None
175+ f = index ['exact' ].get (dotted ) or index ['suffix' ].get (dotted )
176+ if f is None and '.' in dotted :
177+ # imported name may be a symbol inside a module; drop the last part.
178+ parent = dotted .rsplit ('.' , 1 )[0 ]
179+ f = index ['exact' ].get (parent ) or index ['suffix' ].get (parent )
180+ return f
181+
182+ def _import_requests (self , file : File ) -> list [tuple [str , int ]]:
183+ """Extract (dotted, level) resolution requests from import statements."""
184+ requests : list [tuple [str , int ]] = []
185+ captures = self ._captures (
186+ "(import_statement) @i (import_from_statement) @f" ,
187+ file .tree .root_node ,
188+ )
189+ for node in captures .get ('i' , []):
190+ for child in node .named_children :
191+ target = child
192+ if child .type == 'aliased_import' :
193+ target = child .child_by_field_name ('name' )
194+ if target is not None and target .type == 'dotted_name' :
195+ requests .append ((target .text .decode ('utf-8' ), 0 ))
196+ for node in captures .get ('f' , []):
197+ module = node .child_by_field_name ('module_name' )
198+ level = 0
199+ base = ''
200+ if module is not None :
201+ if module .type == 'relative_import' :
202+ prefix = next ((c for c in module .children if c .type == 'import_prefix' ), None )
203+ level = len (prefix .text .decode ('utf-8' )) if prefix is not None else 1
204+ dotted_part = next ((c for c in module .named_children if c .type == 'dotted_name' ), None )
205+ base = dotted_part .text .decode ('utf-8' ) if dotted_part is not None else ''
206+ else :
207+ base = module .text .decode ('utf-8' )
208+ requests .append ((base , level ))
209+ for name_node in node .children_by_field_name ('name' ):
210+ leaf = name_node
211+ if name_node .type == 'aliased_import' :
212+ leaf = name_node .child_by_field_name ('name' )
213+ if leaf is not None :
214+ name_txt = leaf .text .decode ('utf-8' )
215+ requests .append ((f"{ base } .{ name_txt } " if base else name_txt , level ))
216+ return requests
217+
218+ def resolve_imports (self , file : File , root : Path , index : object ) -> list [File ]:
219+ if not index :
220+ return []
221+ package_parts = self ._module_parts (file .path , root )
222+ if package_parts is None :
223+ return []
224+ # Package of the importing file = its parent dotted path.
225+ package_parts = package_parts [:- 1 ] if package_parts else []
226+ seen : set [Path ] = set ()
227+ targets : list [File ] = []
228+ for dotted , level in self ._import_requests (file ):
229+ if level :
230+ base = package_parts [: len (package_parts ) - (level - 1 )] if level > 1 else list (package_parts )
231+ full = '.' .join ([* base , dotted ]) if dotted else '.' .join (base )
232+ else :
233+ full = dotted
234+ resolved = self ._resolve_dotted (full , index )
235+ if resolved is None or resolved .path == file .path or resolved .path in seen :
236+ continue
237+ if self .is_dependency (str (resolved .path )):
238+ continue
239+ seen .add (resolved .path )
240+ targets .append (resolved )
241+ return targets
242+
139243 def _extract_type_target (self , node : Node ) -> Optional [Node ]:
140244 if node .type == 'attribute' :
141245 return node .child_by_field_name ('attribute' )
0 commit comments