diff --git a/CHANGELOG.md b/CHANGELOG.md index b43b07e3..90c3bb5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,20 @@ uses [Semantic Versioning](https://semver.org/). impl/trait methods become components, with trait-impl, field-type, struct-literal, and call edges. `#[cfg(test)]` modules are skipped, and `build.rs` is classified as a build artifact. +- **Dart / Flutter analyzer.** Classes, mixins, extensions, enums, top-level + functions and getters, and methods become components, with inheritance, + field-type, instantiation and call edges. `package:`/relative imports, + exports and `part` files decide which definition a name resolves to, and + library-private names never resolve outside their library; names not + visible through imports are dropped rather than guessed. Without a + `pubspec.yaml` in the analyzed tree, `package:` imports cannot be resolved, + so Dart calls fall back to name matching across Dart files (with a + warning). Flutter-aware: + widgets and State classes are typed, instantiations in `build()` become + widget-to-widget edges, `ref.watch`/`ref.read` link widgets to Riverpod + providers (including `@riverpod` codegen), and GoRouter routes link to their + screens. Generated `*.g.dart`/`*.freezed.dart` files and `.dart_tool/` are + ignored, and Dart modules get Flutter-specific documentation guidance. ### Changed diff --git a/README.md b/README.md index 92051d5b..95a6edfa 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ Full list in the [CHANGELOG](./CHANGELOG.md). ## Features **Languages.** Python, Java, JavaScript, TypeScript, C, C++, C#, Kotlin, -PHP, Ruby, Scala, Rust. +PHP, Ruby, Scala, Rust, Dart (Flutter-aware). **Hierarchical decomposition.** The dependency graph is clustered into a module tree, recursively, so a 1.4M-line repository gets the same treatment diff --git a/codewiki/cli/main.py b/codewiki/cli/main.py index 98b9fe3e..254c360d 100644 --- a/codewiki/cli/main.py +++ b/codewiki/cli/main.py @@ -20,7 +20,7 @@ def cli(ctx): AI agents write the documentation from your dependency graph. Supports Python, Java, JavaScript, TypeScript, C, C++, C#, Kotlin, PHP, Ruby, - Scala, and Rust. + Scala, Rust, and Dart. """ # Ensure context object exists ctx.ensure_object(dict) diff --git a/codewiki/cli/utils/repo_validator.py b/codewiki/cli/utils/repo_validator.py index a380d4af..63da2646 100644 --- a/codewiki/cli/utils/repo_validator.py +++ b/codewiki/cli/utils/repo_validator.py @@ -34,6 +34,7 @@ ".scala", # Scala ".sc", # Scala Scripts ".rs", # Rust + ".dart", # Dart } @@ -65,7 +66,7 @@ def validate_repository(repo_path: Path) -> tuple[Path, list[tuple[str, int]]]: raise RepositoryError( f"No supported code files found in {repo_path}\n\n" "CodeWiki supports: Python, Java, JavaScript, TypeScript, C, C++, C#, Kotlin, PHP, " - "Ruby, Scala, Rust\n\n" + "Ruby, Scala, Rust, Dart\n\n" "Please navigate to a code repository or specify a custom directory:\n" " cd /path/to/your/project\n" " codewiki generate" diff --git a/codewiki/cli/utils/validation.py b/codewiki/cli/utils/validation.py index 0cc6adc3..315aa9b2 100644 --- a/codewiki/cli/utils/validation.py +++ b/codewiki/cli/utils/validation.py @@ -168,6 +168,7 @@ def detect_supported_languages(directory: Path) -> list[tuple[str, int]]: "Ruby": [".rb"], "Scala": [".scala", ".sc"], "Rust": [".rs"], + "Dart": [".dart"], } # Directories to exclude from counting diff --git a/codewiki/mcp/tools/analysis.py b/codewiki/mcp/tools/analysis.py index 5c67ad44..3a4f8df5 100644 --- a/codewiki/mcp/tools/analysis.py +++ b/codewiki/mcp/tools/analysis.py @@ -229,6 +229,7 @@ def _detect_via_mtime( ".scala", ".sc", ".rs", + ".dart", } changed: list[str] = [] diff --git a/codewiki/src/be/dependency_analyzer/analysis/analysis_service.py b/codewiki/src/be/dependency_analyzer/analysis/analysis_service.py index 3b55bdc6..861ab97b 100644 --- a/codewiki/src/be/dependency_analyzer/analysis/analysis_service.py +++ b/codewiki/src/be/dependency_analyzer/analysis/analysis_service.py @@ -340,7 +340,7 @@ def _filter_supported_languages(self, code_files: list[dict]) -> list[dict]: Filter code files to only include supported languages. Supports Python, JavaScript, TypeScript, Java, C#, C, C++, PHP, Ruby, Kotlin, - Scala, and Rust. + Scala, Rust, and Dart. """ supported_languages = { "python", @@ -356,6 +356,7 @@ def _filter_supported_languages(self, code_files: list[dict]) -> list[dict]: "rust", "kotlin", "scala", + "dart", } return [ @@ -379,6 +380,7 @@ def _get_supported_languages(self) -> list[str]: "kotlin", "scala", "rust", + "dart", ] def _cleanup_repository(self, temp_dir: str): diff --git a/codewiki/src/be/dependency_analyzer/analysis/call_graph_analyzer.py b/codewiki/src/be/dependency_analyzer/analysis/call_graph_analyzer.py index 8c127aad..1cc33ec0 100644 --- a/codewiki/src/be/dependency_analyzer/analysis/call_graph_analyzer.py +++ b/codewiki/src/be/dependency_analyzer/analysis/call_graph_analyzer.py @@ -62,6 +62,10 @@ class CallGraphAnalyzer: def __init__(self): """Initialize the call graph analyzer.""" self.functions: dict[str, Node] = {} + self._dart_directives: dict[str, list] = {} + self._dart_visible: dict[str, set[str]] = {} + self._dart_library_members: dict[str, set[str]] = {} + self._dart_unscoped = False self.call_relationships: list[CallRelationship] = [] self._python_project_modules: set = set() self._python_external_import_roots: set = set() @@ -85,6 +89,10 @@ def analyze_code_files(self, code_files: list[dict], base_dir: str) -> dict: code_files = self._route_contextual_headers(code_files, base_dir) self._python_project_modules = self._collect_python_modules(code_files) self._python_external_import_roots = set() + self._dart_directives = {} + self._dart_visible = {} + self._dart_library_members = {} + self._dart_unscoped = False files_analyzed = 0 files_failed = 0 @@ -116,6 +124,9 @@ def analyze_code_files(self, code_files: list[dict], base_dir: str) -> dict: f"{files_failed} failed, {len(self.functions)} functions, {len(self.call_relationships)} relationships ({elapsed_time:.1f}s)" ) + if self._dart_directives: + self._build_dart_scopes(base_dir) + logger.debug("Resolving call relationships") self._resolve_call_relationships() self._deduplicate_relationships() @@ -261,6 +272,8 @@ def _analyze_code_file(self, repo_dir: str, file_info: dict): self._analyze_scala_file(file_path, content, repo_dir) elif language == "rust": self._analyze_rust_file(file_path, content, repo_dir) + elif language == "dart": + self._analyze_dart_file(file_path, content, repo_dir) # else: # logger.warning( # f"Unsupported language for call graph analysis: {language} for file {file_path}" @@ -560,6 +573,136 @@ def _analyze_rust_file(self, file_path: str, content: str, repo_dir: str): except Exception: logger.exception(f"Failed to analyze Rust file {file_path}") + def _analyze_dart_file(self, file_path: str, content: str, repo_dir: str): + """ + Analyze Dart file using tree-sitter based analyzer. + + Besides components and relationships, records the file's + import/export/part directives for Dart scope-aware resolution. + + Args: + file_path: Path to the Dart file + content: File content string + repo_dir: Repository base directory + """ + from codewiki.src.be.dependency_analyzer.analyzers.dart import TreeSitterDartAnalyzer + + try: + analyzer = TreeSitterDartAnalyzer(str(file_path), content, repo_path=repo_dir) + + for func in analyzer.nodes: + func_id = func.id if func.id else f"{file_path}:{func.name}" + self.functions[func_id] = func + + self.call_relationships.extend(analyzer.call_relationships) + self._dart_directives[analyzer.relative_path] = analyzer.directives + except Exception: + logger.exception(f"Failed to analyze Dart file {file_path}") + + def _build_dart_scopes(self, base_dir: str) -> None: + """Resolve recorded Dart directives into per-file visibility scopes.""" + from codewiki.src.be.dependency_analyzer.analyzers.dart_imports import ( + DartPackageResolver, + build_scopes, + ) + + files = set(self._dart_directives) + resolver = DartPackageResolver.from_files(base_dir, files) + scopes = build_scopes(self._dart_directives, resolver, files) + self._dart_library_members = scopes.library_members + if resolver.packages: + self._dart_visible = scopes.visible + return + # Without a pubspec.yaml (e.g. only `lib/` was analyzed) `package:` + # imports resolve to nothing, and scoping would drop nearly every + # cross-file edge. Match names across all Dart files instead. + self._dart_unscoped = True + logger.warning( + "No pubspec.yaml found for the analyzed Dart files; `package:` imports " + "cannot be resolved, so Dart calls are matched by name across Dart files." + ) + + def _dart_scope_candidates( + self, func_ids: list[str], scope: set[str] | None, caller_file: str | None = None + ) -> list[str]: + from codewiki.src.be.dependency_analyzer.analyzers.dart_imports import ( + is_private_dart_name, + ) + + own_library = self._dart_library_members.get(caller_file, set()) if caller_file else set() + candidates: list[str] = [] + for func_id in func_ids: + func = self.functions.get(func_id) + if func_id in candidates or func is None: + continue + func_file = Path(func.relative_path).as_posix() + if scope is not None and func_file not in scope: + continue + # A `_`-prefixed segment in the target (`_Ticker.tick`) is private + # to its library, whatever the callee's own spelling. + if is_private_dart_name(func.name) and func_file not in own_library: + continue + candidates.append(func_id) + return candidates + + def _resolve_dart_callee( + self, callee: str, caller: Node, lang_indexes: dict | None + ) -> str | None: + """Prefer definitions the caller's library can see (imports, parts, + re-exports); library-private names only resolve inside the library.""" + from codewiki.src.be.dependency_analyzer.analyzers.dart_imports import ( + is_private_dart_name, + ) + + if not lang_indexes or "::" in callee: + return None + caller_file = Path(caller.relative_path).as_posix() + scope: set[str] | None + if is_private_dart_name(callee): + scope = self._dart_library_members.get(caller_file, {caller_file}) + elif self._dart_unscoped: + scope = None # any Dart file + else: + scope = self._dart_visible.get(caller_file) + if not scope: + return None + keys = [callee] + if "." in callee: + keys.append(callee.split(".")[0]) + for key in keys: + # Exact names first: an imported `helper()` must not turn ambiguous + # because some visible class also has a `helper` method. + for index in ("exact", "simple"): + candidates = self._dart_scope_candidates( + lang_indexes[index].get(key, []), scope, caller_file + ) + if len(candidates) == 1: + return candidates[0] + if candidates: + break + + from codewiki.src.be.dependency_analyzer.analyzers.dart_flutter import ( + RIVERPOD_NOTIFIER_TYPE, + RIVERPOD_PROVIDER_TYPE, + provider_alias_candidates, + ) + + # riverpod_generator providers live in ignored *.g.dart files: map + # `fooProvider` to the @riverpod declaration it was generated from. + for alias in provider_alias_candidates(callee): + candidates = [ + func_id + for func_id in self._dart_scope_candidates( + lang_indexes["exact"].get(alias, []), scope, caller_file + ) + if self.functions[func_id].node_type + in (RIVERPOD_PROVIDER_TYPE, RIVERPOD_NOTIFIER_TYPE) + and self.functions[func_id].name == alias + ] + if len(candidates) == 1: + return candidates[0] + return None + def _resolve_call_relationships(self): """ Resolve function call relationships across all languages. @@ -590,13 +733,21 @@ def _resolve_call_relationships(self): relationship for relationship in self.call_relationships if relationship.is_resolved - or not self._is_external_callee( + or not self._is_unresolvable_dart(relationship) + and not self._is_external_callee( self._caller_language(relationship.caller), relationship.callee, dotted_packages, ) ] + def _is_unresolvable_dart(self, relationship: CallRelationship) -> bool: + """A Dart caller's callee that Dart resolution rejected is external or + invisible; drop it so ast_parser's name-only fallback cannot bind it + to an unrelated component.""" + caller = self.functions.get(relationship.caller) + return caller is not None and caller.language == "dart" + def _dotted_project_packages(self) -> dict[str, set]: """Project packages/namespaces, partitioned by language. Java and C# share the namespace-origin rule: a dotted callee qualified to a package @@ -637,6 +788,16 @@ def _is_external_callee( return True if language == "python" and "." in callee: return self._is_external_python_callee(callee) + if language == "dart": + from codewiki.src.be.dependency_analyzer.analyzers.dart_imports import ( + is_private_dart_name, + ) + + # A library-private name that did not resolve inside its library + # can never be a component elsewhere; dropping it keeps the + # name-based fallback from binding another library's `_Body`. + if is_private_dart_name(callee): + return True return False def _is_external_python_callee(self, callee: str) -> bool: @@ -721,6 +882,10 @@ def _resolve_callee( caller_language = caller.language if caller else None lang_indexes = indexes["by_lang"].get(caller_language) if caller_language else None + if caller_language == "dart" and caller is not None: + # Dart resolution is authoritative: it never falls back to name + # guessing in other languages or invisible libraries. + return self._resolve_dart_callee(relationship.callee, caller, lang_indexes) if lang_indexes: match = self._resolve_callee_in( relationship, lang_indexes["exact"], lang_indexes["simple"] @@ -860,6 +1025,8 @@ def _generate_visualization_data(self) -> dict: node_classes.append("lang-scala") elif file_ext == ".rs": node_classes.append("lang-rust") + elif file_ext == ".dart": + node_classes.append("lang-dart") cytoscape_elements.append( { diff --git a/codewiki/src/be/dependency_analyzer/analyzers/dart.py b/codewiki/src/be/dependency_analyzer/analyzers/dart.py new file mode 100644 index 00000000..9f5a0b35 --- /dev/null +++ b/codewiki/src/be/dependency_analyzer/analyzers/dart.py @@ -0,0 +1,932 @@ +"""Tree-sitter based Dart analyzer. + +Extracts classes, mixins, extensions, enums, top-level functions and +getters, and their methods/getters as documentable components, plus +inheritance, field-type, instantiation and call dependency edges. +Import/part handling lives in ``dart_imports.py``; Flutter knowledge +(widgets, state classes, Riverpod, GoRouter) in ``dart_flutter.py``. + +Component types follow the Rust/Scala two-level split: ``component_type`` +is the coarse type leaf selection gates on ("class", "interface", +"method", "function"), while ``node_type`` and ``display_name`` keep the +Dart/Flutter construct ("mixin", "extension", "enum", "widget", ...). + +The grammar comes from tree-sitter-language-pack (ABI 14, compatible with +the pinned tree-sitter 0.23 runtime). It has no call node: a call is a +primary (identifier/this/super) followed by sibling ``selector`` nodes, +the last of which holds an ``argument_part``. + +Known limitations: +- Dart 3.3+ ``extension type``, unnamed ``library;`` and null-aware + collection elements (``?x``) parse as ERROR nodes; declarations outside + the broken span are still extracted. +- Generic invocations ``Foo(x)`` parse as relational expressions and + are recovered from the node text. +- Constructors, setters and operators are not components; calls in their + bodies, in initializer lists and in field initializers are attributed + to the enclosing class. +""" + +import logging +import os +import re +from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path + +from tree_sitter_language_pack import get_parser + +from codewiki.src.be.dependency_analyzer.analyzers.dart_flutter import ( + COMPOSITION_METHOD_RE, + REF_METHODS, + RIVERPOD_ANNOTATIONS, + RIVERPOD_NOTIFIER_TYPE, + RIVERPOD_PROVIDER_TYPE, + classify_class, + classify_top_level_initializer, +) +from codewiki.src.be.dependency_analyzer.analyzers.dart_imports import ( + DartDirective, + parse_directives, +) +from codewiki.src.be.dependency_analyzer.models.core import CallRelationship, Node + +logger = logging.getLogger(__name__) + +_CLASS_MODIFIERS = frozenset({"abstract", "sealed", "base", "interface", "final", "mixin"}) +# Member signatures that become components, with their node_type. +_MEMBER_SIGNATURES = {"function_signature": "method", "getter_signature": "getter"} +_TOP_LEVEL_SIGNATURES = {"function_signature": "function", "getter_signature": "getter"} +# Member signatures whose code belongs to the class rather than a component. +_CLASS_LEVEL_SIGNATURES = frozenset( + { + "constructor_signature", + "factory_constructor_signature", + "constant_constructor_signature", + "redirecting_factory_constructor_signature", + "setter_signature", + "operator_signature", + } +) + +# Dart core and ubiquitous Flutter framework types never emitted as edges. +DART_CORE_TYPES = frozenset( + { + "int", + "double", + "num", + "bool", + "String", + "Object", + "dynamic", + "void", + "Never", + "Null", + "Function", + "Type", + "Symbol", + "Record", + "Enum", + "Comparable", + "BigInt", + "List", + "Map", + "Set", + "Iterable", + "Iterator", + "MapEntry", + "Future", + "FutureOr", + "Stream", + "StreamController", + "StreamSubscription", + "Completer", + "Timer", + "Zone", + "Duration", + "DateTime", + "Uri", + "RegExp", + "StringBuffer", + "StackTrace", + "Sink", + "Uint8List", + "Exception", + "Error", + "StateError", + "ArgumentError", + "RangeError", + "FormatException", + "UnimplementedError", + "UnsupportedError", + "Widget", + "BuildContext", + "Key", + "ValueKey", + "GlobalKey", + "UniqueKey", + "ObjectKey", + "State", + "StatelessWidget", + "StatefulWidget", + "WidgetRef", + "Ref", + } +) +# Bare calls to SDK/framework functions that never point at a repo component. +DART_NOISE_CALLS = frozenset( + { + "print", + "debugPrint", + "identical", + "setState", + "jsonEncode", + "jsonDecode", + "unawaited", + "max", + "min", + "runApp", + "assert", + "scheduleMicrotask", + } +) +_GENERIC_CALL_RE = re.compile(r"\A\s*([A-Za-z_$][\w$]*)\s*<([^()]*)>\s*\(", re.S) +_IDENT_RE = re.compile(r"[A-Za-z_$][\w$]*") + +# The bundled tree-sitter Dart grammar predates Dart 3.8 null-aware collection +# elements (`[?a]`, `{'k': ?v}`, `{?x}`) and mis-parses everything after them. +# A `?` directly after `[`, `{`, `,` or `:` (never a ternary/nullable/`?.`) is +# blanked with a space before parsing, so byte/line/column positions are kept. +_NULL_AWARE_ELEMENT_RE = re.compile(r"([\[{,:]\s*)\?(?=[A-Za-z_$(])") + + +def _blank_null_aware_elements(source: str) -> str: + return _NULL_AWARE_ELEMENT_RE.sub(lambda m: m.group(1) + " ", source) + + +def _is_type_name(name: str) -> bool: + stripped = name.lstrip("_$") + return bool(stripped) and stripped[0].isupper() + + +def _selector_tokens(selectors) -> list[tuple[str, str | None, object]]: + """Flatten a selector chain into ("member", name, node) / ("call", None, + argument_part) / ("other", None, node) tokens.""" + tokens: list[tuple[str, str | None, object]] = [] + for selector in selectors: + for child in selector.named_children: + if child.type in ( + "unconditional_assignable_selector", + "conditional_assignable_selector", + ): + ident = _first_child(child, "identifier") + tokens.append(("member", _text(ident) if ident is not None else None, child)) + elif child.type == "argument_part": + tokens.append(("call", None, child)) + else: + tokens.append(("other", None, child)) + return tokens + + +def _local_declared_types(*roots) -> dict[str, str]: + """name -> declared type for parameters and typed locals under ``roots``.""" + types: dict[str, str] = {} + for root in roots: + if root is None: + continue + for node in [root, *_descendants(root)]: + if node.type not in ("formal_parameter", "initialized_variable_definition"): + continue + declared = _first_child(node, "type_identifier") + ident = node.child_by_field_name("name") + if declared is not None and ident is not None: + types[_text(ident)] = _text(declared) + return types + + +@lru_cache(maxsize=1) +def _dart_parser(): + return get_parser("dart") + + +def _text(node) -> str: + return node.text.decode("utf8", errors="replace") if node is not None else "" + + +def _has_riverpod_annotation(annotations) -> bool: + return any(_text(a.child_by_field_name("name")) in RIVERPOD_ANNOTATIONS for a in annotations) + + +def _first_child(node, *types): + return next((c for c in node.named_children if c.type in types), None) if node else None + + +def _class_kind(modifiers: set[str]) -> tuple[str, str]: + if "interface" in modifiers and "abstract" in modifiers: + return "interface", "interface" + if "sealed" in modifiers: + return "class", "sealed class" + if "abstract" in modifiers: + return "class", "abstract class" + if "mixin" in modifiers: + return "class", "mixin class" + if "interface" in modifiers: + return "class", "interface class" + return "class", "class" + + +def _member_signature(wrapper): + if wrapper.type in _MEMBER_SIGNATURES or wrapper.type in _CLASS_LEVEL_SIGNATURES: + return wrapper + return _first_child(wrapper, *_MEMBER_SIGNATURES, *_CLASS_LEVEL_SIGNATURES) + + +def _parameter_names(signature) -> list[str] | None: + params = _first_child(signature, "formal_parameter_list") + if params is None: + return None + names: list[str] = [] + stack = [params] + while stack: + current = stack.pop() + if current.type == "formal_parameter": + ident = current.child_by_field_name("name") + if ident is None: + idents = [n for n in _descendants(current) if n.type == "identifier"] + ident = idents[-1] if idents else None + if ident is not None: + names.append(_text(ident)) + continue + stack.extend(reversed(current.named_children)) + return names + + +def _descendants(node): + stack = list(reversed(node.named_children)) + while stack: + current = stack.pop() + yield current + stack.extend(reversed(current.named_children)) + + +@dataclass +class _ScanTarget: + caller: str + owner: str | None + node: object + signature: object | None = None + method_name: str | None = None + + +class TreeSitterDartAnalyzer: + def __init__(self, file_path: str, content: str, repo_path: str | None = None): + self.file_path = Path(file_path) + self.content = content + self.repo_path = repo_path or "" + self.nodes: list[Node] = [] + self.call_relationships: list[CallRelationship] = [] + self.directives: list[DartDirective] = [] + self.import_prefixes: set[str] = set() + # Same-file symbol table keyed by logical name ("Foo", "Foo.bar"). + self.top_level_nodes: dict[str, Node] = {} + self.seen_relationships: set = set() + self._scan_targets: list[_ScanTarget] = [] + self._field_types: dict[str, dict[str, str]] = {} + self._field_decls: list[tuple[str, object]] = [] + self._type_ref_nodes: dict[str, list] = {} + self._class_extends: dict[str, str | None] = {} + self._type_params: set[str] = set() + self._extension_on: dict[str, str] = {} + self._top_level_vars: list[tuple[str, object]] = [] + self._analyze() + + # ------------------------------------------------------------------ + # Setup + # ------------------------------------------------------------------ + + @property + def relative_path(self) -> str: + if self.repo_path: + try: + return Path(os.path.relpath(str(self.file_path), self.repo_path)).as_posix() + except ValueError: + pass + return self.file_path.as_posix() + + def _component_id(self, logical_name: str) -> str: + return f"{self.relative_path}::{logical_name}" + + def _analyze(self): + try: + tree = _dart_parser().parse(bytes(_blank_null_aware_elements(self.content), "utf8")) + root = tree.root_node + lines = self.content.splitlines() + self.directives = parse_directives(root) + self.import_prefixes = { + d.prefix for d in self.directives if d.kind == "import" and d.prefix + } + self._type_params = { + _text(t) + for n in _descendants(root) + if n.type == "type_parameter" + for t in n.named_children[:1] + } + self._extract_declarations(root, lines) + self._apply_flutter_kinds(root, lines) + self._extract_relationships() + except Exception as e: # noqa: BLE001 — a broken file must not abort the sweep + logger.error(f"Error parsing Dart file {self.file_path}: {e}") + + # ------------------------------------------------------------------ + # Pass 1: declarations + # ------------------------------------------------------------------ + + def _extract_declarations(self, root, lines): + children = root.named_children + for i, child in enumerate(children): + following = children[i + 1] if i + 1 < len(children) else None + body = ( + following if following is not None and following.type == "function_body" else None + ) + if child.type == "class_definition": + self._add_class(child, lines) + elif child.type == "mixin_declaration": + self._add_mixin(child, lines) + elif child.type == "extension_declaration": + self._add_extension(child, lines) + elif child.type == "enum_declaration": + self._add_enum(child, lines) + elif child.type in _TOP_LEVEL_SIGNATURES: + self._add_top_level_function(child, body, lines) + elif child.type == "static_final_declaration_list": + for declaration in child.named_children: + if declaration.type == "static_final_declaration": + name = _first_child(declaration, "identifier") + if name is not None: + self._top_level_vars.append((_text(name), declaration)) + + def _add_class(self, node, lines): + name = _text(node.child_by_field_name("name")) + if not name: + return + modifiers = {c.type for c in node.children} & _CLASS_MODIFIERS + component_type, node_type = _class_kind(modifiers) + superclass = node.child_by_field_name("superclass") + interfaces = node.child_by_field_name("interfaces") + extends = None + mixins: list[str] = [] + if superclass is not None: + first = _first_child(superclass, "type_identifier") + extends = _text(first) if first is not None else None + for holder in (superclass, node): + mixin_node = _first_child(holder, "mixins") + if mixin_node is not None: + mixins = [ + _text(t) for t in mixin_node.named_children if t.type == "type_identifier" + ] + break + implemented = ( + [_text(t) for t in interfaces.named_children if t.type == "type_identifier"] + if interfaces is not None + else [] + ) + bases = [b for b in (extends, *mixins, *implemented) if b] + self._register_type( + node, name, component_type, node_type, lines, bases, [superclass, interfaces] + ) + self._class_extends[name] = extends + self._extract_members(name, node.child_by_field_name("body"), lines) + + def _add_mixin(self, node, lines): + name = _text(_first_child(node, "identifier")) + if not name: + return + on_types = [_text(c) for c in node.named_children if c.type == "type_identifier"] + interfaces = _first_child(node, "interfaces") + implemented = ( + [_text(t) for t in interfaces.named_children if t.type == "type_identifier"] + if interfaces is not None + else [] + ) + on_nodes = [c for c in node.named_children if c.type == "type_identifier"] + self._register_type( + node, name, "class", "mixin", lines, on_types + implemented, [*on_nodes, interfaces] + ) + self._class_extends[name] = None + self._extract_members(name, _first_child(node, "class_body"), lines) + + def _add_extension(self, node, lines): + on_type = node.child_by_field_name("class") + on_name = _text(_first_child(on_type, "type_identifier") or on_type) if on_type else "" + name = _text(node.child_by_field_name("name")) + if not name: + name = f"extension_on_{on_name or 'unknown'}" + if name in self.top_level_nodes: + # A second unnamed extension on the same type must not + # overwrite the first. + name = f"{name}_L{node.start_point[0] + 1}" + self._extension_on[name] = on_name + self._register_type( + node, name, "class", "extension", lines, [on_name] if on_name else [], [on_type] + ) + self._class_extends[name] = None + self._extract_members(name, node.child_by_field_name("body"), lines) + + def _add_enum(self, node, lines): + name = _text(node.child_by_field_name("name")) + if not name: + return + interfaces = _first_child(node, "interfaces") + mixin_node = _first_child(node, "mixins") + bases = [ + _text(t) + for holder in (mixin_node, interfaces) + if holder is not None + for t in holder.named_children + if t.type == "type_identifier" + ] + self._register_type(node, name, "class", "enum", lines, bases, [mixin_node, interfaces]) + self._class_extends[name] = None + self._extract_members(name, node.child_by_field_name("body"), lines) + + def _register_type(self, node, name, component_type, node_type, lines, bases, type_nodes): + self._add_node( + node, node, name, component_type, node_type, lines, base_classes=bases or None + ) + self._type_ref_nodes[name] = [n for n in type_nodes if n is not None] + + def _extract_members(self, owner: str, body, lines): + if body is None: + return + children = body.named_children + for i, child in enumerate(children): + following = children[i + 1] if i + 1 < len(children) else None + body_node = ( + following if following is not None and following.type == "function_body" else None + ) + if child.type == "method_signature": + self._add_member(owner, child, body_node, lines) + elif child.type == "declaration": + if _member_signature(child) is not None: + self._add_member(owner, child, body_node, lines) + else: + self._add_field(owner, child) + + def _add_member(self, owner: str, wrapper, body, lines): + signature = _member_signature(wrapper) + if signature is None: + return + if signature.type in _MEMBER_SIGNATURES: + name = _text(signature.child_by_field_name("name")) + if not name: + return + node = self._add_node( + wrapper, + body or wrapper, + f"{owner}.{name}", + "method", + _MEMBER_SIGNATURES[signature.type], + lines, + class_name=owner, + parameters=_parameter_names(signature), + ) + if body is not None: + self._scan_targets.append(_ScanTarget(node.id, owner, body, signature, name)) + return + # Constructors (incl. initializer lists), setters, operators: the + # class owns their code. + class_id = self._component_id(owner) + self._scan_targets.append(_ScanTarget(class_id, owner, wrapper, signature)) + if body is not None: + self._scan_targets.append(_ScanTarget(class_id, owner, body, signature)) + + def _add_field(self, owner: str, declaration): + declared = _first_child(declaration, "type_identifier") + names = [ + _text(_first_child(item, "identifier")) + for holder in declaration.named_children + if holder.type in ("initialized_identifier_list", "static_final_declaration_list") + for item in holder.named_children + if item.type in ("initialized_identifier", "static_final_declaration") + ] + if declared is not None: + fields = self._field_types.setdefault(owner, {}) + for name in names: + if name: + fields[name] = _text(declared) + self._field_decls.append((owner, declaration)) + self._scan_targets.append(_ScanTarget(self._component_id(owner), owner, declaration)) + + def _add_top_level_function(self, signature, body, lines): + name = _text(signature.child_by_field_name("name")) + if not name: + return + node = self._add_node( + signature, + body or signature, + name, + "function", + _TOP_LEVEL_SIGNATURES[signature.type], + lines, + parameters=_parameter_names(signature), + ) + if body is not None: + self._scan_targets.append(_ScanTarget(node.id, None, body, signature, name)) + + # ------------------------------------------------------------------ + # Node construction + # ------------------------------------------------------------------ + + @staticmethod + def _docstring(anchor) -> str: + comments: list[str] = [] + sibling = anchor.prev_named_sibling + while sibling is not None and sibling.type in ( + "annotation", + "documentation_comment", + "comment", + ): + if sibling.type != "annotation": + text = _text(sibling).strip() + if sibling.type == "comment" and not text.startswith(("///", "/**")): + break + comments.append(text) + sibling = sibling.prev_named_sibling + return "\n".join(reversed(comments)) + + def _add_node( + self, + start, + end, + logical_name: str, + component_type: str, + node_type: str, + lines, + class_name: str | None = None, + parameters: list[str] | None = None, + base_classes: list[str] | None = None, + ) -> Node: + component_id = self._component_id(logical_name) + start_idx = start.start_point[0] + end_idx = end.end_point[0] + 1 + # Annotations of members and top-level functions are preceding + # siblings; include them in the span (class annotations are children). + sibling = start.prev_named_sibling + while sibling is not None and sibling.type == "annotation": + start_idx = min(start_idx, sibling.start_point[0]) + sibling = sibling.prev_named_sibling + docstring = self._docstring(start) + node = Node( + id=component_id, + name=logical_name, + component_type=component_type, + file_path=str(self.file_path), + relative_path=self.relative_path, + source_code="\n".join(lines[start_idx:end_idx]) if start_idx < len(lines) else "", + start_line=start_idx + 1, + end_line=end_idx, + has_docstring=bool(docstring), + docstring=docstring, + parameters=parameters, + node_type=node_type, + base_classes=base_classes, + class_name=class_name, + display_name=f"{node_type} {logical_name}", + component_id=component_id, + language="dart", + ) + self.nodes.append(node) + self.top_level_nodes[logical_name] = node + return node + + # ------------------------------------------------------------------ + # Flutter / Riverpod + # ------------------------------------------------------------------ + + def _apply_flutter_kinds(self, root, lines): + # Same-file widget subclass chains: iterate to a fixpoint. + widgets: set[str] = set() + changed = True + while changed: + changed = False + for name, extends in self._class_extends.items(): + if name not in widgets and classify_class(extends, widgets) == "widget": + widgets.add(name) + changed = True + for name, extends in self._class_extends.items(): + kind = classify_class(extends, widgets) + if kind: + self._set_node_type(name, kind) + + # @riverpod classes (annotation is a child) and functions (preceding sibling). + for child in root.named_children: + if child.type == "class_definition": + annotations = [c for c in child.named_children if c.type == "annotation"] + if _has_riverpod_annotation(annotations): + self._set_node_type( + _text(child.child_by_field_name("name")), RIVERPOD_NOTIFIER_TYPE + ) + elif child.type in _TOP_LEVEL_SIGNATURES: + annotations = [] + sibling = child.prev_named_sibling + while sibling is not None and sibling.type in ( + "annotation", + "documentation_comment", + "comment", + ): + if sibling.type == "annotation": + annotations.append(sibling) + sibling = sibling.prev_named_sibling + if _has_riverpod_annotation(annotations): + self._set_node_type( + _text(child.child_by_field_name("name")), RIVERPOD_PROVIDER_TYPE + ) + + # Top-level provider / router variables become components. + for name, declaration in self._top_level_vars: + text = _text(declaration) + initializer = text.split("=", 1)[1] if "=" in text else "" + kind = classify_top_level_initializer(initializer) + if kind: + node = self._add_node(declaration, declaration, name, "function", kind, lines) + self._scan_targets.append(_ScanTarget(node.id, None, declaration)) + + def _set_node_type(self, name: str, node_type: str): + node = self.top_level_nodes.get(name) + if node is not None and node.component_type in ("class", "interface", "function"): + node.node_type = node_type + node.display_name = f"{node_type} {name}" + + # ------------------------------------------------------------------ + # Pass 2: relationships + # ------------------------------------------------------------------ + + def _extract_relationships(self): + for name, type_nodes in self._type_ref_nodes.items(): + caller = self._component_id(name) + for type_node in type_nodes: + line = type_node.start_point[0] + 1 + for ref in [type_node, *_descendants(type_node)]: + if ref.type == "type_identifier": + self._add_type_edge(caller, _text(ref), line) + for owner, declaration in self._field_decls: + caller = self._component_id(owner) + line = declaration.start_point[0] + 1 + for child in declaration.named_children: + if child.type == "type_identifier": + self._add_type_edge(caller, _text(child), line) + elif child.type == "type_arguments": + for ref in _descendants(child): + if ref.type == "type_identifier": + self._add_type_edge(caller, _text(ref), line) + for target in self._scan_targets: + self._scan(target, self._lift_target(target)) + + def _lift_target(self, target) -> str | None: + """Instantiations in a widget's/state's build helpers and createState + are also recorded on the class: the widget tree is class-level.""" + if not target.owner or not target.method_name: + return None + owner = self.top_level_nodes.get(target.owner) + if owner is None or owner.node_type not in ("widget", "state"): + return None + if not COMPOSITION_METHOD_RE.match(target.method_name): + return None + return owner.id + + def _scan(self, target, lift_to: str | None = None): + local_types = _local_declared_types(target.signature, target.node) + stack = [target.node] + while stack: + node = stack.pop() + if node.type in ("const_object_expression", "new_expression"): + type_node = _first_child(node, "type_identifier") + if type_node is not None: + self._add_instantiation(target.caller, _text(type_node), node, lift_to) + elif node.type == "ERROR": + self._handle_error_call(target, node) + elif node.type == "relational_expression": + self._handle_generic_invocation(target, node, lift_to) + children = node.children + i = 0 + while i < len(children): + child = children[i] + if child.type in ("identifier", "this", "super"): + j = i + 1 + selectors = [] + while j < len(children) and children[j].type == "selector": + selectors.append(children[j]) + j += 1 + cascades = [] + while j < len(children) and children[j].type == "cascade_section": + cascades.append(children[j]) + j += 1 + if selectors: + self._process_chain(target, child, selectors, local_types, lift_to) + elif cascades and child.type == "identifier": + for cascade in cascades: + self._process_cascade(target, child, cascade, local_types) + i = j if j > i + 1 else i + 1 + continue + i += 1 + stack.extend(reversed(node.named_children)) + + def _process_chain(self, target, head, selectors, local_types, lift_to): + tokens = _selector_tokens(selectors) + if not tokens: + return + caller, owner = target.caller, target.owner + line = head.start_point[0] + 1 + name = _text(head) + if head.type == "identifier" and name in self.import_prefixes and tokens[0][0] == "member": + name, tokens = tokens[0][1] or "", tokens[1:] + if not tokens or not name: + return + kind = tokens[0][0] + called_member = kind == "member" and len(tokens) > 1 and tokens[1][0] == "call" + + if head.type in ("this", "super"): + if head.type == "this" and called_member and owner: + local = self.top_level_nodes.get(f"{owner}.{tokens[0][1]}") + if local is not None: + self._add_resolved(caller, local.id, line) + elif ( + head.type == "this" + and owner + and len(tokens) == 3 + and tokens[0][0] == "member" + and tokens[1][0] == "member" + and tokens[2][0] == "call" + ): + receiver = self._field_types.get(owner, {}).get(tokens[0][1] or "") + if receiver and tokens[1][1]: + self._add_member_call_on_type(caller, receiver, tokens[1][1], line) + return + + if _is_type_name(name): + if kind == "call": + self._add_instantiation(caller, name, head, lift_to) + elif kind == "member": + local = self.top_level_nodes.get(f"{name}.{tokens[0][1]}") + if called_member and local is not None: + self._add_resolved(caller, local.id, line) + elif called_member: + # Named constructor or static method: depends on the type. + self._add_instantiation(caller, name, head, lift_to) + else: + # Enum value, static field, constructor tear-off. + self._add_type_edge(caller, name, line) + return + + if kind == "call": + self._add_function_call(caller, owner, name, line) + return + if called_member: + member = tokens[0][1] or "" + if self._handle_ref_call(caller, member, tokens[1][2], line): + return + receiver = local_types.get(name) or self._field_types.get(owner or "", {}).get(name) + if receiver: + self._add_member_call_on_type(caller, receiver, member, line) + + def _process_cascade(self, target, head, cascade, local_types): + selector = _first_child(cascade, "cascade_selector") + ident = _first_child(selector, "identifier") if selector is not None else None + if ident is None or _first_child(cascade, "argument_part") is None: + return + name = _text(head) + receiver = local_types.get(name) or self._field_types.get(target.owner or "", {}).get(name) + if receiver: + self._add_member_call_on_type( + target.caller, receiver, _text(ident), head.start_point[0] + 1 + ) + + def _handle_error_call(self, target, node): + """Calls to contextual keywords (``get()``, ``set()``) parse as an + ERROR holding a function_signature; recover them as bare calls.""" + signature = _first_child(node, "function_signature") + if signature is None or signature.start_byte != node.start_byte: + return + name = signature.child_by_field_name("name") + if name is not None and _first_child(signature, "formal_parameter_list") is not None: + self._add_function_call( + target.caller, target.owner, _text(name), node.start_point[0] + 1 + ) + + def _handle_generic_invocation(self, target, node, lift_to): + named = node.named_children + if ( + len(named) < 2 + or named[0].type != "relational_expression" + or named[-1].type != "parenthesized_expression" + ): + return + match = _GENERIC_CALL_RE.match(_text(node)) + if not match: + return + name, type_args = match.group(1), match.group(2) + line = node.start_point[0] + 1 + if _is_type_name(name): + self._add_instantiation(target.caller, name, node, lift_to) + else: + self._add_function_call(target.caller, target.owner, name, line) + for arg in _IDENT_RE.findall(type_args): + if _is_type_name(arg): + self._add_type_edge(target.caller, arg, line) + + def _handle_ref_call(self, caller: str, member: str, call_node, line: int) -> bool: + """`ref.watch(fooProvider)` / `.read` / `.listen` / ...: an edge to the + provider (`.notifier`, `.future` and family arguments stripped).""" + if member not in REF_METHODS: + return False + arguments = _first_child(call_node, "arguments") + first = ( + arguments.named_children[0] + if arguments is not None and arguments.named_children + else None + ) + if first is not None and first.type == "argument": + first = first.named_children[0] if first.named_children else None + if first is None or first.type != "identifier": + return False + provider = _text(first) + if not provider.endswith("Provider"): + return False + local = self.top_level_nodes.get(provider) + if local is not None: + self._add_resolved(caller, local.id, line) + else: + self._add_raw(caller, provider, line) + return True + + # ------------------------------------------------------------------ + # Edge emission + # ------------------------------------------------------------------ + + def _add_instantiation(self, caller: str, type_name: str, node, lift_to: str | None): + line = node.start_point[0] + 1 + self._add_type_edge(caller, type_name, line) + if lift_to: + self._add_type_edge(lift_to, type_name, line) + + def _add_type_edge(self, caller: str, type_name: str, line: int): + if not type_name or type_name in DART_CORE_TYPES or type_name in self._type_params: + return + local = self.top_level_nodes.get(type_name) + if local is not None and local.component_type != "method": + self._add_resolved(caller, local.id, line) + else: + self._add_raw(caller, type_name, line) + + def _add_function_call(self, caller: str, owner: str | None, name: str, line: int): + if not name or name in DART_NOISE_CALLS: + return + if owner: + local = self.top_level_nodes.get(f"{owner}.{name}") + if local is not None: + self._add_resolved(caller, local.id, line) + return + local = self.top_level_nodes.get(name) + if local is not None and local.component_type == "function": + self._add_resolved(caller, local.id, line) + return + on_type = self._extension_on.get(owner or "") + if on_type is not None: + # A bare call inside an extension may target the extended type + # or a free function; a core extended type means neither is ours. + if on_type and on_type not in DART_CORE_TYPES and on_type not in self._type_params: + self._add_raw(caller, f"{on_type}.{name}", line) + self._add_raw(caller, name, line) + return + self._add_raw(caller, name, line) + + def _add_member_call_on_type(self, caller: str, type_name: str, member: str, line: int): + if not member or type_name in DART_CORE_TYPES or type_name in self._type_params: + return + local = self.top_level_nodes.get(f"{type_name}.{member}") + if local is not None: + self._add_resolved(caller, local.id, line) + else: + self._add_raw(caller, f"{type_name}.{member}", line) + + def _add_resolved(self, caller: str, callee_id: str, line: int): + self._add_relationship(caller, callee_id, line, True) + + def _add_raw(self, caller: str, callee: str, line: int): + self._add_relationship(caller, callee, line, False) + + def _add_relationship(self, caller: str, callee: str, line: int | None, resolved: bool): + key = (caller, callee, line) + if caller == callee or key in self.seen_relationships: + return + self.seen_relationships.add(key) + self.call_relationships.append( + CallRelationship(caller=caller, callee=callee, call_line=line, is_resolved=resolved) + ) + + +def analyze_dart_file( + file_path: str, content: str, repo_path: str | None = None +) -> tuple[list[Node], list[CallRelationship]]: + analyzer = TreeSitterDartAnalyzer(file_path, content, repo_path) + return analyzer.nodes, analyzer.call_relationships diff --git a/codewiki/src/be/dependency_analyzer/analyzers/dart_flutter.py b/codewiki/src/be/dependency_analyzer/analyzers/dart_flutter.py new file mode 100644 index 00000000..f31735bb --- /dev/null +++ b/codewiki/src/be/dependency_analyzer/analyzers/dart_flutter.py @@ -0,0 +1,112 @@ +"""Flutter, Riverpod, Bloc and GoRouter knowledge for the Dart analyzer. + +Pure tables and classifiers; the analyzer decides where to apply them. +Classification is by declared supertype name, so only direct framework +subclasses and same-file subclass chains are recognised. +""" + +import re + +FLUTTER_WIDGET_BASES = frozenset( + { + "StatelessWidget", + "StatefulWidget", + "ConsumerWidget", + "ConsumerStatefulWidget", + "HookWidget", + "HookConsumerWidget", + "StatefulHookWidget", + "StatefulHookConsumerWidget", + "InheritedWidget", + "InheritedNotifier", + "InheritedModel", + "RenderObjectWidget", + "LeafRenderObjectWidget", + "SingleChildRenderObjectWidget", + "MultiChildRenderObjectWidget", + "ProxyWidget", + "ParentDataWidget", + } +) +FLUTTER_STATE_BASES = frozenset({"State", "ConsumerState"}) +STATE_MANAGEMENT_BASES = { + "ChangeNotifier": "notifier", + "ValueNotifier": "notifier", + "Notifier": "notifier", + "AsyncNotifier": "notifier", + "StreamNotifier": "notifier", + "FamilyNotifier": "notifier", + "FamilyAsyncNotifier": "notifier", + "AutoDisposeNotifier": "notifier", + "AutoDisposeAsyncNotifier": "notifier", + "StateNotifier": "notifier", + "Bloc": "bloc", + "HydratedBloc": "bloc", + "Cubit": "cubit", + "HydratedCubit": "cubit", +} +RIVERPOD_ANNOTATIONS = frozenset({"riverpod", "Riverpod"}) +REF_METHODS = frozenset( + {"watch", "read", "listen", "listenManual", "invalidate", "refresh", "exists"} +) +# Methods whose instantiations are the widget's composition: any method name +# starting with `build`/`_build` (build, buildHeader, _buildRow, builder) plus +# createState(). +COMPOSITION_METHOD_RE = re.compile(r"^(_?build\w*|createState)$") +_INITIALIZER_CTOR_RE = re.compile( + r"\A\s*(?:const\s+|new\s+)?([A-Z]\w*)(?:\.\w+)*\s*(?:<[^()]*>)?\s*\(", re.S +) +_PROVIDER_SUFFIX = "Provider" +RIVERPOD_NOTIFIER_TYPE = "riverpod notifier" # @riverpod class +RIVERPOD_PROVIDER_TYPE = "riverpod provider" # @riverpod function +_RIVERPOD_KINDS = ( + "", + "Future", + "Stream", + "State", + "StateNotifier", + "ChangeNotifier", + "Notifier", + "AsyncNotifier", + "StreamNotifier", +) +PROVIDER_CONSTRUCTORS = frozenset( + f"{prefix}{kind}Provider" + for prefix in ("", "AutoDispose", "Family", "AutoDisposeFamily") + for kind in _RIVERPOD_KINDS +) + + +def classify_class(extends: str | None, local_widgets: set[str]) -> str | None: + if not extends: + return None + if extends in FLUTTER_WIDGET_BASES or extends in local_widgets: + return "widget" + if extends in FLUTTER_STATE_BASES: + return "state" + return STATE_MANAGEMENT_BASES.get(extends) + + +def classify_top_level_initializer(text: str) -> str | None: + match = _INITIALIZER_CTOR_RE.match(text) + if not match: + return None + constructor = match.group(1) + if constructor in PROVIDER_CONSTRUCTORS: + return "provider" + if constructor == "GoRouter": + return "router" + return None + + +def provider_alias_candidates(callee: str) -> list[str]: + """Declarations riverpod_generator may have generated ``callee`` from: + ``fooProvider`` comes from function ``foo`` or class ``Foo`` (Riverpod 2 + and 3), or class ``FooNotifier`` (Riverpod 3 drops the suffix).""" + if not callee.endswith(_PROVIDER_SUFFIX) or "." in callee: + return [] + stem = callee[: -len(_PROVIDER_SUFFIX)] + if not stem: + return [] + upper = stem[:1].upper() + stem[1:] + return [stem, upper, f"{upper}Notifier"] diff --git a/codewiki/src/be/dependency_analyzer/analyzers/dart_imports.py b/codewiki/src/be/dependency_analyzer/analyzers/dart_imports.py new file mode 100644 index 00000000..70e22591 --- /dev/null +++ b/codewiki/src/be/dependency_analyzer/analyzers/dart_imports.py @@ -0,0 +1,228 @@ +"""Dart import/export/part directives and repository-local URI resolution. + +The Dart analyzer records each file's directives; once every file is +parsed, the call-graph analyzer resolves them to repository files and +computes two scopes per file: + +- ``visible``: the caller's library (the file, the library it is a + ``part of``, and that library's other parts) plus every library it + imports, followed transitively through ``export`` directives. Name + resolution prefers candidates inside this set. +- ``library_members``: the library alone. Names starting with ``_`` are + library-private in Dart, so they may only resolve inside it. + +``package:/`` maps to ``/lib/``; +``dart:`` URIs and packages not defined in the repository map to nothing. +""" + +import os +import posixpath +import re +from collections import defaultdict +from collections.abc import Iterable +from dataclasses import dataclass + +_PUBSPEC_NAME_RE = re.compile(r"^name:\s*['\"]?([A-Za-z_][A-Za-z0-9_]*)", re.MULTILINE) + + +@dataclass(frozen=True) +class DartDirective: + kind: str # "import" | "export" | "part" | "part_of" + uri: str | None + prefix: str | None = None + line: int = 0 + + +def _string_value(node) -> str | None: + """The contents of the first string literal under ``node``, quotes stripped.""" + stack = [node] + while stack: + current = stack.pop() + if current.type == "string_literal": + text = current.text.decode("utf8", errors="replace").lstrip("r") + return text.strip("'\"") + stack.extend(reversed(current.named_children)) + return None + + +def _first_child(node, node_type: str): + return next((c for c in node.named_children if c.type == node_type), None) + + +def parse_directives(root) -> list[DartDirective]: + directives: list[DartDirective] = [] + for child in root.named_children: + line = child.start_point[0] + 1 + if child.type == "import_or_export": + for sub in child.named_children: + if sub.type == "library_import": + spec = _first_child(sub, "import_specification") or sub + uri_node = _first_child(spec, "configurable_uri") or _first_child(spec, "uri") + prefix = _first_child(spec, "identifier") + directives.append( + DartDirective( + "import", + _string_value(uri_node) if uri_node is not None else None, + prefix.text.decode() if prefix is not None else None, + line, + ) + ) + elif sub.type == "library_export": + uri_node = _first_child(sub, "configurable_uri") or _first_child(sub, "uri") + directives.append( + DartDirective( + "export", + _string_value(uri_node) if uri_node is not None else None, + None, + line, + ) + ) + elif child.type == "part_directive": + uri_node = _first_child(child, "uri") + directives.append( + DartDirective("part", _string_value(uri_node) if uri_node else None, None, line) + ) + elif child.type == "part_of_directive": + uri_node = _first_child(child, "uri") + directives.append( + DartDirective("part_of", _string_value(uri_node) if uri_node else None, None, line) + ) + return directives + + +def _read_package_name(pubspec_path: str) -> str | None: + try: + with open(pubspec_path, encoding="utf-8", errors="replace") as handle: + match = _PUBSPEC_NAME_RE.search(handle.read()) + except OSError: + return None + return match.group(1) if match else None + + +class DartPackageResolver: + def __init__(self, packages: dict[str, str]): + self.packages = packages + + @classmethod + def from_files(cls, repo_dir: str, dart_relpaths: Iterable[str]) -> "DartPackageResolver": + """Find the pubspec.yaml of every directory that holds (or is an ancestor + of) an analyzed Dart file. Walking ancestors of analyzed files, instead + of the whole tree, never enters ignored directories.""" + packages: dict[str, str] = {} + seen: set[str] = set() + for relpath in dart_relpaths: + directory = posixpath.dirname(relpath) + while directory not in seen: + seen.add(directory) + pubspec = os.path.join(repo_dir, directory, "pubspec.yaml") + if os.path.isfile(pubspec): + name = _read_package_name(pubspec) + if name and name not in packages: + packages[name] = directory + if not directory: + break + directory = posixpath.dirname(directory) + return cls(packages) + + def resolve(self, uri: str | None, from_relpath: str) -> str | None: + if not uri or uri.startswith("dart:"): + return None + if uri.startswith("package:"): + name, _, rest = uri[len("package:") :].partition("/") + root = self.packages.get(name) + if root is None or not rest: + return None + target = posixpath.normpath(posixpath.join(root, "lib", rest)) + elif "://" in uri or uri.startswith("/"): + return None + else: + target = posixpath.normpath(posixpath.join(posixpath.dirname(from_relpath), uri)) + if target.startswith("../") or target == "..": + return None + return target + + +@dataclass +class DartScopes: + visible: dict[str, set[str]] + library_members: dict[str, set[str]] + + +def build_scopes( + directives_by_file: dict[str, list[DartDirective]], + resolver: DartPackageResolver, + known_files: set[str], +) -> DartScopes: + library_of: dict[str, str] = {} + parts_of: dict[str, set[str]] = defaultdict(set) + + # The `part` side is authoritative; `part of ''` is the fallback. + for path, directives in directives_by_file.items(): + for directive in directives: + if directive.kind == "part": + target = resolver.resolve(directive.uri, path) + if target in known_files: + library_of[target] = path + parts_of[path].add(target) + for path, directives in directives_by_file.items(): + if path in library_of: + continue + for directive in directives: + if directive.kind == "part_of" and directive.uri: + library = resolver.resolve(directive.uri, path) + if library in known_files: + library_of[path] = library + parts_of[library].add(path) + + def members(path: str) -> set[str]: + library = library_of.get(path, path) + return {library} | parts_of.get(library, set()) + + def targets(path: str, kind: str) -> set[str]: + found: set[str] = set() + for member in members(path): + for directive in directives_by_file.get(member, []): + if directive.kind == kind: + target = resolver.resolve(directive.uri, member) + if target in known_files: + found.add(target) + return found + + closure_cache: dict[str, set[str]] = {} + + def exported_closure(start: str) -> set[str]: + library = library_of.get(start, start) + if library not in closure_cache: + closure_cache[library] = _exported_closure(start) + return closure_cache[library] + + def _exported_closure(start: str) -> set[str]: + result: set[str] = set() + stack = [start] + while stack: + current = stack.pop() + library_files = members(current) + if library_files <= result: + continue + result |= library_files + stack.extend(targets(current, "export")) + return result + + visible: dict[str, set[str]] = {} + library_members: dict[str, set[str]] = {} + for path in directives_by_file: + own = members(path) + scope = set(own) + for imported in targets(path, "import"): + scope |= exported_closure(imported) + visible[path] = scope + library_members[path] = own + return DartScopes(visible=visible, library_members=library_members) + + +def is_private_dart_name(name: str) -> bool: + """Library-private callee (`_Foo`, `Foo._bar`). Already-resolved + component ids (containing `::`) are never treated as names.""" + if not name or "::" in name: + return False + return any(segment.startswith("_") for segment in name.split(".")) diff --git a/codewiki/src/be/dependency_analyzer/ast_parser.py b/codewiki/src/be/dependency_analyzer/ast_parser.py index 65dd2c7f..e3f67a2d 100644 --- a/codewiki/src/be/dependency_analyzer/ast_parser.py +++ b/codewiki/src/be/dependency_analyzer/ast_parser.py @@ -104,6 +104,7 @@ def _build_components_from_analysis(self, call_graph_result: dict): display_name=func_dict.get("display_name", ""), component_id=component_id, artifact_class=func_dict.get("artifact_class"), + language=func_dict.get("language"), ) self.components[component_id] = node @@ -189,6 +190,7 @@ def _file_to_module_path(self, file_path: str) -> str: ".scala", ".sc", ".rs", + ".dart", ] for ext in extensions: if path.endswith(ext): diff --git a/codewiki/src/be/dependency_analyzer/utils/patterns.py b/codewiki/src/be/dependency_analyzer/utils/patterns.py index 9fb8e676..ce48c541 100644 --- a/codewiki/src/be/dependency_analyzer/utils/patterns.py +++ b/codewiki/src/be/dependency_analyzer/utils/patterns.py @@ -50,6 +50,19 @@ ".classpath", "gradle-app.setting", "*.gradle", + # Dart/Flutter: tool cache and generated sources + ".dart_tool", + ".dart_tool/", + "*.g.dart", + "*.freezed.dart", + "*.gr.dart", + "*.mocks.dart", + "*.gen.dart", + "*.pb.dart", + "*.pbenum.dart", + "*.pbjson.dart", + "*.pbgrpc.dart", + "*.chopper.dart", # IDEs and editors / Java ".project", # C/C++ @@ -162,6 +175,7 @@ "*.kts", "*.scala", "*.sc", + "*.dart", "*.clj", "*.hs", "*.ml", @@ -289,6 +303,7 @@ ".kt": "kotlin", ".scala": "scala", ".sc": "scala", + ".dart": "dart", ".cs": "csharp", } @@ -334,6 +349,8 @@ "app.rs", "start.rs", "bin.rs", + # Dart/Flutter + "main.dart", # C/C++ "main.c", "main.cpp", @@ -534,6 +551,15 @@ "trait {name}", "object {name}", ], + "dart": [ + "class {name}", + "mixin {name}", + "extension {name}", + "enum {name}", + "void {name}(", + "Widget {name}(", + "Future {name}(", + ], "general": ["{name}("], # Fallback pattern } diff --git a/codewiki/src/be/prompt_template.py b/codewiki/src/be/prompt_template.py index 90263374..662ec3e2 100644 --- a/codewiki/src/be/prompt_template.py +++ b/codewiki/src/be/prompt_template.py @@ -315,6 +315,20 @@ "listed above) and cite the file path in the documentation." ) +# Appended to the user prompt when the module contains Dart sources. Kept +# out of USER_PROMPT for the same reason as ARTIFACT_USAGE_NOTE. +DART_FLUTTER_NOTE = ( + "* NOTE (Dart/Flutter): when these components are Flutter code, also cover, " + "where they apply: (1) the widget tree — which widgets each widget builds " + "and where it is used, and Stateless vs Stateful/Consumer widgets; (2) state " + "management — where state lives (State classes, Riverpod providers and " + "notifiers, Bloc/Cubit, ChangeNotifier), which providers each widget " + "watches or reads, and how updates flow to the UI; (3) navigation — routes, " + "paths and screens (GoRouter, Navigator pushes) and how parameters are " + "passed; (4) lifecycle and async behaviour (initState/dispose, Futures, " + "Streams). Leave out any of these that do not apply." +) + REPO_OVERVIEW_ARTIFACT_ADDENDUM = """ The repository also contains the following build, CI, container, packaging, manifest and configuration artifacts: {artifact_index} @@ -371,6 +385,7 @@ ".scala": "scala", ".sc": "scala", ".rs": "rust", + ".dart": "dart", ".php": "php", ".phtml": "php", ".inc": "php", @@ -569,6 +584,12 @@ def format_user_prompt( artifact_index = render_artifact_index(components) artifact_section = f"\n\n{artifact_index}\n{ARTIFACT_USAGE_NOTE}" if artifact_index else "" + has_dart = any( + components[cid].relative_path.endswith(".dart") + for cid in core_component_ids + if cid in components + ) + dart_section = f"\n\n{DART_FLUTTER_NOTE}" if has_dart else "" def _assemble(codes: str, tree: str) -> str: return ( @@ -579,6 +600,7 @@ def _assemble(codes: str, tree: str) -> str: ) + f"\n\n{links_note}" + artifact_section + + dart_section ) prompt = _assemble(core_component_codes, formatted_module_tree) diff --git a/guides/development.md b/guides/development.md index e4228f5b..e20e8d8e 100644 --- a/guides/development.md +++ b/guides/development.md @@ -133,8 +133,8 @@ Analyzers are standalone classes, one file per language. Follow (`cli/utils/validation.py`, `cli/utils/repo_validator.py`). 5. **Tests.** Add `tests/test__analyzer.py` with small source snippets that check component and edge extraction. See - `tests/test_scala_analyzer.py`, `tests/test_ruby_analyzer.py`, and - `tests/test_rust_analyzer.py`. + `tests/test_scala_analyzer.py`, `tests/test_ruby_analyzer.py`, + `tests/test_rust_analyzer.py`, and `tests/test_dart_analyzer.py`. 6. **Docs.** Add the language to the README list. ## Tests and lint diff --git a/pyproject.toml b/pyproject.toml index 03f3d6cd..1a3867c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ "GitPython>=3.1.40", "Jinja2>=3.1.6", "tree-sitter>=0.23.2", - "tree-sitter-language-pack>=0.8.0", + "tree-sitter-language-pack>=0.8.0,<0.9.0", "tree-sitter-python>=0.23.6", "tree-sitter-java>=0.23.5", "tree-sitter-javascript>=0.21.4", diff --git a/tests/test_dart_analyzer.py b/tests/test_dart_analyzer.py new file mode 100644 index 00000000..e7f21c58 --- /dev/null +++ b/tests/test_dart_analyzer.py @@ -0,0 +1,568 @@ +"""Tests for the tree-sitter based Dart analyzer.""" + +from pathlib import Path + +import pytest + +pytest.importorskip("tree_sitter_language_pack") + +from codewiki.cli.utils.validation import detect_supported_languages +from codewiki.src.be.dependency_analyzer.analyzers.dart import ( + TreeSitterDartAnalyzer, + analyze_dart_file, +) +from codewiki.src.be.dependency_analyzer.ast_parser import DependencyParser + +SAMPLE = """\ +import 'package:demo/models/car.dart' as car; + +/// Logs messages. +mixin Loggable on Object { + void log(String m) => print(m); +} + +abstract class Repo { + Future fetch(); +} + +abstract interface class Disposable { + void dispose(); +} + +sealed class Shape {} + +/// Cars repo. +class CarRepo extends Repo with Loggable implements Disposable { + final ApiClient api; + CarRepo(this.api); + factory CarRepo.create() => CarRepo(ApiClient()); + + /// Loads the car. + @override + Future fetch() async { + final r = await api.get('/car'); + log('x'); + return Car.fromJson(r); + } + + int get count => 1; + set count(int v) {} + + @override + void dispose() {} +} + +extension StringX on String { + String shout() => toUpperCase(); +} + +extension on int { + int twice() => this * 2; +} + +enum Status { + idle, + busy; + + bool get isIdle => this == Status.idle; +} + +int helper(int a, {int b = 2}) => a + b; + +String get appName => 'demo'; +""" + + +def _analyze(tmp_path: Path, source: str = SAMPLE, name: str = "lib/repo.dart"): + file_path = tmp_path / name + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_text(source, encoding="utf-8") + return analyze_dart_file(str(file_path), source, repo_path=str(tmp_path)) + + +def _by_name(nodes): + return {node.name: node for node in nodes} + + +def test_extracts_declarations_with_types(tmp_path: Path) -> None: + nodes, _ = _analyze(tmp_path) + by_name = _by_name(nodes) + expected = { + "Loggable": ("class", "mixin"), + "Loggable.log": ("method", "method"), + "Repo": ("class", "abstract class"), + "Repo.fetch": ("method", "method"), + "Disposable": ("interface", "interface"), + "Disposable.dispose": ("method", "method"), + "Shape": ("class", "sealed class"), + "CarRepo": ("class", "class"), + "CarRepo.fetch": ("method", "method"), + "CarRepo.count": ("method", "getter"), + "CarRepo.dispose": ("method", "method"), + "StringX": ("class", "extension"), + "StringX.shout": ("method", "method"), + "extension_on_int": ("class", "extension"), + "extension_on_int.twice": ("method", "method"), + "Status": ("class", "enum"), + "Status.isIdle": ("method", "getter"), + "helper": ("function", "function"), + "appName": ("function", "getter"), + } + assert {name: (n.component_type, n.node_type) for name, n in by_name.items()} == expected + # Constructors and setters are not components. + assert "CarRepo.create" not in by_name + assert all(node.language == "dart" for node in nodes) + assert by_name["CarRepo"].id == "lib/repo.dart::CarRepo" + assert by_name["CarRepo.fetch"].class_name == "CarRepo" + + +def test_bases_docstrings_parameters_and_spans(tmp_path: Path) -> None: + by_name = _by_name(_analyze(tmp_path)[0]) + assert by_name["CarRepo"].base_classes == ["Repo", "Loggable", "Disposable"] + assert by_name["Loggable"].base_classes == ["Object"] + assert by_name["StringX"].base_classes == ["String"] + assert by_name["CarRepo"].docstring == "/// Cars repo." + # Doc comment is found across the @override annotation. + assert by_name["CarRepo.fetch"].docstring == "/// Loads the car." + assert by_name["helper"].parameters == ["a", "b"] + fetch = by_name["CarRepo.fetch"] + assert fetch.source_code.lstrip().startswith("@override") + assert fetch.source_code.rstrip().endswith("}") + assert fetch.end_line - fetch.start_line == 5 + + +def test_import_prefixes_and_directives(tmp_path: Path) -> None: + file_path = tmp_path / "lib" / "repo.dart" + file_path.parent.mkdir(parents=True) + file_path.write_text(SAMPLE, encoding="utf-8") + analyzer = TreeSitterDartAnalyzer(str(file_path), SAMPLE, repo_path=str(tmp_path)) + assert analyzer.import_prefixes == {"car"} + assert analyzer.relative_path == "lib/repo.dart" + assert [d.kind for d in analyzer.directives] == ["import"] + + +def test_parse_errors_keep_surrounding_declarations(tmp_path: Path) -> None: + source = ( + "library;\n" + "class Before {}\n" + "extension type CarId(int v) {}\n" + "Map body(String? pin) => {'pin': ?pin};\n" + "class After {\n void run() {}\n}\n" + ) + by_name = _by_name(_analyze(tmp_path, source, "lib/broken.dart")[0]) + assert {"Before", "After", "After.run"} <= set(by_name) + + +def test_empty_and_garbage_files_return_nothing(tmp_path: Path) -> None: + assert _analyze(tmp_path, "", "lib/empty.dart") == ([], []) + nodes, _ = _analyze(tmp_path, "class {{{ void (", "lib/garbage.dart") + assert isinstance(nodes, list) + + +def test_generated_dart_files_are_ignored(tmp_path: Path) -> None: + lib = tmp_path / "lib" + lib.mkdir() + (tmp_path / "pubspec.yaml").write_text("name: demo\n", encoding="utf-8") + (lib / "car.dart").write_text("part 'car.g.dart';\nclass Car {}\n", encoding="utf-8") + (lib / "car.g.dart").write_text("part of 'car.dart';\nclass _$CarGen {}\n", encoding="utf-8") + (lib / "car.freezed.dart").write_text("class _$CarFreezed {}\n", encoding="utf-8") + (lib / "car.gen.dart").write_text("class CarGen {}\n", encoding="utf-8") + tool = tmp_path / ".dart_tool" + tool.mkdir() + (tool / "gen.dart").write_text("class Hidden {}\n", encoding="utf-8") + + components = DependencyParser(str(tmp_path)).parse_repository() + + dart_ids = {cid for cid, node in components.items() if node.language == "dart"} + assert dart_ids == {"lib/car.dart::Car"} + + +def test_dart_is_a_detected_language(tmp_path: Path) -> None: + (tmp_path / "main.dart").write_text("void main() {}\n", encoding="utf-8") + assert ("Dart", 1) in detect_supported_languages(tmp_path) + + +def _edges(relationships): + return { + (r.caller.split("::")[-1], r.callee.split("::")[-1], r.is_resolved) for r in relationships + } + + +def test_inheritance_and_field_type_edges(tmp_path: Path) -> None: + edges = _edges(_analyze(tmp_path)[1]) + assert ("CarRepo", "Repo", True) in edges + assert ("CarRepo", "Loggable", True) in edges + assert ("CarRepo", "Disposable", True) in edges + assert ("CarRepo", "Car", False) in edges # type argument of the superclass + assert ("CarRepo", "ApiClient", False) in edges # field type + # Core types and type parameters never become edges. + callees = {callee for _, callee, _ in edges} + assert not callees & {"Future", "String", "Object", "int", "T"} + + +def test_call_and_instantiation_edges(tmp_path: Path) -> None: + edges = _edges(_analyze(tmp_path)[1]) + assert ("CarRepo.fetch", "ApiClient.get", False) in edges # typed field receiver + assert ("CarRepo.fetch", "log", False) in edges # inherited, resolved cross-file later + assert ("CarRepo.fetch", "Car", False) in edges # Car.fromJson -> type edge + assert ("CarRepo", "CarRepo", True) not in edges # no self edges + assert ("CarRepo", "ApiClient", False) in edges # factory body -> class-level + assert ("Status.isIdle", "Status", True) in edges # enum value access + callees = {callee for _, callee, _ in edges} + assert "print" not in callees + assert "toUpperCase" not in callees + + +CALLS = """\ +import 'package:demo/models.dart' as m; + +class Engine { + void start() {} + void restart() { + stop(); + this.start(); + start(); + } + void stop() {} +} + +Engine build() => Engine(); + +class Garage { + final Engine engine; + Garage(this.engine); + + void open(Engine spare, {required Door door}) { + engine.start(); + spare.restart(); + door.unlock(); + final Window w = Window(); + w.close(); + final inferred = Engine(); + inferred.stop(); + engine..start()..stop(); + final box = Box(1); + final made = new Crate(); + const label = Label('x'); + final car = m.Car(1); + final named = Window.tinted(); + build(); + unknown.call(); + } +} +""" + + +def test_call_resolution_rules(tmp_path: Path) -> None: + edges = _edges(_analyze(tmp_path, CALLS, "lib/garage.dart")[1]) + expected_present = { + ("Engine.restart", "Engine.stop", True), + ("Engine.restart", "Engine.start", True), # this.start() and bare start() + ("Garage.open", "Engine.start", True), # typed field + ("Garage.open", "Engine.restart", True), # typed parameter + ("Garage.open", "Door.unlock", False), # typed named parameter, unknown type + ("Garage.open", "Window", False), # instantiation + ("Garage.open", "Window.close", False), # typed local + ("Garage.open", "Engine", True), + ("Garage.open", "Engine.stop", True), # cascade on typed field + ("Garage.open", "Box", False), # generic invocation misparse + ("Garage.open", "Item", False), # its type argument + ("Garage.open", "Crate", False), # new expression + ("Garage.open", "Label", False), # const object expression + ("Garage.open", "Car", False), # import prefix stripped + ("Garage.open", "build", True), # same-file top-level function + ("build", "Engine", True), + ("Garage", "Engine", True), # field type + } + assert expected_present <= edges + callees = {callee for _, callee, _ in edges} + assert "m" not in callees and "m.Car" not in callees + assert "unknown.call" not in callees and "call" not in callees + assert "inferred.stop" not in callees # untyped local: unknown receiver + + +def test_relationships_are_deduplicated(tmp_path: Path) -> None: + source = "class A {}\nclass B {\n void f() { A(); A(); }\n}\n" + _, relationships = _analyze(tmp_path, source, "lib/dup.dart") + keys = [(r.caller, r.callee, r.call_line) for r in relationships] + assert len(keys) == len(set(keys)) + + +def test_extension_bare_calls(tmp_path: Path) -> None: + source = "class Api {}\nextension X on Api {\n void g() { get(); helperFn(); }\n}\n" + edges = _edges(_analyze(tmp_path, source, "lib/ext.dart")[1]) + assert ("X.g", "Api.get", False) in edges + assert ("X.g", "helperFn", False) in edges + assert ("X.g", "get", False) in edges + + +def test_extension_on_core_type_emits_nothing(tmp_path: Path) -> None: + source = "extension on String {\n String s() => trim();\n}\n" + edges = _edges(_analyze(tmp_path, source, "lib/core_ext.dart")[1]) + assert not {e for e in edges if e[0] == "extension_on_String.s"} + + +def test_this_field_method_call(tmp_path: Path) -> None: + source = ( + "class Api { void get() {} }\n" + "class A {\n final Api api;\n A(this.api);\n void f() { this.api.get(); }\n}\n" + ) + edges = _edges(_analyze(tmp_path, source, "lib/thisf.dart")[1]) + assert ("A.f", "Api.get", True) in edges + + +def _write_repo(tmp_path: Path, files: dict[str, str]) -> None: + (tmp_path / "pubspec.yaml").write_text("name: demo\n", encoding="utf-8") + for relpath, source in files.items(): + path = tmp_path / relpath + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(source, encoding="utf-8") + + +def test_imported_definition_wins_over_global_duplicate(tmp_path: Path) -> None: + _write_repo( + tmp_path, + { + "lib/a/home.dart": ( + "import 'package:demo/a/widgets.dart';\n" + "class HomePage {\n void show() { InfoCard(); }\n}\n" + ), + "lib/a/widgets.dart": "class InfoCard {}\n", + "lib/b/other.dart": "class InfoCard {}\n", + }, + ) + components = DependencyParser(str(tmp_path)).parse_repository() + deps = components["lib/a/home.dart::HomePage.show"].depends_on + assert "lib/a/widgets.dart::InfoCard" in deps + assert "lib/b/other.dart::InfoCard" not in deps + + +def test_private_names_bind_within_library_only(tmp_path: Path) -> None: + _write_repo( + tmp_path, + { + "lib/c/lib.dart": "part 'part.dart';\nclass X {\n void f() { _helper(); }\n}\n", + "lib/c/part.dart": "part of 'lib.dart';\nvoid _helper() {}\nvoid _onlyInC() {}\n", + "lib/d/z.dart": ( + "void _helper() {}\nclass Z {\n void g() { _helper(); _onlyInC(); }\n}\n" + ), + }, + ) + components = DependencyParser(str(tmp_path)).parse_repository() + assert components["lib/c/lib.dart::X.f"].depends_on == {"lib/c/part.dart::_helper"} + # Z.g sees its own _helper; _onlyInC is private to library c and must not bind. + assert components["lib/d/z.dart::Z.g"].depends_on == {"lib/d/z.dart::_helper"} + + +def test_dotted_callee_falls_back_to_visible_type(tmp_path: Path) -> None: + _write_repo( + tmp_path, + { + "lib/api.dart": "class ApiClient {\n void get() {}\n}\n", + "lib/other_api.dart": "class Other {\n void get() {}\n}\n", + "lib/repo.dart": ( + "import 'api.dart';\n" + "class Repo {\n final ApiClient api;\n Repo(this.api);\n" + " void load() { api.get(); api.post(); }\n}\n" + ), + }, + ) + components = DependencyParser(str(tmp_path)).parse_repository() + deps = components["lib/repo.dart::Repo.load"].depends_on + assert "lib/api.dart::ApiClient.get" in deps + assert "lib/api.dart::ApiClient" in deps # api.post(): no such method -> the type + assert "lib/other_api.dart::Other.get" not in deps + + +def test_inherited_mixin_call_resolves_cross_file(tmp_path: Path) -> None: + _write_repo( + tmp_path, + { + "lib/log.dart": "mixin Loggable {\n void log(String m) {}\n}\n", + "lib/svc.dart": ( + "import 'log.dart';\nclass Svc with Loggable {\n void run() { log('x'); }\n}\n" + ), + }, + ) + components = DependencyParser(str(tmp_path)).parse_repository() + assert "lib/log.dart::Loggable.log" in components["lib/svc.dart::Svc.run"].depends_on + assert "lib/log.dart::Loggable" in components["lib/svc.dart::Svc"].depends_on + + +def test_blank_null_aware_elements_only_touches_element_question_marks() -> None: + from codewiki.src.be.dependency_analyzer.analyzers.dart import _blank_null_aware_elements + + changed = { + "['a', ?b]": "['a', b]", + "{'pin': ?pin}": "{'pin': pin}", + "{?x, ?f(y)}": "{ x, f(y)}", + } + for src, expected in changed.items(): + assert _blank_null_aware_elements(src) == expected + unchanged = [ + "a ? b : c", + "a ?b : c", + "String? s", + "List xs", + "x?.y", + "a ?? b", + "a ??= b", + "x?[0]", + "{'a': c ? d : e}", + "{'a': c ?d : e}", + ] + for src in unchanged: + assert _blank_null_aware_elements(src) == src + for src in [*changed, *unchanged]: + assert len(_blank_null_aware_elements(src)) == len(src) + + +def test_null_aware_elements_do_not_break_later_declarations(tmp_path: Path) -> None: + source = ( + "class Repo {\n" + " Future save(String? body, bool? done, String? section) async {\n" + " helper();\n" + " final payload = {\n" + " 'body': ?body,\n" + " 'done': ?done,\n" + " if (section != null) 'section': section.trim().isEmpty ? null : section,\n" + " 'list': [?body],\n" + " };\n" + " if (payload.isEmpty) return;\n" + " }\n" + "}\n" + "class After {\n void run() {}\n}\n" + ) + nodes, rels = _analyze(tmp_path, source) + names = {n.name for n in nodes} + assert {"Repo", "Repo.save", "After", "After.run"} <= names + assert any(r.caller.endswith("Repo.save") and r.callee == "helper" for r in rels) + + +def test_unscoped_names_are_not_guessed_across_languages(tmp_path: Path) -> None: + _write_repo( + tmp_path, + { + "windows/runner/win32_window.h": ( + "struct Size { int w; int h; };\nstruct Point { int x; int y; };\n" + ), + "lib/home.dart": ("class Home {\n void build() { Size(10, 20); Point(1, 2); }\n}\n"), + }, + ) + components = DependencyParser(str(tmp_path)).parse_repository() + deps = components["lib/home.dart::Home.build"].depends_on + assert not {d for d in deps if d.startswith("windows/")} + + +def test_dotted_call_does_not_bind_to_unimported_class(tmp_path: Path) -> None: + _write_repo( + tmp_path, + { + "lib/player.dart": "class Player {\n void forward() {}\n}\n", + "lib/anim.dart": ( + "class Anim {\n final AnimationController controller;\n" + " Anim(this.controller);\n void run() { controller.forward(); }\n}\n" + ), + }, + ) + components = DependencyParser(str(tmp_path)).parse_repository() + assert "lib/player.dart::Player.forward" not in components["lib/anim.dart::Anim.run"].depends_on + + +def test_bare_call_requires_import_of_defining_file(tmp_path: Path) -> None: + _write_repo( + tmp_path, + { + "lib/dialogs.dart": "void showDialog() {}\n", + "lib/page.dart": "class Page {\n void open() { showDialog(); }\n}\n", + }, + ) + components = DependencyParser(str(tmp_path)).parse_repository() + assert components["lib/page.dart::Page.open"].depends_on == set() + + +def test_private_target_in_other_library_is_not_bound(tmp_path: Path) -> None: + _write_repo( + tmp_path, + { + "lib/a.dart": "class _Ticker {\n void tick() {}\n}\n", + "lib/b.dart": ("import 'a.dart';\nclass B {\n void f() { tick(); }\n}\n"), + }, + ) + components = DependencyParser(str(tmp_path)).parse_repository() + assert "lib/a.dart::_Ticker.tick" not in components["lib/b.dart::B.f"].depends_on + + +def test_top_level_function_wins_over_same_named_method(tmp_path: Path) -> None: + _write_repo( + tmp_path, + { + "lib/util.dart": "void helper() {}\nclass Tool {\n void helper() {}\n}\n", + "lib/main.dart": "import 'package:demo/util.dart';\nvoid go() { helper(); }\n", + }, + ) + components = DependencyParser(str(tmp_path)).parse_repository() + assert components["lib/main.dart::go"].depends_on == {"lib/util.dart::helper"} + + +def test_missing_pubspec_falls_back_to_dart_name_matching(tmp_path: Path, caplog) -> None: + files = { + "lib/util.dart": "class Engine {\n void start() {}\n}\n", + "lib/main.dart": ( + "import 'package:app/util.dart';\n" + "class Car {\n final Engine e = Engine();\n void go() { e.start(); Size(1, 2); }\n}\n" + ), + "windows/runner/win32_window.h": "struct Size { int w; int h; };\n", + } + for relpath, source in files.items(): + path = tmp_path / relpath + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(source, encoding="utf-8") + with caplog.at_level("WARNING"): + components = DependencyParser(str(tmp_path)).parse_repository() + assert "No pubspec.yaml" in caplog.text + assert "lib/util.dart::Engine" in components["lib/main.dart::Car"].depends_on + deps = components["lib/main.dart::Car.go"].depends_on + assert "lib/util.dart::Engine.start" in deps + assert not {d for d in deps if d.startswith("windows/")} + + +def test_barrel_reexport_selects_exported_definition(tmp_path: Path) -> None: + _write_repo( + tmp_path, + { + "lib/barrel.dart": "export 'src/card.dart';\n", + "lib/src/card.dart": "class InfoCard {}\n", + "lib/other/card.dart": "class InfoCard {}\n", + "lib/home.dart": ( + "import 'package:demo/barrel.dart';\n" + "class Home {\n void show() { InfoCard(); }\n}\n" + ), + }, + ) + components = DependencyParser(str(tmp_path)).parse_repository() + deps = components["lib/home.dart::Home.show"].depends_on + assert "lib/src/card.dart::InfoCard" in deps + assert "lib/other/card.dart::InfoCard" not in deps + + +def test_two_unnamed_extensions_on_same_type_stay_distinct(tmp_path: Path) -> None: + source = ( + "extension on String {\n int a() => 1;\n}\nextension on String {\n int b() => 2;\n}\n" + ) + nodes, _ = _analyze(tmp_path, source) + ids = [n.id for n in nodes] + assert len([i for i in ids if "extension_on_String" in i and i.count(".") == 1]) == 2 + assert any(i.endswith(".a") for i in ids) and any(i.endswith(".b") for i in ids) + assert len(ids) == len(set(ids)) + + +def test_member_annotations_are_part_of_the_span(tmp_path: Path) -> None: + source = ( + "class A {\n @override\n @Deprecated('x')\n void f() {}\n}\n@pragma('x')\nvoid g() {}\n" + ) + nodes, _ = _analyze(tmp_path, source) + by_name = {n.name: n for n in nodes} + assert by_name["A.f"].source_code.lstrip().startswith("@override") + assert by_name["A.f"].start_line == 2 + assert by_name["g"].source_code.lstrip().startswith("@pragma") diff --git a/tests/test_dart_flutter.py b/tests/test_dart_flutter.py new file mode 100644 index 00000000..2dcc7f7c --- /dev/null +++ b/tests/test_dart_flutter.py @@ -0,0 +1,193 @@ +"""Tests for the Flutter-aware parts of the Dart analyzer.""" + +from pathlib import Path + +import pytest + +pytest.importorskip("tree_sitter_language_pack") + +from codewiki.src.be.dependency_analyzer.analyzers.dart import analyze_dart_file +from codewiki.src.be.dependency_analyzer.analyzers.dart_flutter import ( + classify_class, + classify_top_level_initializer, + provider_alias_candidates, +) +from codewiki.src.be.dependency_analyzer.ast_parser import DependencyParser + +APP = """\ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +final carProvider = FutureProvider((ref) => ref.watch(repoProvider).fetch()); +final counterProvider = NotifierProvider(CounterNotifier.new); +final router = GoRouter(routes: [ + GoRoute(path: '/', builder: (c, s) => const HomePage()), + GoRoute(path: '/settings', builder: (c, s) => SettingsPage()), +]); +final greeting = 'hi'; + +class CounterNotifier extends Notifier { + @override + int build() => 0; +} + +class HomePage extends ConsumerWidget { + const HomePage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final car = ref.watch(carProvider); + final n = ref.watch(counterProvider.notifier); + return Scaffold( + body: Column(children: [CarCard(car: car), BatteryGauge.small(), _buildFooter()]), + ); + } + + Widget _buildFooter() => const Footer(); +} + +class CarCard extends StatelessWidget { + Widget build(BuildContext context) => Text('car'); +} + +class Counter extends StatefulWidget { + @override + State createState() => _CounterState(); +} + +class _CounterState extends State { + @override + Widget build(BuildContext context) => CarCard(); +} + +class FancyCard extends CarCard {} +""" + + +def _analyze(tmp_path: Path, source: str = APP, name: str = "lib/app.dart"): + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(source, encoding="utf-8") + return analyze_dart_file(str(path), source, repo_path=str(tmp_path)) + + +def _edges(relationships): + return {(r.caller.split("::")[-1], r.callee.split("::")[-1]) for r in relationships} + + +def test_classifiers() -> None: + assert classify_class("StatelessWidget", set()) == "widget" + assert classify_class("ConsumerStatefulWidget", set()) == "widget" + assert classify_class("CarCard", {"CarCard"}) == "widget" + assert classify_class("State", set()) == "state" + assert classify_class("ConsumerState", set()) == "state" + assert classify_class("Notifier", set()) == "notifier" + assert classify_class("_$Counter", set()) is None + assert classify_class("Bloc", set()) == "bloc" + assert classify_class("HydratedCubit", set()) == "cubit" + assert classify_class("Cubit", set()) == "cubit" + assert classify_class("Repo", set()) is None + assert classify_class(None, set()) is None + assert classify_top_level_initializer(" FutureProvider((ref) => 1)") == "provider" + assert classify_top_level_initializer(" StateProvider.autoDispose((ref) => 0)") == "provider" + assert ( + classify_top_level_initializer(" FutureProvider.family((ref, id) => id)") + == "provider" + ) + assert classify_top_level_initializer(" GoRouter(routes: [])") == "router" + assert classify_top_level_initializer(" 'hi'") is None + assert classify_top_level_initializer(" AuthProvider()") is None + assert ( + classify_top_level_initializer(" NotifierProvider.autoDispose(A.new)") == "provider" + ) + assert ( + classify_top_level_initializer(" AutoDisposeFutureProvider((ref) => 1)") == "provider" + ) + assert provider_alias_candidates("cartProvider") == ["cart", "Cart", "CartNotifier"] + assert provider_alias_candidates("Provider") == [] + assert provider_alias_candidates("cart") == [] + + +def test_flutter_node_types(tmp_path: Path) -> None: + nodes, _ = _analyze(tmp_path) + types = {n.name: (n.component_type, n.node_type) for n in nodes} + assert types["HomePage"] == ("class", "widget") + assert types["CarCard"] == ("class", "widget") + assert types["FancyCard"] == ("class", "widget") # same-file widget subclass + assert types["Counter"] == ("class", "widget") + assert types["_CounterState"] == ("class", "state") + assert types["CounterNotifier"] == ("class", "notifier") + assert types["carProvider"] == ("function", "provider") + assert types["counterProvider"] == ("function", "provider") + assert types["router"] == ("function", "router") + assert "greeting" not in types + assert next(n for n in nodes if n.name == "HomePage").display_name == "widget HomePage" + + +def test_build_composition_is_lifted_to_widget_class(tmp_path: Path) -> None: + edges = _edges(_analyze(tmp_path)[1]) + for child in ("CarCard", "BatteryGauge", "Footer", "Scaffold", "Column"): + assert ("HomePage", child) in edges, child + assert ("HomePage.build", "CarCard") in edges # method-level edge kept + assert ("Counter", "_CounterState") in edges # createState pairing + assert ("_CounterState", "Counter") in edges # State type argument + assert ("_CounterState", "CarCard") in edges + + +def test_riverpod_and_router_edges(tmp_path: Path) -> None: + edges = _edges(_analyze(tmp_path)[1]) + assert ("HomePage.build", "carProvider") in edges + assert ("HomePage.build", "counterProvider") in edges # .notifier stripped + assert ("carProvider", "repoProvider") in edges + assert ("carProvider", "Car") in edges + assert ("counterProvider", "CounterNotifier") in edges # type arg and tear-off + assert ("router", "HomePage") in edges + assert ("router", "SettingsPage") in edges + callees = {callee for _, callee in edges} + assert "ref.watch" not in callees and "watch" not in callees + + +def test_riverpod_codegen_alias_resolves(tmp_path: Path) -> None: + (tmp_path / "pubspec.yaml").write_text("name: demo\n", encoding="utf-8") + lib = tmp_path / "lib" + lib.mkdir() + (lib / "state.dart").write_text( + "import 'package:riverpod_annotation/riverpod_annotation.dart';\n" + "part 'state.g.dart';\n" + "@riverpod\nclass Counter extends _$Counter {\n @override\n int build() => 0;\n}\n" + "@riverpod\nclass CartNotifier extends _$CartNotifier {\n int build() => 0;\n}\n" + "@Riverpod(keepAlive: true)\nFuture total(Ref ref) async => 1;\n" + "Future notAProvider() async => 1;\n" + "class Cart extends ChangeNotifier {}\n" + "class AppDatabase extends _$AppDatabase {}\n", + encoding="utf-8", + ) + (lib / "state.g.dart").write_text("part of 'state.dart';\n", encoding="utf-8") + (lib / "view.dart").write_text( + "import 'state.dart';\n" + "class View extends ConsumerWidget {\n" + " Widget build(BuildContext context, WidgetRef ref) {\n" + " ref.watch(counterProvider);\n" + " ref.watch(totalProvider);\n" + " ref.watch(cartProvider);\n" # Riverpod 3 naming + " ref.read(cartNotifierProvider);\n" # Riverpod 2 naming + " ref.watch(notAProviderProvider);\n" + " ref.watch(appDatabaseProvider);\n" + " return const Placeholder();\n }\n}\n", + encoding="utf-8", + ) + components = DependencyParser(str(tmp_path)).parse_repository() + assert components["lib/state.dart::Counter"].node_type == "riverpod notifier" + assert components["lib/state.dart::total"].node_type == "riverpod provider" + deps = components["lib/view.dart::View.build"].depends_on + assert { + "lib/state.dart::Counter", + "lib/state.dart::total", + "lib/state.dart::CartNotifier", + } <= deps + # Only @riverpod-annotated declarations are alias targets. + assert "lib/state.dart::notAProvider" not in deps + assert "lib/state.dart::AppDatabase" not in deps # `extends _$X` alone is not @riverpod + assert "lib/state.dart::Cart" not in deps # ChangeNotifier is not @riverpod + assert components["lib/state.dart::AppDatabase"].node_type == "class" diff --git a/tests/test_dart_imports.py b/tests/test_dart_imports.py new file mode 100644 index 00000000..d97c7995 --- /dev/null +++ b/tests/test_dart_imports.py @@ -0,0 +1,136 @@ +"""Tests for Dart directive parsing, URI resolution and visibility scopes.""" + +from pathlib import Path + +import pytest + +pytest.importorskip("tree_sitter_language_pack") + +from tree_sitter_language_pack import get_parser + +from codewiki.src.be.dependency_analyzer.analyzers.dart_imports import ( + DartDirective, + DartPackageResolver, + build_scopes, + is_private_dart_name, + parse_directives, +) + + +def _directives(source: str) -> list[DartDirective]: + return parse_directives(get_parser("dart").parse(source.encode()).root_node) + + +def test_parse_directives_covers_all_forms() -> None: + source = ( + "import 'package:flutter/material.dart';\n" + "import 'package:demo/models/car.dart' as car show Car;\n" + "import '../services/api.dart' hide Foo;\n" + "export 'src/widgets.dart';\n" + "part 'home.g.dart';\n" + ) + assert _directives(source) == [ + DartDirective("import", "package:flutter/material.dart", None, 1), + DartDirective("import", "package:demo/models/car.dart", "car", 2), + DartDirective("import", "../services/api.dart", None, 3), + DartDirective("export", "src/widgets.dart", None, 4), + DartDirective("part", "home.g.dart", None, 5), + ] + + +def test_parse_part_of_uri_and_library_name_forms() -> None: + assert _directives("part of 'home.dart';\n") == [DartDirective("part_of", "home.dart", None, 1)] + assert _directives("part of my.lib;\n") == [DartDirective("part_of", None, None, 1)] + + +def test_package_resolver_maps_own_package_relative_and_rejects_others(tmp_path: Path) -> None: + (tmp_path / "pubspec.yaml").write_text("name: demo\nversion: 1.0.0\n", encoding="utf-8") + resolver = DartPackageResolver.from_files(str(tmp_path), ["lib/a/home.dart"]) + assert resolver.packages == {"demo": ""} + assert ( + resolver.resolve("package:demo/models/car.dart", "lib/a/home.dart") == "lib/models/car.dart" + ) + assert resolver.resolve("../b/x.dart", "lib/a/home.dart") == "lib/b/x.dart" + assert resolver.resolve("x.dart", "lib/a/home.dart") == "lib/a/x.dart" + assert resolver.resolve("package:flutter/material.dart", "lib/a/home.dart") is None + assert resolver.resolve("dart:async", "lib/a/home.dart") is None + assert resolver.resolve("../../../escape.dart", "lib/a/home.dart") is None + assert resolver.resolve(None, "lib/a/home.dart") is None + + +def test_package_resolver_handles_nested_packages(tmp_path: Path) -> None: + (tmp_path / "pubspec.yaml").write_text("name: root_app\n", encoding="utf-8") + (tmp_path / "packages" / "core").mkdir(parents=True) + (tmp_path / "packages" / "core" / "pubspec.yaml").write_text("name: 'core'\n", encoding="utf-8") + resolver = DartPackageResolver.from_files( + str(tmp_path), ["lib/main.dart", "packages/core/lib/src/util.dart"] + ) + assert resolver.packages == {"root_app": "", "core": "packages/core"} + assert resolver.resolve("package:core/src/util.dart", "lib/main.dart") == ( + "packages/core/lib/src/util.dart" + ) + + +def test_build_scopes_follows_imports_parts_and_exports() -> None: + resolver = DartPackageResolver({"demo": ""}) + directives = { + "lib/home.dart": [ + DartDirective("import", "package:demo/barrel.dart"), + DartDirective("part", "home_part.dart"), + ], + "lib/home_part.dart": [DartDirective("part_of", "home.dart")], + "lib/barrel.dart": [DartDirective("export", "src/card.dart")], + "lib/src/card.dart": [DartDirective("export", "deep.dart")], + "lib/src/deep.dart": [], + "lib/other.dart": [], + } + scopes = build_scopes(directives, resolver, set(directives)) + expected = { + "lib/home.dart", + "lib/home_part.dart", + "lib/barrel.dart", + "lib/src/card.dart", + "lib/src/deep.dart", + } + assert scopes.visible["lib/home.dart"] == expected + # A part sees exactly what its library sees. + assert scopes.visible["lib/home_part.dart"] == expected + assert scopes.library_members["lib/home_part.dart"] == {"lib/home.dart", "lib/home_part.dart"} + assert scopes.visible["lib/other.dart"] == {"lib/other.dart"} + + +def test_part_to_missing_file_is_ignored() -> None: + resolver = DartPackageResolver({"demo": ""}) + directives = {"lib/home.dart": [DartDirective("part", "home.g.dart")]} + scopes = build_scopes(directives, resolver, {"lib/home.dart"}) + assert scopes.visible["lib/home.dart"] == {"lib/home.dart"} + assert scopes.library_members["lib/home.dart"] == {"lib/home.dart"} + + +def test_export_cycles_terminate() -> None: + resolver = DartPackageResolver({}) + directives = { + "a.dart": [DartDirective("import", "b.dart")], + "b.dart": [DartDirective("export", "c.dart")], + "c.dart": [DartDirective("export", "b.dart")], + } + scopes = build_scopes(directives, resolver, set(directives)) + assert scopes.visible["a.dart"] == {"a.dart", "b.dart", "c.dart"} + + +def test_is_private_dart_name() -> None: + assert is_private_dart_name("_Body") + assert is_private_dart_name("Foo._bar") + assert not is_private_dart_name("Foo.bar") + assert not is_private_dart_name("lib/a.dart::_Body") + + +def test_part_of_fallback_joins_library_without_part_directive() -> None: + resolver = DartPackageResolver({}) + directives = { + "lib/lib.dart": [], + "lib/extra.dart": [DartDirective("part_of", "lib.dart")], + } + scopes = build_scopes(directives, resolver, set(directives)) + assert scopes.library_members["lib/extra.dart"] == {"lib/lib.dart", "lib/extra.dart"} + assert scopes.visible["lib/lib.dart"] == {"lib/lib.dart", "lib/extra.dart"} diff --git a/tests/test_doc_layout.py b/tests/test_doc_layout.py index 68358085..0569373c 100644 --- a/tests/test_doc_layout.py +++ b/tests/test_doc_layout.py @@ -8,9 +8,11 @@ from types import SimpleNamespace from codewiki.src.be import doc_layout as L +from codewiki.src.be.dependency_analyzer.models.core import Node from codewiki.src.be.documentation_generator import DocumentationGenerator from codewiki.src.be.module_naming import find_missing_module_docs, sub_module_report from codewiki.src.be.prompt_template import ( + DART_FLUTTER_NOTE, format_leaf_system_prompt, format_system_prompt, format_user_prompt, @@ -358,3 +360,31 @@ def test_module_names_drop_ampersand(): assert sanitize_module_name("Data_Model_&_Persistence") == "Data_Model___Persistence" tree = dedupe_module_tree_names({"A & B": {"components": [], "children": {}}}) assert list(tree) == ["A___B"] + + +def _component(tmp_path, relpath: str, cid: str) -> Node: + path = tmp_path / relpath + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("// code\n", encoding="utf-8") + return Node( + id=cid, + name=cid.split("::")[-1], + component_type="class", + file_path=str(path), + relative_path=relpath, + ) + + +def test_dart_modules_get_flutter_note(tmp_path): + components = { + "lib/home.dart::HomePage": _component(tmp_path, "lib/home.dart", "lib/home.dart::HomePage") + } + prompt = format_user_prompt("auth", list(components), components, TREE, ["auth"], "flat") + assert DART_FLUTTER_NOTE in prompt + assert "```dart" in prompt + + +def test_non_dart_modules_have_no_flutter_note(tmp_path): + components = {"a.py::A": _component(tmp_path, "a.py", "a.py::A")} + prompt = format_user_prompt("auth", list(components), components, TREE, ["auth"], "flat") + assert DART_FLUTTER_NOTE not in prompt