@@ -30,6 +30,94 @@ def _comment_replace(match: re.Match) -> str:
3030 return token
3131 src_clean = comment_string_pattern .sub (_comment_replace , src )
3232
33+ # Dart part files can point to their parent library through package: URIs.
34+ # Resolve those through the nearest pubspec.yaml so part declarations use
35+ # the parent library stem instead of a machine-local part-file path.
36+ def _find_package_root (start : Path ) -> tuple [Path , str ] | None :
37+ for parent in [start , * start .parents ]:
38+ pubspec = parent / "pubspec.yaml"
39+ if not pubspec .exists ():
40+ continue
41+ try :
42+ for line in pubspec .read_text (encoding = "utf-8" , errors = "replace" ).splitlines ():
43+ m = re .match (r"\s*name\s*:\s*([A-Za-z0-9_]+)\s*$" , line )
44+ if m :
45+ return parent , m .group (1 )
46+ except OSError :
47+ return None
48+ return None
49+
50+ def _resolve_part_parent (parent_ref : str ) -> Path :
51+ if parent_ref .startswith ("package:" ):
52+ # Dart part files can name their parent via package:pkg/lib-relative path.
53+ package_ref = parent_ref [len ("package:" ):]
54+ package_name , _ , package_path = package_ref .partition ("/" )
55+ package_root = _find_package_root (path .parent )
56+ if package_root and package_root [1 ] == package_name and package_path :
57+ return (package_root [0 ] / "lib" / package_path ).resolve ()
58+ return (path .parent / Path (package_path or package_name ).name ).resolve ()
59+ return (path .parent / parent_ref ).resolve ()
60+
61+ # These SDK/common Dart types usually do not describe project relationships.
62+ # Keep them out of reference nodes unless a legacy extraction path explicitly
63+ # opts in for compatibility.
64+ dart_sdk_noise_types = {
65+ "String" , "int" , "double" , "bool" , "num" , "dynamic" , "Object" ,
66+ "List" , "Map" , "Set" , "Iterable" , "MapEntry" , "Iterator" ,
67+ "Comparable" , "Future" , "FutureOr" , "Stream" ,
68+ "void" , "Function" , "Never" , "Null" , "Type" , "Enum" , "Record" ,
69+ "DateTime" , "Duration" , "Uri" , "Runes" , "Symbol" , "BigInt" ,
70+ "RegExp" , "Pattern" , "Match" , "StackTrace" , "Error" , "Exception" ,
71+ }
72+ # Defensive cleanup for regex matches that accidentally include declaration
73+ # keywords in type-like strings.
74+ declaration_modifiers = {
75+ "static" , "late" , "final" , "const" , "var" , "external" , "abstract" ,
76+ "factory" , "async" , "base" , "interface" , "sealed" , "mixin" ,
77+ }
78+
79+ def _strip_balanced_generics (text : str ) -> str :
80+ current : list [str ] = []
81+ depth = 0
82+ for char in text :
83+ if char == "<" :
84+ depth += 1
85+ elif char == ">" :
86+ depth = max (0 , depth - 1 )
87+ elif depth == 0 :
88+ current .append (char )
89+ return "" .join (current )
90+
91+ def _clean_type_name (
92+ text : str | None ,
93+ * ,
94+ allow_sdk_noise : bool | set [str ] = False ,
95+ ) -> str | None :
96+ """Normalize a type-ish Dart fragment before creating a graph node."""
97+ if not text :
98+ return None
99+ clean = text .strip ().rstrip ("?" )
100+ clean = clean .split ("." )[- 1 ].strip ()
101+ clean = _strip_balanced_generics (clean ).strip ().rstrip ("?" )
102+ if "," in clean or "(" in clean or ")" in clean :
103+ return None
104+ parts = [part for part in clean .split () if part not in declaration_modifiers ]
105+ clean = " " .join (parts ).strip ()
106+ if not clean or clean in {"get" , "set" , "_" }:
107+ return None
108+ if clean .endswith (" get" ) or clean .endswith (" set" ):
109+ return None
110+ if not re .match (r"^[A-Za-z_]\w*$" , clean ):
111+ return None
112+ if clean in dart_sdk_noise_types :
113+ if allow_sdk_noise is True :
114+ return clean
115+ if isinstance (allow_sdk_noise , set ) and clean in allow_sdk_noise :
116+ return clean
117+ return None
118+ return clean
119+
120+ # Use stem (not str(path)) for child IDs to keep them machine-independent.
33121 stem = _file_stem (path )
34122 file_nid = _make_id (str (path ))
35123
@@ -40,7 +128,7 @@ def _comment_replace(match: re.Match) -> str:
40128 parent_ref = part_of_match .group (1 )
41129 if parent_ref .endswith (".dart" ):
42130 try :
43- parent_path = ( path . parent / parent_ref ). resolve ( )
131+ parent_path = _resolve_part_parent ( parent_ref )
44132 if parent_path .exists ():
45133 stem = _file_stem (parent_path )
46134 file_nid = _make_id (str (parent_path ))
@@ -138,14 +226,17 @@ def _find_matching_brace(text: str, start_pos: int) -> int:
138226
139227 # 1. Classes, mixins, and enums declarations (with inheritance, mixins, interfaces, and generics)
140228 # Supports multiple combined modifiers (e.g., abstract base class, mixin class) without capturing "class" as a name
141- class_pattern = r"^\s*(?:(?:abstract|sealed|base|interface|final|mixin)\s+)*(?:class|mixin|enum|extension\s+type)\s+(\w+)"
229+ class_pattern = r"^\s*(?:(?:abstract|sealed|base|interface|final|mixin)\s+)*(?:class|mixin|enum|extension\s+type(?:\s+const)? )\s+(\w+)"
142230 for m in re .finditer (class_pattern , src_clean , re .MULTILINE ):
143231 class_name = m .group (1 )
232+ if class_name == "_" :
233+ continue
144234 class_nid = _make_id (stem , class_name )
145235 add_node (class_nid , class_name )
146236 add_edge (file_nid , class_nid , "defines" )
147237
148- # Manually parse extends/on, with, and implements in header to handle nested generics brackets balanced
238+ # Manually parse extends/on, with, and implements so nested generic
239+ # brackets do not split relation targets incorrectly.
149240 start_idx = m .end ()
150241 rest = src_clean [start_idx : start_idx + 500 ]
151242
@@ -227,33 +318,36 @@ def _find_matching_brace(text: str, start_pos: int) -> int:
227318 interfaces_list = _split_types (header [impl_m .end ():])
228319
229320 # Map extends inheritance relation
230- if base_class :
231- base_nid = _make_id (base_class )
232- add_node (base_nid , base_class , source_file = None )
321+ clean_base = _clean_type_name (base_class )
322+ if clean_base :
323+ base_nid = _make_id (clean_base )
324+ add_node (base_nid , clean_base , source_file = None )
233325 add_edge (class_nid , base_nid , "inherits" )
234326
235327 # Map generic type arguments (e.g. MyBloc extends Bloc<MyEvent, MyState>)
236328 if generics :
237329 for gen in _split_types (generics ):
238- gen_clean = gen . split ( "<" )[ 0 ]. strip ( )
239- if gen_clean not in { "String" , "int" , "double" , "bool" , "num" , "dynamic" , "Object" , "void" } :
330+ gen_clean = _clean_type_name ( gen )
331+ if gen_clean :
240332 gen_nid = _make_id (gen_clean )
241333 add_node (gen_nid , gen_clean , source_file = None )
242334 add_edge (class_nid , gen_nid , "references" )
243335
244- # Map mixins
336+ # Dart `with` clauses are mixins, not interfaces.
245337 for mixin in mixins_list :
246- mixin_clean = mixin .split ("<" )[0 ].strip ()
247- mixin_nid = _make_id (mixin_clean )
248- add_node (mixin_nid , mixin_clean , source_file = None )
249- add_edge (class_nid , mixin_nid , "mixes_in" )
338+ mixin_clean = _clean_type_name (mixin )
339+ if mixin_clean :
340+ mixin_nid = _make_id (mixin_clean )
341+ add_node (mixin_nid , mixin_clean , source_file = None )
342+ add_edge (class_nid , mixin_nid , "mixes_in" )
250343
251344 # Map interfaces
252345 for interface in interfaces_list :
253- interface_clean = interface .split ("<" )[0 ].strip ()
254- interface_nid = _make_id (interface_clean )
255- add_node (interface_nid , interface_clean , source_file = None )
256- add_edge (class_nid , interface_nid , "implements" )
346+ interface_clean = _clean_type_name (interface , allow_sdk_noise = {"Object" })
347+ if interface_clean :
348+ interface_nid = _make_id (interface_clean )
349+ add_node (interface_nid , interface_clean , source_file = None )
350+ add_edge (class_nid , interface_nid , "implements" )
257351
258352 # Extract class body for precise framework dependencies and event handling
259353 start_idx = m .start ()
@@ -371,11 +465,11 @@ def _find_matching_brace(text: str, start_pos: int) -> int:
371465 typedef_pattern = r"^\s*typedef\s+(\w+)\s*(?:<[^>]+>)?\s*=\s*([a-zA-Z0-9_<>,.?\s]+);"
372466 for m in re .finditer (typedef_pattern , src_clean , re .MULTILINE ):
373467 typedef_name = m .group (1 )
374- target_type = m . group ( 2 ). split ( "<" )[ 0 ]. split ( "." )[ - 1 ]. strip ( )
375- if target_type not in { "String" , "int" , "double" , "bool" , "num" , "dynamic" , "Object" , "List" , "Map" , "Set" , "void" , "Function" }:
376- typedef_nid = _make_id ( stem , typedef_name )
377- add_node ( typedef_nid , typedef_name )
378- add_edge ( file_nid , typedef_nid , "defines" )
468+ typedef_nid = _make_id ( stem , typedef_name )
469+ add_node ( typedef_nid , typedef_name )
470+ add_edge ( file_nid , typedef_nid , "defines" )
471+ target_type = _clean_type_name ( m . group ( 2 ) )
472+ if target_type :
379473 target_nid = _make_id (target_type )
380474 add_node (target_nid , target_type , source_file = None )
381475 add_edge (typedef_nid , target_nid , "references" , context = "typedef" )
@@ -391,14 +485,22 @@ def _find_matching_brace(text: str, start_pos: int) -> int:
391485 add_node (ext_nid , label )
392486 add_edge (file_nid , ext_nid , "defines" )
393487
394- target_nid = _make_id (target_class )
395- add_node (target_nid , target_class , source_file = None )
396- add_edge (ext_nid , target_nid , "extends" )
488+ clean_target = _clean_type_name (target_class )
489+ if clean_target :
490+ target_nid = _make_id (clean_target )
491+ add_node (target_nid , clean_target , source_file = None )
492+ add_edge (ext_nid , target_nid , "extends" )
397493
398494 # 4. Top-level and class-level variable declarations (generic variables, records, late, and destructuring)
399495 # Restrict indentation to 0-2 spaces to avoid matching local variables inside functions or switch expressions
400496 var_pattern = r"^\s{0,2}(?:late\s+)?(?:(?:final|const|var)\s+)?(?:\([^)]+\)\s+|([a-zA-Z0-9_<>,.?]+(?:\s+[a-zA-Z0-9_<>,.?]+){0,3})\s+)?(?:(\w+)|(?:\w+\s*)?\(([^)]+)\))\s*(?:=|$|;)"
401497 for m in re .finditer (var_pattern , src_clean , re .MULTILINE ):
498+ if re .match (
499+ r"^\s*(?:class|mixin|enum|extension|import|export|part|library|typedef)\b" ,
500+ m .group (0 ),
501+ ):
502+ continue
503+
402504 var_type = m .group (1 )
403505 single_name = m .group (2 )
404506 destructured_names = m .group (3 )
@@ -412,8 +514,8 @@ def _find_matching_brace(text: str, start_pos: int) -> int:
412514 add_node (var_nid , single_name )
413515 add_edge (file_nid , var_nid , "defines" )
414516
415- if var_type and var_type not in { "String" , "int" , "double" , "bool" , "num" , "dynamic" , "Object" , "List" , "Map" , "Set" , "void" }:
416- clean_type = var_type . split ( "<" )[ 0 ]. split ( "." )[ - 1 ]. strip ()
517+ clean_type = _clean_type_name ( var_type )
518+ if clean_type :
417519 type_nid = _make_id (clean_type )
418520 add_node (type_nid , clean_type , source_file = None )
419521 add_edge (file_nid , type_nid , "references" , context = "variable_type" )
@@ -422,6 +524,8 @@ def _find_matching_brace(text: str, start_pos: int) -> int:
422524 if ":" in name :
423525 name = name .split (":" )[- 1 ].strip ()
424526 if re .match (r"^[a-zA-Z_]\w*$" , name ) and not re .match (r"^[A-Z]" , name ):
527+ if name == "_" :
528+ continue
425529 if name not in {"if" , "for" , "while" , "switch" , "catch" , "return" }:
426530 var_nid = _make_id (stem , name )
427531 add_node (var_nid , name )
@@ -433,7 +537,7 @@ def _find_matching_brace(text: str, start_pos: int) -> int:
433537 for m in re .finditer (method_pattern , src_clean , re .MULTILINE ):
434538 raw_name = m .group (1 )
435539 name = raw_name .split ("." )[- 1 ]
436- if name in {"if" , "for" , "while" , "switch" , "catch" , "return" , "void" , "dynamic" , "final" , "const" , "get" , "set" }:
540+ if name in {"if" , "for" , "while" , "switch" , "catch" , "return" , "void" , "dynamic" , "final" , "const" , "get" , "set" , "_" }:
437541 continue
438542 if re .match (r"^[A-Z]" , name ):
439543 continue
@@ -516,11 +620,13 @@ def _find_matching_brace(text: str, start_pos: int) -> int:
516620 # Matches any method call with type parameters: methodName<Type>() or object.methodName<Type>()
517621 # Automatically extracts GetIt, Injectable, Riverpod, Provider, BlocProvider, and InheritedWidget type lookups!
518622 generic_call_pattern = r"\b\w+<([a-zA-Z0-9_.]+(?:<[a-zA-Z0-9_.,\s<>]+>)?)\s*>\s*\("
519- type_blacklist = {"String" , "int" , "double" , "bool" , "num" , "dynamic" , "Object" , "List" , "Map" , "Set" , "Future" , "Stream" , "void" }
520623 for m in re .finditer (generic_call_pattern , src_clean ):
521- type_name = m .group (1 ).split ("." )[- 1 ].strip ()
522- clean_name = type_name .split ("<" )[0 ].strip ()
523- if clean_name not in type_blacklist :
624+ line_start = src_clean .rfind ("\n " , 0 , m .start ()) + 1
625+ line = src_clean [line_start : m .start ()]
626+ if re .match (r"^\s*(?:class|mixin|enum|extension|typedef)\b" , line ):
627+ continue
628+ clean_name = _clean_type_name (m .group (1 ))
629+ if clean_name :
524630 target_nid = _make_id (clean_name )
525631 add_node (target_nid , clean_name , source_file = None )
526632 add_edge (file_nid , target_nid , "references" , context = "type_lookup" )
0 commit comments