Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions codewiki/src/be/dependency_analyzer/analyzers/csharp.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,9 @@ def _extract_nodes(self, node, top_level_nodes, lines):
node_type = "delegate"
node_name = self._decl_name(node)
qualified_name = self._qualify(node, *self._find_containing_type_names(node), node_name)
elif node.type == "method_declaration":
elif node.type in ("method_declaration", "constructor_declaration"):
# A constructor is a component like any method (`A.A`, as C++ names it): it often
# does the real work, and calls in its body need it as their caller.
method_name = self._decl_name(node)
if method_name:
node_type = "method"
Expand Down Expand Up @@ -626,7 +628,7 @@ def _find_containing_type_names(self, node) -> list:
def _find_containing_method(self, node):
current = node.parent
while current:
if current.type == "method_declaration":
if current.type in ("method_declaration", "constructor_declaration"):
method_name = self._decl_name(current)
class_name = self._find_containing_class_name(current)
if method_name and class_name:
Expand Down
33 changes: 29 additions & 4 deletions codewiki/src/be/dependency_analyzer/analyzers/dart.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@
the broken span are still extracted.
- Generic invocations ``Foo<T>(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.
- A constructor with a body or an initializer list is a component (``A.A``
for the unnamed one, ``A.named`` for a named or factory one), like any
method: it often does the real work. Constructors with neither, setters
and operators are not components; calls in their code and in field
initializers are attributed to the enclosing class.
"""

import logging
Expand Down Expand Up @@ -57,6 +59,10 @@
# 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"}
# Constructors that become components when they have code of their own (a body or initializers).
_CONSTRUCTOR_SIGNATURES = frozenset(
{"constructor_signature", "factory_constructor_signature", "constant_constructor_signature"}
)
# Member signatures whose code belongs to the class rather than a component.
_CLASS_LEVEL_SIGNATURES = frozenset(
{
Expand Down Expand Up @@ -493,7 +499,26 @@ def _add_member(self, owner: str, wrapper, body, lines):
if body is not None:
self._scan_targets.append(_ScanTarget(node.id, owner, body, signature, name))
return
# Constructors (incl. initializer lists), setters, operators: the
if signature.type in _CONSTRUCTOR_SIGNATURES and (
body is not None or _first_child(wrapper, "initializers") is not None
):
names = [_text(c) for c in signature.named_children if c.type == "identifier"]
name = names[-1] if names else owner # `A(...)` -> A.A, `A.named(...)` -> A.named
node = self._add_node(
wrapper,
body or wrapper,
f"{owner}.{name}",
"method",
"constructor",
lines,
class_name=owner,
parameters=_parameter_names(signature),
)
self._scan_targets.append(_ScanTarget(node.id, owner, wrapper, signature))
if body is not None:
self._scan_targets.append(_ScanTarget(node.id, owner, body, signature))
return
# Constructors with no code of their own, setters, operators: the
# class owns their code.
class_id = self._component_id(owner)
self._scan_targets.append(_ScanTarget(class_id, owner, wrapper, signature))
Expand Down
9 changes: 7 additions & 2 deletions codewiki/src/be/dependency_analyzer/analyzers/java.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@

logger = logging.getLogger(__name__)

# A record's compact constructor (`record R(int x) { R { ... } }`) has no parameter list.
CONSTRUCTORS = ("constructor_declaration", "compact_constructor_declaration")


class TreeSitterJavaAnalyzer:
def __init__(self, file_path: str, content: str, repo_path: str = None):
Expand Down Expand Up @@ -133,7 +136,9 @@ def _extract_nodes(self, node, top_level_nodes, lines):
qualified_name = self._qualified_type_name(
node_name, self._find_containing_type_names(node)
)
elif node.type == "method_declaration":
elif node.type in ("method_declaration", *CONSTRUCTORS):
# A constructor is a component like any method (`A.A`, as C++ names it): it often
# does the real work, and calls in its body need it as their caller.
node_type = "method"
name_node = next((c for c in node.children if c.type == "identifier"), None)
if name_node:
Expand Down Expand Up @@ -602,7 +607,7 @@ def _find_containing_type_names(self, node) -> list[str]:
def _find_containing_method(self, node):
current = node.parent
while current:
if current.type == "method_declaration":
if current.type in ("method_declaration", *CONSTRUCTORS):
method_name = self._get_identifier_name(current)
class_name = self._find_containing_class_name(current)
if method_name and class_name:
Expand Down
5 changes: 3 additions & 2 deletions codewiki/src/be/dependency_analyzer/analyzers/javascript.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,8 +176,9 @@ def _extract_methods_from_class(self, class_node, class_name: str) -> None:
if method_name:
method_node = self._create_method_node(child, method_name, class_name)
if method_node:
if method_name != "constructor":
self.nodes.append(method_node)
# A constructor is a component like any method (Python's __init__ is one): it
# often does the real work, and calls in its body need it as their caller.
self.nodes.append(method_node)
self.top_level_nodes[method_node.name] = method_node
elif child.type == "field_definition":
# Handle arrow function properties
Expand Down
22 changes: 16 additions & 6 deletions codewiki/src/be/dependency_analyzer/analyzers/kotlin.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@
logger = logging.getLogger(__name__)


# A constructor with a body is a component like any method (it often does the real work, and calls
# in its body need it as their caller). The primary constructor has no body of its own.
CONSTRUCTORS = {"secondary_constructor": "constructor", "anonymous_initializer": "init"}


class TreeSitterKotlinAnalyzer:
def __init__(self, file_path: str, content: str, repo_path: Optional[str] = None):
self.file_path = Path(file_path)
Expand Down Expand Up @@ -97,10 +102,9 @@ def _extract_nodes(self, node, top_level_nodes, lines):
name_node = next((c for c in node.children if c.type == "identifier"), None)
node_name = name_node.text.decode() if name_node else None

elif node.type == "function_declaration":
name_node = next((c for c in node.children if c.type == "identifier"), None)
if name_node:
method_name = name_node.text.decode()
elif node.type in ("function_declaration", *CONSTRUCTORS):
method_name = self._member_name(node)
if method_name:
containing_class = self._find_containing_class_name(node)
if containing_class:
node_type = "method"
Expand Down Expand Up @@ -414,12 +418,18 @@ def _find_containing_class(self, node, top_level_nodes):
return self._get_component_id(class_name)
return None

def _member_name(self, node) -> Optional[str]:
"""A function's name; a secondary constructor is `constructor`, an `init` block `init`."""
if node.type in CONSTRUCTORS:
return CONSTRUCTORS[node.type]
return self._get_identifier_name(node)

def _find_containing_method(self, node):
"""Find the component ID of the containing function/method."""
current = node.parent
while current:
if current.type == "function_declaration":
method_name = self._get_identifier_name(current)
if current.type in ("function_declaration", *CONSTRUCTORS):
method_name = self._member_name(current)
class_name = self._find_containing_class_name(current)
if method_name:
if class_name:
Expand Down
8 changes: 1 addition & 7 deletions codewiki/src/be/dependency_analyzer/analyzers/ruby.py
Original file line number Diff line number Diff line change
Expand Up @@ -550,17 +550,13 @@ def _add_method_node(self, node, method_name: str, scope: list[str], lines):
class_name = None

parameters = self._method_parameters(node)
# Constructors are registered for call resolution but are not
# documentable components themselves.
include_in_nodes = method_name != "initialize"
self._add_node(
node,
logical_name,
component_type,
lines,
parameters=parameters,
class_name=class_name,
include_in_nodes=include_in_nodes,
)

def _add_node(
Expand All @@ -573,7 +569,6 @@ def _add_node(
base_classes: list[str] | None = None,
class_name: str | None = None,
qualified_name: str | None = None,
include_in_nodes: bool = True,
):
component_id = self._get_component_id(logical_name)
relative_path = self._get_relative_path()
Expand Down Expand Up @@ -613,8 +608,7 @@ def _add_node(
language="ruby",
qualified_name=qualified_name or logical_name,
)
if include_in_nodes:
self.nodes.append(node_obj)
self.nodes.append(node_obj)
self.top_level_nodes[logical_name] = node_obj

def _method_parameters(self, method_node) -> list[str] | None:
Expand Down
3 changes: 2 additions & 1 deletion codewiki/src/be/dependency_analyzer/analyzers/typescript.py
Original file line number Diff line number Diff line change
Expand Up @@ -608,7 +608,8 @@ def _create_node_from_entity(self, entity_data: dict) -> Optional[Node]:
return None

def _should_include_node(self, node: Node) -> bool:
excluded_names = {"constructor", "__proto__", "prototype"}
# A constructor is a component like any method: it often does the real work.
excluded_names = {"__proto__", "prototype"}

if node.component_type == "variable":
return False
Expand Down
177 changes: 177 additions & 0 deletions tests/test_constructors_are_components.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
"""A class's constructor is a component like any method, in every object-oriented analyzer.

It often does the real work (node-semver's ``SemVer`` constructor parses the whole version string), and calls
in its body need it as their caller: JS and Ruby registered the constructor only for call resolution, so its
calls had a caller that was not a component and their edges were dropped; Java, C#, Kotlin and Dart gave
them to the class. Python (``__init__``), PHP (``__construct``), C++ (``A.A``) and Scala (``A.this``) already
emitted it as a method.
"""

import pytest

from codewiki.src.be.dependency_analyzer.analyzers.csharp import analyze_csharp_file
from codewiki.src.be.dependency_analyzer.analyzers.dart import TreeSitterDartAnalyzer
from codewiki.src.be.dependency_analyzer.analyzers.java import analyze_java_file
from codewiki.src.be.dependency_analyzer.analyzers.javascript import (
analyze_javascript_file_treesitter,
)
from codewiki.src.be.dependency_analyzer.analyzers.kotlin import TreeSitterKotlinAnalyzer
from codewiki.src.be.dependency_analyzer.analyzers.ruby import analyze_ruby_file
from codewiki.src.be.dependency_analyzer.analyzers.typescript import (
analyze_typescript_file_treesitter,
)


def _kotlin(path, src, repo):
a = TreeSitterKotlinAnalyzer(path, src, repo)
return a.nodes, a.call_relationships


def _dart(path, src, repo):
a = TreeSitterDartAnalyzer(path, src, repo)
return a.nodes, a.call_relationships


CASES = {
"javascript": (
analyze_javascript_file_treesitter,
"semver.js",
"""function parse(v) { return v }
class SemVer {
constructor(version) { this.raw = parse(version) }
format() { return this.raw }
}
""",
"SemVer.constructor",
"parse",
),
"typescript": (
analyze_typescript_file_treesitter,
"struct.ts",
"""function check(x: number): number { return x }
export class Struct {
value: number
constructor(v: number) { this.value = check(v) }
}
""",
"Struct.constructor",
"check",
),
"ruby": (
analyze_ruby_file,
"color.rb",
"""def parse(x) x end
class Color
def initialize(x)
@v = parse(x)
end
end
""",
"Color.initialize",
"parse",
),
"java": (
analyze_java_file,
"A.java",
"""class A {
private int v;
A(int x) { v = parse(x); }
int parse(int x) { return x; }
}
""",
"A.A",
"parse",
),
"csharp": (
analyze_csharp_file,
"A.cs",
"""class A {
int v;
public A(int x) { v = Parse(x); }
int Parse(int x) { return x; }
}
""",
"A.A",
"Parse",
),
"kotlin": (
_kotlin,
"A.kt",
"""class A(val x: Int) {
var v = 0
constructor(s: String) : this(s.length) { v = parse(x) }
fun parse(x: Int): Int = x
}
""",
"A.constructor",
"parse",
),
"dart": (
_dart,
"a.dart",
"""class A {
int v;
A(int x) : v = 0 { v = parse(x); }
static int parse(x) => 0;
}
""",
"A.A",
"parse",
),
}


def _grammar_loads(lang: str) -> bool:
"""requirements.txt pins tree-sitter 0.23.2, which cannot load tree-sitter-c-sharp's ABI 15 grammar."""
if lang != "csharp":
return True
import tree_sitter_c_sharp
from tree_sitter import Language, Parser

try:
Parser(Language(tree_sitter_c_sharp.language()))
except ValueError:
return False
return True


@pytest.mark.parametrize("lang", sorted(CASES))
def test_a_constructor_is_a_method_component_and_the_caller_of_its_calls(lang, tmp_path):
if not _grammar_loads(lang):
pytest.skip(f"this tree-sitter cannot load the {lang} grammar")
analyze, name, src, ctor, callee = CASES[lang]
path = tmp_path / name
path.write_text(src)
nodes, edges = analyze(str(path), src, str(tmp_path))
by_name = {n.name: n for n in nodes}
assert ctor in by_name, sorted(by_name)
assert by_name[ctor].component_type in ("method", "function")
assert by_name[ctor].id == f"{name}::{ctor}"
callers = {e.caller for e in edges if e.callee.split(".")[-1].split("::")[-1] == callee}
assert f"{name}::{ctor}" in callers, sorted((e.caller, e.callee) for e in edges)


def test_kotlin_init_blocks_are_components(tmp_path):
src = """class A(val x: Int) {
var v = 0
init { v = parse(x) }
fun parse(x: Int): Int = x
}
"""
nodes, edges = _kotlin(str(tmp_path / "A.kt"), src, str(tmp_path))
assert "A.init" in {n.name for n in nodes}
assert any(e.caller == "A.kt::A.init" and e.callee.endswith("parse") for e in edges)


def test_a_dart_constructor_with_no_code_of_its_own_stays_with_its_class(tmp_path):
src = """class A {
int v;
A(this.v);
const A.c(this.v);
factory A.redir(int x) = A;
factory A.of(int x) => A(x);
}
"""
names = {n.name for n in _dart(str(tmp_path / "a.dart"), src, str(tmp_path))[0]}
assert "A.of" in names
assert not names & {"A.A", "A.c", "A.redir"}
Loading
Loading