mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-12 02:17:04 +00:00
feat(dart): modernize AST parser, support nested generics and part-of redirection (#1098)
Modernizes the Dart extractor: comment stripping, `part of` redirection, nested-generic-aware extends/with/implements parsing, generic type-argument mapping, and generic call detection. Verified against current v8: merges cleanly, full suite 1530 passed, and inheritance edges connect to same-file class nodes with no ghost-node splits or dangling edges (consistent with the #1033/#1096 node-ID invariants).
This commit is contained in:
+503
-35
@@ -1,5 +1,6 @@
|
||||
"""Deterministic structural extraction from source code using tree-sitter. Outputs nodes+edges dicts."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
@@ -8,7 +9,8 @@ import sys
|
||||
import unicodedata
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Callable, Any
|
||||
from typing import Any, Callable
|
||||
|
||||
from .cache import load_cached, save_cached
|
||||
from .mcp_ingest import extract_mcp_config, is_mcp_config_path
|
||||
|
||||
@@ -4090,56 +4092,522 @@ def extract_blade(path: Path) -> dict:
|
||||
|
||||
|
||||
def extract_dart(path: Path) -> dict:
|
||||
"""Extract classes, mixins, functions, imports, and calls from a .dart file using regex."""
|
||||
"""Extract classes, mixins, functions, imports, generic calls, and annotations from a .dart file using regex."""
|
||||
try:
|
||||
src = path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return {"error": f"cannot read {path}"}
|
||||
|
||||
# Use stem (not str(path)) for child IDs to keep them machine-independent.
|
||||
# Remove inline and multi-line comments while leaving string literals untouched to prevent stripping URLs/paths inside strings
|
||||
comment_string_pattern = re.compile(
|
||||
r'"""(?:\\.|[\s\S])*?"""'
|
||||
r"|'''(?:\\.|[\s\S])*?'''"
|
||||
r'|"(?:\\.|[^"\\])*"'
|
||||
r"|'(?:\\.|[^'\\])*'"
|
||||
r"|/\*[\s\S]*?\*/"
|
||||
r"|//[^\n]*"
|
||||
)
|
||||
def _comment_replace(match: re.Match) -> str:
|
||||
token = match.group(0)
|
||||
if token.startswith("/"):
|
||||
return ""
|
||||
return token
|
||||
src_clean = comment_string_pattern.sub(_comment_replace, src)
|
||||
|
||||
stem = _file_stem(path)
|
||||
file_nid = _make_id(str(path))
|
||||
nodes = [{"id": file_nid, "label": path.name, "file_type": "code",
|
||||
"source_file": str(path), "source_location": None}]
|
||||
|
||||
# Check if this is a part-of file and redirect to parent
|
||||
part_of_match = re.search(r"^\s*part\s+of\s+['\"]([^'\"]+)['\"]", src_clean, re.MULTILINE)
|
||||
is_part = False
|
||||
if part_of_match:
|
||||
parent_ref = part_of_match.group(1)
|
||||
if parent_ref.endswith(".dart"):
|
||||
try:
|
||||
parent_path = (path.parent / parent_ref).resolve()
|
||||
if parent_path.exists():
|
||||
stem = _file_stem(parent_path)
|
||||
file_nid = _make_id(str(parent_path))
|
||||
is_part = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
nodes = []
|
||||
if not is_part:
|
||||
nodes.append({"id": file_nid, "label": path.name, "file_type": "code",
|
||||
"source_file": str(path), "source_location": None})
|
||||
edges = []
|
||||
defined: set[str] = set()
|
||||
|
||||
# Classes and mixins
|
||||
for m in re.finditer(r"^\s*(?:abstract\s+)?(?:class|mixin)\s+(\w+)", src, re.MULTILINE):
|
||||
nid = _make_id(stem, m.group(1))
|
||||
def add_node(nid: str, label: str, ftype: str = "code", source_file: str | None = str(path)) -> None:
|
||||
if nid not in defined:
|
||||
nodes.append({"id": nid, "label": m.group(1), "file_type": "code",
|
||||
"source_file": str(path), "source_location": None})
|
||||
edges.append({"source": file_nid, "target": nid, "relation": "defines",
|
||||
"confidence": "EXTRACTED", "confidence_score": 1.0,
|
||||
"source_file": str(path), "source_location": None, "weight": 1.0})
|
||||
nodes.append({"id": nid, "label": label, "file_type": ftype,
|
||||
"source_file": source_file, "source_location": None})
|
||||
defined.add(nid)
|
||||
|
||||
# Top-level and member functions/methods
|
||||
for m in re.finditer(r"^\s*(?:static\s+|async\s+)?(?:\w+\s+)+(\w+)\s*\(", src, re.MULTILINE):
|
||||
name = m.group(1)
|
||||
if name in {"if", "for", "while", "switch", "catch", "return"}:
|
||||
def add_edge(src_id: str, tgt_id: str, relation: str, weight: float = 1.0, context: str | None = None) -> None:
|
||||
edge = {"source": src_id, "target": tgt_id, "relation": relation,
|
||||
"confidence": "EXTRACTED", "confidence_score": 1.0,
|
||||
"source_file": str(path), "source_location": None, "weight": weight}
|
||||
if context:
|
||||
edge["context"] = context
|
||||
edges.append(edge)
|
||||
|
||||
def _split_types(text: str) -> list[str]:
|
||||
parts = []
|
||||
current = []
|
||||
depth = 0
|
||||
for char in text:
|
||||
if char == "<":
|
||||
depth += 1
|
||||
current.append(char)
|
||||
elif char == ">":
|
||||
depth -= 1
|
||||
current.append(char)
|
||||
elif char == "," and depth == 0:
|
||||
parts.append("".join(current).strip())
|
||||
current = []
|
||||
else:
|
||||
current.append(char)
|
||||
if current:
|
||||
parts.append("".join(current).strip())
|
||||
return [p for p in parts if p]
|
||||
|
||||
def _find_matching_brace(text: str, start_pos: int) -> int:
|
||||
brace_count = 0
|
||||
in_double_quote = False
|
||||
in_single_quote = False
|
||||
escape = False
|
||||
|
||||
first_brace = text.find("{", start_pos)
|
||||
if first_brace == -1:
|
||||
return len(text)
|
||||
|
||||
brace_count = 1
|
||||
i = first_brace + 1
|
||||
n = len(text)
|
||||
while i < n:
|
||||
char = text[i]
|
||||
if escape:
|
||||
escape = False
|
||||
i += 1
|
||||
continue
|
||||
if char == "\\":
|
||||
escape = True
|
||||
i += 1
|
||||
continue
|
||||
if text[i:i+3] == '"""' and not in_single_quote:
|
||||
i += 3
|
||||
end = text.find('"""', i)
|
||||
i = end + 3 if end != -1 else n
|
||||
continue
|
||||
if text[i:i+3] == "'''" and not in_double_quote:
|
||||
i += 3
|
||||
end = text.find("'''", i)
|
||||
i = end + 3 if end != -1 else n
|
||||
continue
|
||||
if char == '"' and not in_single_quote:
|
||||
in_double_quote = not in_double_quote
|
||||
elif char == "'" and not in_double_quote:
|
||||
in_single_quote = not in_single_quote
|
||||
elif not in_double_quote and not in_single_quote:
|
||||
if char == "{":
|
||||
brace_count += 1
|
||||
elif char == "}":
|
||||
brace_count -= 1
|
||||
if brace_count == 0:
|
||||
return i + 1
|
||||
i += 1
|
||||
return len(text)
|
||||
|
||||
# 1. Classes, mixins, and enums declarations (with inheritance, mixins, interfaces, and generics)
|
||||
# Supports multiple combined modifiers (e.g., abstract base class, mixin class) without capturing "class" as a name
|
||||
class_pattern = r"^\s*(?:(?:abstract|sealed|base|interface|final|mixin)\s+)*(?:class|mixin|enum|extension\s+type)\s+(\w+)"
|
||||
for m in re.finditer(class_pattern, src_clean, re.MULTILINE):
|
||||
class_name = m.group(1)
|
||||
class_nid = _make_id(stem, class_name)
|
||||
add_node(class_nid, class_name)
|
||||
add_edge(file_nid, class_nid, "defines")
|
||||
|
||||
# Manually parse extends/on, with, and implements in header to handle nested generics brackets balanced
|
||||
start_idx = m.end()
|
||||
rest = src_clean[start_idx : start_idx + 500]
|
||||
|
||||
# Skip class generic parameters
|
||||
if rest.lstrip().startswith("<"):
|
||||
offset = rest.find("<")
|
||||
depth = 1
|
||||
i = offset + 1
|
||||
while i < len(rest) and depth > 0:
|
||||
if rest[i] == "<": depth += 1
|
||||
elif rest[i] == ">": depth -= 1
|
||||
i += 1
|
||||
rest = rest[i:]
|
||||
|
||||
# Skip primary constructor (e.g. extension type MyExt(int id))
|
||||
if rest.lstrip().startswith("("):
|
||||
offset = rest.find("(")
|
||||
depth = 1
|
||||
i = offset + 1
|
||||
while i < len(rest) and depth > 0:
|
||||
if rest[i] == "(": depth += 1
|
||||
elif rest[i] == ")": depth -= 1
|
||||
i += 1
|
||||
rest = rest[i:]
|
||||
|
||||
header_end = rest.find("{")
|
||||
if header_end == -1:
|
||||
header_end = rest.find(";")
|
||||
if header_end == -1:
|
||||
header_end = len(rest)
|
||||
header = rest[:header_end]
|
||||
|
||||
base_class = None
|
||||
generics = None
|
||||
mixins_list = []
|
||||
interfaces_list = []
|
||||
|
||||
# Parse extends or on
|
||||
extends_m = re.search(r"^\s*(?:extends|on)\s+([a-zA-Z0-9_.]+)", header)
|
||||
if extends_m:
|
||||
base_class = extends_m.group(1)
|
||||
rest_header = header[extends_m.end():]
|
||||
if rest_header.strip().startswith("<"):
|
||||
start_idx = rest_header.find("<")
|
||||
depth = 1
|
||||
i = start_idx + 1
|
||||
while i < len(rest_header) and depth > 0:
|
||||
if rest_header[i] == "<":
|
||||
depth += 1
|
||||
elif rest_header[i] == ">":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
generics = rest_header[start_idx + 1 : i]
|
||||
break
|
||||
i += 1
|
||||
if generics is not None:
|
||||
header = rest_header[i + 1:]
|
||||
else:
|
||||
header = rest_header
|
||||
else:
|
||||
header = rest_header
|
||||
|
||||
# Parse with
|
||||
with_m = re.search(r"^\s*with\s+", header)
|
||||
if with_m:
|
||||
rest_header = header[with_m.end():]
|
||||
impl_idx = rest_header.find("implements")
|
||||
if impl_idx != -1:
|
||||
mixins_str = rest_header[:impl_idx]
|
||||
header = rest_header[impl_idx:]
|
||||
else:
|
||||
mixins_str = rest_header
|
||||
header = ""
|
||||
mixins_list = _split_types(mixins_str)
|
||||
|
||||
# Parse implements
|
||||
impl_m = re.search(r"^\s*implements\s+", header)
|
||||
if impl_m:
|
||||
interfaces_list = _split_types(header[impl_m.end():])
|
||||
|
||||
# Map extends inheritance relation
|
||||
if base_class:
|
||||
base_nid = _make_id(base_class)
|
||||
add_node(base_nid, base_class, source_file=None)
|
||||
add_edge(class_nid, base_nid, "inherits")
|
||||
|
||||
# Map generic type arguments (e.g. MyBloc extends Bloc<MyEvent, MyState>)
|
||||
if generics:
|
||||
for gen in _split_types(generics):
|
||||
gen_clean = gen.split("<")[0].strip()
|
||||
if gen_clean not in {"String", "int", "double", "bool", "num", "dynamic", "Object", "void"}:
|
||||
gen_nid = _make_id(gen_clean)
|
||||
add_node(gen_nid, gen_clean, source_file=None)
|
||||
add_edge(class_nid, gen_nid, "references")
|
||||
|
||||
# Map mixins
|
||||
for mixin in mixins_list:
|
||||
mixin_clean = mixin.split("<")[0].strip()
|
||||
mixin_nid = _make_id(mixin_clean)
|
||||
add_node(mixin_nid, mixin_clean, source_file=None)
|
||||
add_edge(class_nid, mixin_nid, "implements")
|
||||
|
||||
# Map interfaces
|
||||
for interface in interfaces_list:
|
||||
interface_clean = interface.split("<")[0].strip()
|
||||
interface_nid = _make_id(interface_clean)
|
||||
add_node(interface_nid, interface_clean, source_file=None)
|
||||
add_edge(class_nid, interface_nid, "implements")
|
||||
|
||||
# Extract class body for precise framework dependencies and event handling
|
||||
start_idx = m.start()
|
||||
brace_pos = src_clean.find("{", start_idx)
|
||||
semi_pos = src_clean.find(";", start_idx)
|
||||
|
||||
has_body = brace_pos != -1
|
||||
if has_body and semi_pos != -1 and semi_pos < brace_pos:
|
||||
has_body = False
|
||||
|
||||
if has_body:
|
||||
end_pos = _find_matching_brace(src_clean, start_idx)
|
||||
class_body = src_clean[brace_pos:end_pos]
|
||||
|
||||
# Bloc event registration: on<MyEvent>()
|
||||
for em in re.finditer(r"\bon<(\w+)>\s*\(", class_body):
|
||||
event_name = em.group(1)
|
||||
event_nid = _make_id(event_name)
|
||||
add_node(event_nid, event_name, source_file=None)
|
||||
add_edge(class_nid, event_nid, "calls", context="bloc_event")
|
||||
|
||||
# Bloc state emissions: emit(MyState) or yield MyState
|
||||
for sm in re.finditer(r"\b(?:emit|yield)\s*\(?\s*(?:const\s+)?([A-Z]\w*)\b", class_body):
|
||||
state_name = sm.group(1)
|
||||
if state_name not in {"String", "List", "Map", "Set", "Future", "Stream", "Object"}:
|
||||
state_nid = _make_id(state_name)
|
||||
add_node(state_nid, state_name, source_file=None)
|
||||
add_edge(class_nid, state_nid, "calls", context="emit_state")
|
||||
|
||||
# Bloc event additions: widget.add(MyEvent()) or bloc.add(MyEvent())
|
||||
for am in re.finditer(r"\b(?:\w*[Bb]loc\w*|context\.read<\w+>\(\))\.add\(\s*(?:const\s+)?([A-Z]\w*)\b", class_body):
|
||||
event_name = am.group(1)
|
||||
if event_name not in {"String", "List", "Map", "Set", "Future", "Stream", "Object"}:
|
||||
event_nid = _make_id(event_name)
|
||||
add_node(event_nid, event_name, source_file=None)
|
||||
add_edge(class_nid, event_nid, "calls", context="bloc_add_event")
|
||||
|
||||
# Riverpod provider references: ref.watch(provider)
|
||||
for rm in re.finditer(r"\bref\.(?:watch|read|listen)\s*\(\s*(\w+)\b", class_body):
|
||||
provider_name = rm.group(1)
|
||||
provider_nid = _make_id(provider_name)
|
||||
add_node(provider_nid, provider_name, source_file=None)
|
||||
add_edge(class_nid, provider_nid, "references", context="riverpod_reference")
|
||||
|
||||
# Widget to Bloc references: BlocBuilder<MyBloc, ...>
|
||||
for bm in re.finditer(r"\bBloc(?:Builder|Listener|Consumer|Provider|Selector)\s*<\s*([a-zA-Z0-9_]+)\b", class_body):
|
||||
bloc_name = bm.group(1)
|
||||
if bloc_name not in {"String", "int", "double", "bool", "num", "dynamic", "Object", "void"}:
|
||||
bloc_nid = _make_id(bloc_name)
|
||||
add_node(bloc_nid, bloc_name, source_file=None)
|
||||
add_edge(class_nid, bloc_nid, "references", context="bloc_widget_binding")
|
||||
|
||||
# context.read<MyBloc>() or BlocProvider.of<MyBloc>(context)
|
||||
for lm in re.finditer(r"\b(?:read|watch|select|of)\s*<([a-zA-Z0-9_]+)>", class_body):
|
||||
bloc_name = lm.group(1)
|
||||
if bloc_name not in {"String", "int", "double", "bool", "num", "dynamic", "Object", "void"}:
|
||||
bloc_nid = _make_id(bloc_name)
|
||||
add_node(bloc_nid, bloc_name, source_file=None)
|
||||
add_edge(class_nid, bloc_nid, "references", context="bloc_lookup")
|
||||
|
||||
# 2. Annotations mapping (class, mixin, enum, or function level annotations)
|
||||
# Support: @riverpod, @Riverpod(...), @injectable, @singleton, @RoutePage(), @HiveType(typeId: 0), @RestApi()
|
||||
# Matches `@annotation` and links it to the next class/mixin/enum/function declaration in the file
|
||||
annotation_pattern = r"@(\w+)(?:\([^)]*\))?"
|
||||
for am in re.finditer(annotation_pattern, src_clean):
|
||||
annotation_name = am.group(1)
|
||||
if annotation_name in {"override", "deprecated", "required", "protected", "mustCallSuper"}:
|
||||
continue
|
||||
annotation_pos = am.end()
|
||||
intervening_text = src_clean[annotation_pos : annotation_pos + 300]
|
||||
|
||||
class_m = re.search(r"^\s*(?:(?:abstract|sealed|base|interface|final|mixin)\s+)*(?:class|mixin|enum|extension\s+type)\s+(\w+)", intervening_text, re.MULTILINE)
|
||||
func_m = re.search(r"^\s*(?:factory\s+|static\s+|async\s+|external\s+|abstract\s+)?(?:\([^)]+\)|[a-zA-Z0-9_<>,.?]+)(?:\s+[a-zA-Z0-9_<>,.?]+){0,3}\s+(\w+)\s*\(", intervening_text, re.MULTILINE)
|
||||
|
||||
target_nid = None
|
||||
target_name = None
|
||||
target_type = None
|
||||
|
||||
if class_m and func_m:
|
||||
if class_m.start() < func_m.start():
|
||||
target_name = class_m.group(1)
|
||||
target_type = "class"
|
||||
target_nid = _make_id(stem, target_name)
|
||||
else:
|
||||
target_name = func_m.group(1)
|
||||
target_type = "function"
|
||||
target_nid = _make_id(stem, target_name)
|
||||
elif class_m:
|
||||
target_name = class_m.group(1)
|
||||
target_type = "class"
|
||||
target_nid = _make_id(stem, target_name)
|
||||
elif func_m:
|
||||
target_name = func_m.group(1)
|
||||
target_type = "function"
|
||||
target_nid = _make_id(stem, target_name)
|
||||
|
||||
if target_nid and target_name:
|
||||
actual_intervening = intervening_text[:min(class_m.start() if class_m else 300, func_m.start() if func_m else 300)]
|
||||
if ";" not in actual_intervening and "}" not in actual_intervening and "{" not in actual_intervening:
|
||||
annotation_nid = _make_id("annotation", annotation_name.lower())
|
||||
add_node(annotation_nid, f"@{annotation_name}", ftype="concept", source_file=None)
|
||||
add_edge(target_nid, annotation_nid, "configures")
|
||||
|
||||
# Riverpod specific provider generation mapping (supports camelCase class and functional providers)
|
||||
if annotation_name.lower() == "riverpod":
|
||||
if target_type == "class":
|
||||
provider_name = target_name[0].lower() + target_name[1:] + "Provider" if len(target_name) > 1 else target_name.lower() + "Provider"
|
||||
else:
|
||||
provider_name = target_name + "Provider"
|
||||
provider_nid = _make_id(provider_name)
|
||||
add_node(provider_nid, provider_name, ftype="concept", source_file=str(path))
|
||||
add_edge(target_nid, provider_nid, "defines", context="riverpod_provider")
|
||||
|
||||
# 2.5 Typedefs (Type Aliases)
|
||||
typedef_pattern = r"^\s*typedef\s+(\w+)\s*(?:<[^>]+>)?\s*=\s*([a-zA-Z0-9_<>,.?\s]+);"
|
||||
for m in re.finditer(typedef_pattern, src_clean, re.MULTILINE):
|
||||
typedef_name = m.group(1)
|
||||
target_type = m.group(2).split("<")[0].split(".")[-1].strip()
|
||||
if target_type not in {"String", "int", "double", "bool", "num", "dynamic", "Object", "List", "Map", "Set", "void", "Function"}:
|
||||
typedef_nid = _make_id(stem, typedef_name)
|
||||
add_node(typedef_nid, typedef_name)
|
||||
add_edge(file_nid, typedef_nid, "defines")
|
||||
target_nid = _make_id(target_type)
|
||||
add_node(target_nid, target_type, source_file=None)
|
||||
add_edge(typedef_nid, target_nid, "references", context="typedef")
|
||||
|
||||
# 3. Extensions (extension MyExt on MyClass)
|
||||
ext_pattern = r"^\s{0,4}extension\s+(\w+)?(?:<[^>]+>)?\s+on\s+(\w+)"
|
||||
for m in re.finditer(ext_pattern, src_clean, re.MULTILINE):
|
||||
ext_name = m.group(1) or f"{stem}_anonymous_extension"
|
||||
target_class = m.group(2)
|
||||
|
||||
ext_nid = _make_id(stem, ext_name)
|
||||
label = m.group(1) or f"Extension on {target_class}"
|
||||
add_node(ext_nid, label)
|
||||
add_edge(file_nid, ext_nid, "defines")
|
||||
|
||||
target_nid = _make_id(target_class)
|
||||
add_node(target_nid, target_class, source_file=None)
|
||||
add_edge(ext_nid, target_nid, "extends")
|
||||
|
||||
# 4. Top-level and class-level variable declarations (generic variables, records, late, and destructuring)
|
||||
# Restrict indentation to 0-2 spaces to avoid matching local variables inside functions or switch expressions
|
||||
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*(?:=|$|;)"
|
||||
for m in re.finditer(var_pattern, src_clean, re.MULTILINE):
|
||||
var_type = m.group(1)
|
||||
single_name = m.group(2)
|
||||
destructured_names = m.group(3)
|
||||
|
||||
if not re.match(r"^\s*(?:late|final|const|var)\b", m.group(0)) and not var_type:
|
||||
continue
|
||||
|
||||
if single_name:
|
||||
if single_name not in {"if", "for", "while", "switch", "catch", "return"}:
|
||||
var_nid = _make_id(stem, single_name)
|
||||
add_node(var_nid, single_name)
|
||||
add_edge(file_nid, var_nid, "defines")
|
||||
|
||||
if var_type and var_type not in {"String", "int", "double", "bool", "num", "dynamic", "Object", "List", "Map", "Set", "void"}:
|
||||
clean_type = var_type.split("<")[0].split(".")[-1].strip()
|
||||
type_nid = _make_id(clean_type)
|
||||
add_node(type_nid, clean_type, source_file=None)
|
||||
add_edge(file_nid, type_nid, "references", context="variable_type")
|
||||
elif destructured_names:
|
||||
for name in [n.strip() for n in destructured_names.split(",") if n.strip()]:
|
||||
if ":" in name:
|
||||
name = name.split(":")[-1].strip()
|
||||
if re.match(r"^[a-zA-Z_]\w*$", name) and not re.match(r"^[A-Z]", name):
|
||||
if name not in {"if", "for", "while", "switch", "catch", "return"}:
|
||||
var_nid = _make_id(stem, name)
|
||||
add_node(var_nid, name)
|
||||
add_edge(file_nid, var_nid, "defines")
|
||||
|
||||
# 5. Top-level and member functions/methods (supports typed/generic/record return types and Riverpod/Bloc references)
|
||||
# Restrict indentation to 0-2 spaces to avoid matching nested local functions or methods inside multiline switch statements
|
||||
method_pattern = r"^\s{0,2}(?:factory\s+|static\s+|async\s+|external\s+|abstract\s+)?(?:\([^)]+\)|[a-zA-Z0-9_<>,.?]+)(?:\s+[a-zA-Z0-9_<>,.?]+){0,3}\s+(\w+(?:\.\w+)?)\s*\("
|
||||
for m in re.finditer(method_pattern, src_clean, re.MULTILINE):
|
||||
raw_name = m.group(1)
|
||||
name = raw_name.split(".")[-1]
|
||||
if name in {"if", "for", "while", "switch", "catch", "return", "void", "dynamic", "final", "const", "get", "set"}:
|
||||
continue
|
||||
if re.match(r"^[A-Z]", name):
|
||||
continue
|
||||
nid = _make_id(stem, name)
|
||||
if nid not in defined:
|
||||
nodes.append({"id": nid, "label": name, "file_type": "code",
|
||||
"source_file": str(path), "source_location": None})
|
||||
edges.append({"source": file_nid, "target": nid, "relation": "defines",
|
||||
"confidence": "EXTRACTED", "confidence_score": 1.0,
|
||||
"source_file": str(path), "source_location": None, "weight": 1.0})
|
||||
defined.add(nid)
|
||||
add_node(nid, name)
|
||||
add_edge(file_nid, nid, "defines")
|
||||
|
||||
# import 'package:...' or import '...'
|
||||
for m in re.finditer(r"""^import\s+['"]([^'"]+)['"]""", src, re.MULTILINE):
|
||||
# Get function body using matching brace to extract Riverpod reference patterns
|
||||
start_idx = m.start()
|
||||
brace_pos = src_clean.find("{", start_idx)
|
||||
semi_pos = src_clean.find(";", start_idx)
|
||||
arrow_pos = src_clean.find("=>", start_idx)
|
||||
|
||||
has_body = brace_pos != -1
|
||||
if has_body and semi_pos != -1 and semi_pos < brace_pos:
|
||||
has_body = False
|
||||
if has_body and arrow_pos != -1 and arrow_pos < brace_pos:
|
||||
has_body = False
|
||||
|
||||
if has_body:
|
||||
end_pos = _find_matching_brace(src_clean, start_idx)
|
||||
func_body = src_clean[brace_pos:end_pos]
|
||||
|
||||
# Extract Riverpod provider references: ref.watch(provider)
|
||||
for rm in re.finditer(r"\bref\.(?:watch|read|listen)\s*\(\s*(\w+)\b", func_body):
|
||||
provider_name = rm.group(1)
|
||||
provider_nid = _make_id(provider_name)
|
||||
add_node(provider_nid, provider_name, source_file=None)
|
||||
add_edge(nid, provider_nid, "references", context="riverpod_reference")
|
||||
|
||||
# Extract Bloc event additions: widget.add(MyEvent()) or bloc.add(MyEvent())
|
||||
for am in re.finditer(r"\b(?:\w*[Bb]loc\w*|context\.read<\w+>\(\))\.add\(\s*(?:const\s+)?([A-Z]\w*)\b", func_body):
|
||||
event_name = am.group(1)
|
||||
if event_name not in {"String", "List", "Map", "Set", "Future", "Stream", "Object"}:
|
||||
event_nid = _make_id(event_name)
|
||||
add_node(event_nid, event_name, source_file=None)
|
||||
add_edge(nid, event_nid, "calls", context="bloc_add_event")
|
||||
|
||||
# context.read<MyBloc>() or BlocProvider.of<MyBloc>(context)
|
||||
for lm in re.finditer(r"\b(?:read|watch|select|of)\s*<([a-zA-Z0-9_]+)>", func_body):
|
||||
bloc_name = lm.group(1)
|
||||
if bloc_name not in {"String", "int", "double", "bool", "num", "dynamic", "Object", "void"}:
|
||||
bloc_nid = _make_id(bloc_name)
|
||||
add_node(bloc_nid, bloc_name, source_file=None)
|
||||
add_edge(nid, bloc_nid, "references", context="bloc_lookup")
|
||||
|
||||
# Universal Navigation Patters (GoRouter, AutoRoute, Navigator)
|
||||
for nm in re.finditer(r"\b(?:go|push|goNamed|pushNamed|replace|replaceNamed)\s*\(\s*(?:context\s*,\s*)?['\"]([a-zA-Z0-9_/?=&%-]+)['\"]", func_body):
|
||||
route_path = nm.group(1)
|
||||
route_nid = _make_id("route", route_path.replace("/", "_").replace("?", "_").replace("=", "_").replace("&", "_"))
|
||||
add_node(route_nid, f"Route {route_path}", ftype="concept", source_file=None)
|
||||
add_edge(nid, route_nid, "navigates", context="route_path")
|
||||
|
||||
for cm in re.finditer(r"\b(?:go|push|goNamed|pushNamed|replace|replaceNamed)\s*\(\s*(?:context\s*,\s*)?([A-Z][a-zA-Z0-9_]*\.[a-zA-Z0-9_]+)", func_body):
|
||||
route_const = cm.group(1)
|
||||
route_nid = _make_id("route", route_const.replace(".", "_"))
|
||||
add_node(route_nid, route_const, ftype="concept", source_file=None)
|
||||
add_edge(nid, route_nid, "navigates", context="route_const")
|
||||
|
||||
for om in re.finditer(r"\b(?:push|replace)\s*\(\s*(?:context\s*,\s*)?.*?\b([A-Z]\w*(?:Route|Screen|Page))\b", func_body):
|
||||
route_class = om.group(1)
|
||||
route_nid = _make_id(route_class)
|
||||
add_node(route_nid, route_class, source_file=None)
|
||||
add_edge(nid, route_nid, "navigates", context="route_object")
|
||||
|
||||
# 6. Imports and Exports
|
||||
for m in re.finditer(r"""^\s*import\s+['"]([^'"]+)['"]""", src_clean, re.MULTILINE):
|
||||
pkg = m.group(1)
|
||||
tgt_nid = _make_id(pkg)
|
||||
if tgt_nid not in defined:
|
||||
nodes.append({"id": tgt_nid, "label": pkg, "file_type": "code",
|
||||
"source_file": str(path), "source_location": None})
|
||||
defined.add(tgt_nid)
|
||||
edges.append({"source": file_nid, "target": tgt_nid, "relation": "imports",
|
||||
"confidence": "EXTRACTED", "confidence_score": 1.0,
|
||||
"source_file": str(path), "source_location": None, "weight": 1.0})
|
||||
add_node(tgt_nid, pkg, source_file=None)
|
||||
add_edge(file_nid, tgt_nid, "imports")
|
||||
|
||||
for m in re.finditer(r"""^\s*export\s+['"]([^'"]+)['"]""", src_clean, re.MULTILINE):
|
||||
pkg = m.group(1)
|
||||
tgt_nid = _make_id(pkg)
|
||||
add_node(tgt_nid, pkg, source_file=None)
|
||||
add_edge(file_nid, tgt_nid, "exports")
|
||||
|
||||
# 7. Generic Invocations / Type Lookups (Universal Dependency Lookup)
|
||||
# Matches any method call with type parameters: methodName<Type>() or object.methodName<Type>()
|
||||
# Automatically extracts GetIt, Injectable, Riverpod, Provider, BlocProvider, and InheritedWidget type lookups!
|
||||
generic_call_pattern = r"\b\w+<([a-zA-Z0-9_.]+(?:<[a-zA-Z0-9_.,\s<>]+>)?)\s*>\s*\("
|
||||
type_blacklist = {"String", "int", "double", "bool", "num", "dynamic", "Object", "List", "Map", "Set", "Future", "Stream", "void"}
|
||||
for m in re.finditer(generic_call_pattern, src_clean):
|
||||
type_name = m.group(1).split(".")[-1].strip()
|
||||
clean_name = type_name.split("<")[0].strip()
|
||||
if clean_name not in type_blacklist:
|
||||
target_nid = _make_id(clean_name)
|
||||
add_node(target_nid, clean_name, source_file=None)
|
||||
add_edge(file_nid, target_nid, "references", context="type_lookup")
|
||||
|
||||
return {"nodes": nodes, "edges": edges}
|
||||
|
||||
@@ -6939,8 +7407,8 @@ def _collect_js_symbol_resolution_facts(paths: list[Path], facts: _SymbolResolut
|
||||
|
||||
def _parse_python_tree(path: Path):
|
||||
try:
|
||||
from tree_sitter import Language, Parser
|
||||
import tree_sitter_python as tspython
|
||||
from tree_sitter import Language, Parser
|
||||
source = path.read_bytes()
|
||||
parser = Parser(Language(tspython.language()))
|
||||
return source, parser.parse(source).root_node
|
||||
@@ -10619,7 +11087,7 @@ def collect_files(target: Path, *, follow_symlinks: bool = False, root: Path | N
|
||||
if target.is_file():
|
||||
return [target]
|
||||
_EXTENSIONS = set(_DISPATCH.keys())
|
||||
from graphify.detect import _load_graphifyignore, _is_ignored, _is_noise_dir
|
||||
from graphify.detect import _is_ignored, _is_noise_dir, _load_graphifyignore
|
||||
ignore_root = root if root is not None else target
|
||||
patterns = _load_graphifyignore(ignore_root)
|
||||
|
||||
|
||||
@@ -0,0 +1,602 @@
|
||||
import unittest
|
||||
import tempfile
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
from graphify.extract import extract_dart, _make_id, _file_stem
|
||||
|
||||
|
||||
class TestDart(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.temp_path = Path(self.temp_dir.name)
|
||||
|
||||
def tearDown(self):
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def test_universal_generic_syntax_extraction(self):
|
||||
"""Test that the universal parser successfully extracts generic relationships, annotations, extensions, classes, and generic calls."""
|
||||
code_content = textwrap.dedent("""
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
export 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
// 1. Class declarations with generics, inheritance, and implements
|
||||
@injectable
|
||||
@HiveType(typeId: 10)
|
||||
class UserBloc extends Bloc<UserEvent, UserState> with MyMixin implements Disposable {
|
||||
UserBloc() : super(InitialState());
|
||||
}
|
||||
|
||||
|
||||
// 2. Enum declarations
|
||||
@jsonSerializable
|
||||
enum UserRole { admin, user }
|
||||
|
||||
// 3. Extensions
|
||||
extension StringExtensions on String {
|
||||
bool get isEmail => contains('@');
|
||||
}
|
||||
|
||||
// 4. Top-level variables
|
||||
final authServiceProvider = Provider<AuthService>((ref) => AuthService());
|
||||
final myData = 42;
|
||||
|
||||
// 5. Generic method invocations (automatically catches GetIt, Provider, BlocProvider, InheritedWidget!)
|
||||
void checkDependencies(BuildContext context) {
|
||||
final custom = context.dependOnInheritedWidgetOfExactType<CustomService>();
|
||||
final auth = context.read<AuthService>();
|
||||
final bloc = BlocProvider.of<UserBloc>(context);
|
||||
final getItService = GetIt.I<DatabaseService>();
|
||||
final locatorService = locator<api.NetworkFactory>();
|
||||
|
||||
}
|
||||
""")
|
||||
|
||||
file_path = self.temp_path / "test_app_bloc.dart"
|
||||
file_path.write_text(code_content, encoding="utf-8")
|
||||
|
||||
result = extract_dart(file_path)
|
||||
|
||||
self.assertIn("nodes", result)
|
||||
self.assertIn("edges", result)
|
||||
|
||||
nodes = result["nodes"]
|
||||
edges = result["edges"]
|
||||
|
||||
# A. File node check
|
||||
file_node = next(
|
||||
(n for n in nodes if n["file_type"] == "code" and n["label"] == "test_app_bloc.dart"),
|
||||
None,
|
||||
)
|
||||
self.assertIsNotNone(file_node)
|
||||
self.assertEqual(file_node["source_file"], str(file_path))
|
||||
|
||||
# B. Class & Enum extraction check
|
||||
user_bloc_node = next((n for n in nodes if n["label"] == "UserBloc"), None)
|
||||
self.assertIsNotNone(user_bloc_node)
|
||||
self.assertEqual(user_bloc_node["source_file"], str(file_path))
|
||||
|
||||
user_role_node = next((n for n in nodes if n["label"] == "UserRole"), None)
|
||||
self.assertIsNotNone(user_role_node)
|
||||
|
||||
# C. Inherits & Generics
|
||||
# Inherits Bloc (Should be global ID "bloc" without stem, source_file is None)
|
||||
inherits_bloc = next(
|
||||
(
|
||||
e
|
||||
for e in edges
|
||||
if e["source"] == user_bloc_node["id"] and e["relation"] == "inherits"
|
||||
),
|
||||
None,
|
||||
)
|
||||
self.assertIsNotNone(inherits_bloc)
|
||||
self.assertEqual(inherits_bloc["target"], "bloc")
|
||||
|
||||
bloc_node = next((n for n in nodes if n["id"] == "bloc"), None)
|
||||
self.assertIsNotNone(bloc_node)
|
||||
self.assertIsNone(bloc_node["source_file"])
|
||||
|
||||
# References UserEvent, UserState generics (Should be global IDs without stem, source_file is None)
|
||||
ref_event = next(
|
||||
(
|
||||
e
|
||||
for e in edges
|
||||
if e["source"] == user_bloc_node["id"]
|
||||
and e["relation"] == "references"
|
||||
and e["target"] == "userevent"
|
||||
),
|
||||
None,
|
||||
)
|
||||
self.assertIsNotNone(ref_event)
|
||||
|
||||
event_node = next((n for n in nodes if n["id"] == "userevent"), None)
|
||||
self.assertIsNotNone(event_node)
|
||||
self.assertIsNone(event_node["source_file"])
|
||||
|
||||
ref_state = next(
|
||||
(
|
||||
e
|
||||
for e in edges
|
||||
if e["source"] == user_bloc_node["id"]
|
||||
and e["relation"] == "references"
|
||||
and e["target"] == "userstate"
|
||||
),
|
||||
None,
|
||||
)
|
||||
self.assertIsNotNone(ref_state)
|
||||
|
||||
# D. Generic Class Annotations (Should be global annotation ID, source_file is None)
|
||||
injectable_annotation = next((n for n in nodes if n["label"] == "@injectable"), None)
|
||||
self.assertIsNotNone(injectable_annotation)
|
||||
self.assertEqual(injectable_annotation["id"], "annotation_injectable")
|
||||
self.assertIsNone(injectable_annotation["source_file"])
|
||||
|
||||
configures_injectable = next(
|
||||
(
|
||||
e
|
||||
for e in edges
|
||||
if e["source"] == user_bloc_node["id"]
|
||||
and e["target"] == injectable_annotation["id"]
|
||||
and e["relation"] == "configures"
|
||||
),
|
||||
None,
|
||||
)
|
||||
self.assertIsNotNone(configures_injectable)
|
||||
|
||||
# Mixin check (Should have global ID "mymixin" and implements edge)
|
||||
ref_mixin = next(
|
||||
(
|
||||
e
|
||||
for e in edges
|
||||
if e["source"] == user_bloc_node["id"]
|
||||
and e["target"] == "mymixin"
|
||||
and e["relation"] == "implements"
|
||||
),
|
||||
None,
|
||||
)
|
||||
self.assertIsNotNone(ref_mixin)
|
||||
|
||||
# E. Extensions (target class string should be global without stem, source_file is None)
|
||||
ext_node = next((n for n in nodes if n["label"] == "StringExtensions"), None)
|
||||
self.assertIsNotNone(ext_node)
|
||||
|
||||
extends_string = next(
|
||||
(e for e in edges if e["source"] == ext_node["id"] and e["relation"] == "extends"), None
|
||||
)
|
||||
self.assertIsNotNone(extends_string)
|
||||
self.assertEqual(extends_string["target"], "string")
|
||||
|
||||
# F. Variable declarations
|
||||
provider_var = next((n for n in nodes if n["label"] == "authServiceProvider"), None)
|
||||
self.assertIsNotNone(provider_var)
|
||||
|
||||
# G. Universal Generic Invocation mappings (Auto-resolved without hardcoding packages!)
|
||||
ref_custom = next(
|
||||
(
|
||||
e
|
||||
for e in edges
|
||||
if e["source"] == file_node["id"]
|
||||
and e["target"] == "customservice"
|
||||
and e["relation"] == "references"
|
||||
),
|
||||
None,
|
||||
)
|
||||
self.assertIsNotNone(ref_custom)
|
||||
|
||||
custom_node = next((n for n in nodes if n["id"] == "customservice"), None)
|
||||
self.assertIsNotNone(custom_node)
|
||||
self.assertIsNone(custom_node["source_file"])
|
||||
|
||||
ref_net = next(
|
||||
(
|
||||
e
|
||||
for e in edges
|
||||
if e["source"] == file_node["id"]
|
||||
and e["target"] == "networkfactory"
|
||||
and e["relation"] == "references"
|
||||
),
|
||||
None,
|
||||
)
|
||||
self.assertIsNotNone(ref_net)
|
||||
|
||||
# H. Imports and Exports (Should have global ID, source_file is None)
|
||||
import_node = next((n for n in nodes if n["id"] == "package_flutter_material_dart"), None)
|
||||
self.assertIsNotNone(import_node)
|
||||
self.assertIsNone(import_node["source_file"])
|
||||
self.assertEqual(import_node["label"], "package:flutter/material.dart")
|
||||
|
||||
export_node = next(
|
||||
(n for n in nodes if n["id"] == "package_flutter_bloc_flutter_bloc_dart"), None
|
||||
)
|
||||
self.assertIsNotNone(export_node)
|
||||
self.assertIsNone(export_node["source_file"])
|
||||
self.assertEqual(export_node["label"], "package:flutter_bloc/flutter_bloc.dart")
|
||||
|
||||
export_edge = next(
|
||||
(
|
||||
e
|
||||
for e in edges
|
||||
if e["source"] == file_node["id"]
|
||||
and e["target"] == export_node["id"]
|
||||
and e["relation"] == "exports"
|
||||
),
|
||||
None,
|
||||
)
|
||||
self.assertIsNotNone(export_edge)
|
||||
|
||||
def test_advanced_dart_features(self):
|
||||
"""Test complex Dart 3+ syntax and precise Riverpod/Bloc mappings."""
|
||||
code_content = textwrap.dedent("""
|
||||
import 'package:riverpod/riverpod.dart';
|
||||
|
||||
# 1. Combined Modifiers & Mixin Class
|
||||
abstract base class MyBaseClass {}
|
||||
abstract interface class MyInterface {}
|
||||
mixin class MyMixinClass {}
|
||||
|
||||
# 2. Riverpod Functional & Class Providers with Codegen
|
||||
@riverpod
|
||||
class MyNotifier extends _$MyNotifier {
|
||||
@override
|
||||
String build() {
|
||||
ref.watch(anotherProvider);
|
||||
return "hello";
|
||||
}
|
||||
}
|
||||
|
||||
@riverpod
|
||||
String myValue(MyValueRef ref) {
|
||||
return "world";
|
||||
}
|
||||
|
||||
# 3. Late & Non-Initialized Final Fields
|
||||
class MyModel {
|
||||
late final String lateField;
|
||||
final int noInitField;
|
||||
final String initField = "init";
|
||||
}
|
||||
|
||||
# 4. Records & Pattern Matching in variables
|
||||
final (int, String) typedRecord = (1, "one");
|
||||
var (recA, recB) = (10, 20);
|
||||
|
||||
# 5. Records in method returns & switch expressions
|
||||
(double, double) getCoordinates() {
|
||||
var localVal = switch (typedRecord) {
|
||||
(int a, String b) => (1.0, 2.0),
|
||||
_ => (0.0, 0.0),
|
||||
};
|
||||
return localVal;
|
||||
}
|
||||
|
||||
# 6. Bloc constructor event registration & emission
|
||||
class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
AuthBloc() : super(AuthInitial()) {
|
||||
on<AuthLogin>((event, emit) {
|
||||
emit(AuthLoading());
|
||||
});
|
||||
on<AuthLogout>((event, emit) {
|
||||
yield AuthSuccess();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
# 7. Widget Bloc trigger & bindings
|
||||
class HomeWidget {
|
||||
void triggerLogin(BuildContext context) {
|
||||
context.read<AuthBloc>().add(AuthLogin());
|
||||
}
|
||||
}
|
||||
""")
|
||||
|
||||
file_path = self.temp_path / "test_advanced.dart"
|
||||
file_path.write_text(code_content, encoding="utf-8")
|
||||
|
||||
result = extract_dart(file_path)
|
||||
|
||||
self.assertIn("nodes", result)
|
||||
self.assertIn("edges", result)
|
||||
|
||||
nodes = result["nodes"]
|
||||
edges = result["edges"]
|
||||
|
||||
# Check classes
|
||||
base_class = next((n for n in nodes if n["label"] == "MyBaseClass"), None)
|
||||
self.assertIsNotNone(base_class)
|
||||
|
||||
interface_class = next((n for n in nodes if n["label"] == "MyInterface"), None)
|
||||
self.assertIsNotNone(interface_class)
|
||||
|
||||
mixin_class = next((n for n in nodes if n["label"] == "MyMixinClass"), None)
|
||||
self.assertIsNotNone(mixin_class)
|
||||
# Ensure we didn't mistakenly capture a node named "class"
|
||||
class_false_positive = next((n for n in nodes if n["label"] == "class"), None)
|
||||
self.assertIsNone(class_false_positive)
|
||||
|
||||
# Check late & final fields
|
||||
late_field = next((n for n in nodes if n["label"] == "lateField"), None)
|
||||
self.assertIsNotNone(late_field)
|
||||
|
||||
no_init_field = next((n for n in nodes if n["label"] == "noInitField"), None)
|
||||
self.assertIsNotNone(no_init_field)
|
||||
|
||||
init_field = next((n for n in nodes if n["label"] == "initField"), None)
|
||||
self.assertIsNotNone(init_field)
|
||||
|
||||
# Check records & destructuring
|
||||
typed_rec = next((n for n in nodes if n["label"] == "typedRecord"), None)
|
||||
self.assertIsNotNone(typed_rec)
|
||||
|
||||
rec_a = next((n for n in nodes if n["label"] == "recA"), None)
|
||||
self.assertIsNotNone(rec_a)
|
||||
rec_b = next((n for n in nodes if n["label"] == "recB"), None)
|
||||
self.assertIsNotNone(rec_b)
|
||||
|
||||
# Ensure deep nested variable switch-expression 'localVal' is not extracted as a top-level define
|
||||
local_val = next((n for n in nodes if n["label"] == "localVal"), None)
|
||||
self.assertIsNone(local_val)
|
||||
|
||||
# Check record-returning method
|
||||
get_coord = next((n for n in nodes if n["label"] == "getCoordinates"), None)
|
||||
self.assertIsNotNone(get_coord)
|
||||
|
||||
# Check Riverpod codegen defines
|
||||
mynotifier_provider = next((n for n in nodes if n["label"] == "myNotifierProvider"), None)
|
||||
self.assertIsNotNone(mynotifier_provider)
|
||||
|
||||
myvalue_provider = next((n for n in nodes if n["label"] == "myValueProvider"), None)
|
||||
self.assertIsNotNone(myvalue_provider)
|
||||
|
||||
# Check Riverpod watcher references
|
||||
ref_edge = next(
|
||||
(
|
||||
e
|
||||
for e in edges
|
||||
if e["target"] == "anotherprovider" and e["relation"] == "references"
|
||||
),
|
||||
None,
|
||||
)
|
||||
self.assertIsNotNone(ref_edge)
|
||||
|
||||
# Check Bloc constructor events & emissions
|
||||
login_edge = next(
|
||||
(e for e in edges if e["target"] == "authlogin" and e["context"] == "bloc_event"), None
|
||||
)
|
||||
self.assertIsNotNone(login_edge)
|
||||
|
||||
emit_edge = next(
|
||||
(e for e in edges if e["target"] == "authloading" and e["context"] == "emit_state"),
|
||||
None,
|
||||
)
|
||||
self.assertIsNotNone(emit_edge)
|
||||
|
||||
# Check Widget Bloc trigger
|
||||
trigger_edge = next(
|
||||
(e for e in edges if e["target"] == "authlogin" and e["context"] == "bloc_add_event"),
|
||||
None,
|
||||
)
|
||||
self.assertIsNotNone(trigger_edge)
|
||||
|
||||
lookup_edge = next(
|
||||
(e for e in edges if e["target"] == "authbloc" and e["context"] == "bloc_lookup"), None
|
||||
)
|
||||
self.assertIsNotNone(lookup_edge)
|
||||
|
||||
def test_namespace_and_spaced_generics(self):
|
||||
"""Test that the parser successfully handles namespaces in extends/implements, and spaces/commas in nested generic variables and methods."""
|
||||
code_content = textwrap.dedent("""
|
||||
class MyWidget extends foo.Bar<Map<String, int>> implements ui.Widget, db.Model {}
|
||||
|
||||
final Map<String, int> myVar = 10;
|
||||
const List<Map<String, int>> myList = [];
|
||||
late final auth.AuthService authService;
|
||||
|
||||
Map<String, Map<String, int>> myMethod(String a) {}
|
||||
auth.AuthService init() {}
|
||||
""")
|
||||
|
||||
file_path = self.temp_path / "test_namespaces.dart"
|
||||
file_path.write_text(code_content, encoding="utf-8")
|
||||
|
||||
result = extract_dart(file_path)
|
||||
nodes = result["nodes"]
|
||||
edges = result["edges"]
|
||||
|
||||
# 1. Namespaced Extends/Implements
|
||||
widget_node = next((n for n in nodes if n["label"] == "MyWidget"), None)
|
||||
self.assertIsNotNone(widget_node)
|
||||
|
||||
# Base class should be 'foo.Bar' -> normalized to 'foo_bar' or 'bar'
|
||||
extends_edge = next(
|
||||
(e for e in edges if e["source"] == widget_node["id"] and e["relation"] == "inherits"),
|
||||
None,
|
||||
)
|
||||
self.assertIsNotNone(extends_edge)
|
||||
self.assertNotEqual(extends_edge["target"], "foo") # Ensure it didn't clip
|
||||
|
||||
# 2. Spaced Generics in Variables
|
||||
self.assertIsNotNone(next((n for n in nodes if n["label"] == "myVar"), None))
|
||||
self.assertIsNotNone(next((n for n in nodes if n["label"] == "myList"), None))
|
||||
self.assertIsNotNone(next((n for n in nodes if n["label"] == "authService"), None))
|
||||
|
||||
# 3. Spaced Generics & Namespaces in Methods
|
||||
self.assertIsNotNone(next((n for n in nodes if n["label"] == "myMethod"), None))
|
||||
self.assertIsNotNone(next((n for n in nodes if n["label"] == "init"), None))
|
||||
|
||||
def test_dart_and_flutter_specifics(self):
|
||||
"""Test typedefs, mixin on, factories, constructor DI types, and universal navigation."""
|
||||
code_content = textwrap.dedent("""
|
||||
mixin AuthMixin on BaseWidget {}
|
||||
typedef JsonMap = Map<String, dynamic>;
|
||||
extension type UserId(int value) implements Object {}
|
||||
|
||||
class MyService {
|
||||
final AuthService api;
|
||||
MyService(this.api);
|
||||
|
||||
factory MyService.fromJson() {}
|
||||
|
||||
void navigate(BuildContext context) {
|
||||
context.go('/home');
|
||||
Navigator.pushNamed(context, Routes.login);
|
||||
context.router.push(ProfileRoute());
|
||||
}
|
||||
}
|
||||
""")
|
||||
|
||||
file_path = self.temp_path / "test_specifics.dart"
|
||||
file_path.write_text(code_content, encoding="utf-8")
|
||||
|
||||
result = extract_dart(file_path)
|
||||
nodes = result["nodes"]
|
||||
edges = result["edges"]
|
||||
|
||||
# 1. Mixin 'on' relation
|
||||
auth_mixin = next((n for n in nodes if n["label"] == "AuthMixin"), None)
|
||||
self.assertIsNotNone(auth_mixin)
|
||||
inherits_base = next(
|
||||
(
|
||||
e
|
||||
for e in edges
|
||||
if e["source"] == auth_mixin["id"]
|
||||
and e["relation"] == "inherits"
|
||||
and e["target"] == "basewidget"
|
||||
),
|
||||
None,
|
||||
)
|
||||
self.assertIsNotNone(inherits_base)
|
||||
|
||||
# 2. Typedefs
|
||||
json_map = next((n for n in nodes if n["label"] == "JsonMap"), None)
|
||||
self.assertIsNotNone(json_map)
|
||||
|
||||
# 3. Variable DI Type (AuthService)
|
||||
api_var = next((n for n in nodes if n["label"] == "api"), None)
|
||||
self.assertIsNotNone(api_var)
|
||||
ref_auth = next(
|
||||
(
|
||||
e
|
||||
for e in edges
|
||||
if e["target"] == "authservice"
|
||||
and e["relation"] == "references"
|
||||
and e["context"] == "variable_type"
|
||||
),
|
||||
None,
|
||||
)
|
||||
self.assertIsNotNone(ref_auth)
|
||||
|
||||
# 4. Factories
|
||||
from_json = next((n for n in nodes if n["label"] == "fromJson"), None)
|
||||
self.assertIsNotNone(from_json)
|
||||
|
||||
# 5. Universal Navigation
|
||||
nav_home = next(
|
||||
(e for e in edges if e["relation"] == "navigates" and e["context"] == "route_path"),
|
||||
None,
|
||||
)
|
||||
self.assertIsNotNone(nav_home)
|
||||
nav_login = next(
|
||||
(e for e in edges if e["relation"] == "navigates" and e["context"] == "route_const"),
|
||||
None,
|
||||
)
|
||||
self.assertIsNotNone(nav_login)
|
||||
nav_profile = next(
|
||||
(e for e in edges if e["relation"] == "navigates" and e["context"] == "route_object"),
|
||||
None,
|
||||
)
|
||||
self.assertIsNotNone(nav_profile)
|
||||
|
||||
# 6. Extension Types
|
||||
user_id = next((n for n in nodes if n["label"] == "UserId"), None)
|
||||
self.assertIsNotNone(user_id)
|
||||
impl_obj = next(
|
||||
(
|
||||
e
|
||||
for e in edges
|
||||
if e["source"] == user_id["id"]
|
||||
and e["relation"] == "implements"
|
||||
and e["target"] == "object"
|
||||
),
|
||||
None,
|
||||
)
|
||||
self.assertIsNotNone(impl_obj)
|
||||
|
||||
def test_roadmap_bug_fixes(self):
|
||||
"""Test all 5 roadmap bug fixes (Bug A, B, C, D, E)."""
|
||||
# Create parent and part child files to test Bug D (Part of file redirect)
|
||||
parent_file = self.temp_path / "parent_lib.dart"
|
||||
parent_file.write_text("library parent_lib;\npart 'child_part.dart';", encoding="utf-8")
|
||||
|
||||
child_code = textwrap.dedent("""
|
||||
part of 'parent_lib.dart';
|
||||
|
||||
class ChildClass extends Bloc<Pair<UserEvent, MyState>, State> {}
|
||||
|
||||
var User(name: myVar, age: myAge) = user;
|
||||
|
||||
void runDI(BuildContext context) {
|
||||
final repo = locator<Repository<User>>();
|
||||
context.go('/home?id=123&type=auth');
|
||||
}
|
||||
""")
|
||||
child_file = self.temp_path / "child_part.dart"
|
||||
child_file.write_text(child_code, encoding="utf-8")
|
||||
|
||||
# Parse child file and verify redirect
|
||||
result = extract_dart(child_file)
|
||||
nodes = result["nodes"]
|
||||
edges = result["edges"]
|
||||
|
||||
# A. Bug D redirect: No child file node should be created in nodes
|
||||
child_node = next((n for n in nodes if n["label"] == "child_part.dart"), None)
|
||||
self.assertIsNone(child_node)
|
||||
|
||||
# B. Check that defines edge source is parent file ID
|
||||
parent_fid = _make_id(str(parent_file.resolve()))
|
||||
child_class = next((n for n in nodes if n["label"] == "ChildClass"), None)
|
||||
self.assertIsNotNone(child_class)
|
||||
|
||||
def_edge = next(
|
||||
(e for e in edges if e["target"] == child_class["id"] and e["relation"] == "defines"),
|
||||
None,
|
||||
)
|
||||
self.assertIsNotNone(def_edge)
|
||||
self.assertEqual(def_edge["source"], parent_fid)
|
||||
|
||||
# C. Bug A safe generic inheritance commas split: check referenced generics
|
||||
# Bloc<Pair<UserEvent, MyState>, State> should reference 'Pair<UserEvent, MyState>' and 'State'
|
||||
# 'Pair<UserEvent, MyState>' will be clean matched to 'Pair' node
|
||||
pair_node = next((n for n in nodes if n["id"] == "pair"), None)
|
||||
self.assertIsNotNone(pair_node)
|
||||
state_node = next((n for n in nodes if n["id"] == "state"), None)
|
||||
self.assertIsNotNone(state_node)
|
||||
# Ensure 'MyState>' or 'UserEvent' are NOT mistakenly generated as top-level generic reference nodes from broken comma-split!
|
||||
bad_node1 = next((n for n in nodes if "mystate" in n["id"]), None)
|
||||
self.assertIsNone(bad_node1)
|
||||
|
||||
# D. Bug B double generics DI lookup: locator<Repository<User>>()
|
||||
repo_node = next((n for n in nodes if n["id"] == "repository"), None)
|
||||
self.assertIsNotNone(repo_node)
|
||||
|
||||
# E. Bug E object destructuring variables: myVar, myAge
|
||||
self.assertIsNotNone(next((n for n in nodes if n["label"] == "myVar"), None))
|
||||
self.assertIsNotNone(next((n for n in nodes if n["label"] == "myAge"), None))
|
||||
# Ensure "name: myVar" or ":myVar" are NOT registered as variables!
|
||||
self.assertIsNone(
|
||||
next((n for n in nodes if "name" in n["label"] or "age" in n["label"]), None)
|
||||
)
|
||||
|
||||
# F. Bug C GoRouter query parameter route mapping
|
||||
nav_edge = next(
|
||||
(e for e in edges if e["relation"] == "navigates" and e["context"] == "route_path"),
|
||||
None,
|
||||
)
|
||||
self.assertIsNotNone(nav_edge)
|
||||
self.assertEqual(nav_edge["target"], "route_home_id_123_type_auth")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user