graphify-php 0.0.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- graphify_php/__init__.py +144 -0
- graphify_php/cli.py +355 -0
- graphify_php/config/__init__.py +47 -0
- graphify_php/config/class_lookup.py +85 -0
- graphify_php/config/discovery.py +55 -0
- graphify_php/config/document.py +29 -0
- graphify_php/config/emit.py +117 -0
- graphify_php/config/environment.py +108 -0
- graphify_php/config/facts.py +97 -0
- graphify_php/config/messaging.py +142 -0
- graphify_php/config/ports.py +23 -0
- graphify_php/config/safe_yaml.py +127 -0
- graphify_php/config_resolver.py +164 -0
- graphify_php/container/__init__.py +50 -0
- graphify_php/container/facts.py +178 -0
- graphify_php/container/layer.py +71 -0
- graphify_php/container/locator.py +74 -0
- graphify_php/container/overlay.py +149 -0
- graphify_php/container/ports.py +40 -0
- graphify_php/container/reader.py +330 -0
- graphify_php/container/reasons.py +31 -0
- graphify_php/graph_adapter.py +520 -0
- graphify_php/php/README.md +106 -0
- graphify_php/php/autoload.php +27 -0
- graphify_php/php/src/CallGraph/CallEdge.php +91 -0
- graphify_php/php/src/CallGraph/CallEdgeRule.php +61 -0
- graphify_php/php/src/CallGraph/CallEdgeWriter.php +19 -0
- graphify_php/php/src/CallGraph/JsonLinesWriter.php +64 -0
- graphify_php/php/src/CallGraph/MethodCallCollector.php +163 -0
- graphify_php/ports.py +174 -0
- graphify_php/resolver.py +519 -0
- graphify_php/shrink.py +413 -0
- graphify_php/shrink_cli.py +150 -0
- graphify_php/sources/phpstan_source.py +641 -0
- graphify_php/sources/route_source.py +105 -0
- graphify_php/sources/tree_sitter_source.py +428 -0
- graphify_php/status.py +136 -0
- graphify_php/syntax/call_sites.py +117 -0
- graphify_php/syntax/class_index.py +121 -0
- graphify_php/syntax/collectors.py +351 -0
- graphify_php/syntax/facts.py +171 -0
- graphify_php/syntax/file_scan.py +158 -0
- graphify_php/syntax/names.py +49 -0
- graphify_php/syntax/nodes.py +209 -0
- graphify_php/syntax/routes.py +288 -0
- graphify_php-0.0.5.dist-info/METADATA +218 -0
- graphify_php-0.0.5.dist-info/RECORD +50 -0
- graphify_php-0.0.5.dist-info/WHEEL +4 -0
- graphify_php-0.0.5.dist-info/entry_points.txt +3 -0
- graphify_php-0.0.5.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""What one PHP file says about the types of its receivers.
|
|
2
|
+
|
|
3
|
+
Scoped per class AND per method rather than flat. Two classes in one file may both declare
|
|
4
|
+
`$repo`, and two methods may both bind a local `$service`; a flat table silently merges them
|
|
5
|
+
and produces an edge to the wrong class — the same failure the short-name fallback produces,
|
|
6
|
+
reached by a different route.
|
|
7
|
+
|
|
8
|
+
A scope records two kinds of answer, and the second is the point of the package: a type it
|
|
9
|
+
knows, and a name it deliberately refuses with a reason. A property that is missing from both
|
|
10
|
+
is a gap; a property in `refusals` is a decision, and the reason travels all the way out to
|
|
11
|
+
`Resolution.unresolved` so the gap becomes a work list.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
|
|
18
|
+
from ..ports import Confidence
|
|
19
|
+
from .call_sites import CallSiteIndex
|
|
20
|
+
from .routes import RouteDeclaration
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class TypeFact:
|
|
25
|
+
"""A receiver type, with the evidence that produced it.
|
|
26
|
+
|
|
27
|
+
`precedence` is how two sources claiming the same property are settled without either
|
|
28
|
+
collector knowing the other exists: lowest wins, and the numbers live on the collectors.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
fqn: str
|
|
32
|
+
confidence: Confidence
|
|
33
|
+
via: str
|
|
34
|
+
precedence: int
|
|
35
|
+
# `A&B`: every class the object is at once. Empty for an ordinary single type. The one
|
|
36
|
+
# that owns the called method is chosen at the call site, not here, because which
|
|
37
|
+
# constituent is the target depends on the method being called.
|
|
38
|
+
members: tuple[str, ...] = ()
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def is_intersection(self) -> bool:
|
|
42
|
+
return bool(self.members)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class MethodScope:
|
|
47
|
+
name: str
|
|
48
|
+
start_line: int
|
|
49
|
+
end_line: int
|
|
50
|
+
locals: dict[str, TypeFact] = field(default_factory=dict)
|
|
51
|
+
refusals: dict[str, str] = field(default_factory=dict)
|
|
52
|
+
# Every local this method writes to, whether or not the write produced a usable type.
|
|
53
|
+
# A second write is what revokes a binding, so the ones that produced nothing still count.
|
|
54
|
+
assigned: set = field(default_factory=set)
|
|
55
|
+
|
|
56
|
+
def record_local(self, name: str, fact: TypeFact) -> None:
|
|
57
|
+
existing = self.locals.get(name)
|
|
58
|
+
if existing is not None and existing.precedence <= fact.precedence:
|
|
59
|
+
return
|
|
60
|
+
self.locals[name] = fact
|
|
61
|
+
|
|
62
|
+
def note_write(self, name: str, reason: str, precedence: int) -> bool:
|
|
63
|
+
"""Record a binding of `name`, revoking an earlier one. True when it was the first.
|
|
64
|
+
|
|
65
|
+
Every binding form counts — an assignment, a parameter, a `foreach` target, a `catch`
|
|
66
|
+
variable, a destructuring target — because without flow analysis a second write
|
|
67
|
+
anywhere in the method is the evidence that the first no longer holds at the call.
|
|
68
|
+
"""
|
|
69
|
+
rewritten = name in self.assigned
|
|
70
|
+
self.assigned.add(name)
|
|
71
|
+
if rewritten:
|
|
72
|
+
self.refuse_local(name, reason, precedence)
|
|
73
|
+
return not rewritten
|
|
74
|
+
|
|
75
|
+
def refuse_local(self, name: str, reason: str, precedence: int) -> None:
|
|
76
|
+
"""Drop whatever was known about a local and say why.
|
|
77
|
+
|
|
78
|
+
Refusal wins over a recorded type of equal or weaker precedence on purpose. The case
|
|
79
|
+
that forces it is a local reassigned after its binding: the earlier `new Emails()` is
|
|
80
|
+
still true at the point it was written and false by the time of the call, and there is
|
|
81
|
+
no flow analysis here to tell the two positions apart. A fact from a strictly stronger
|
|
82
|
+
source survives, so one collector's doubt cannot erase another's declaration.
|
|
83
|
+
"""
|
|
84
|
+
existing = self.locals.get(name)
|
|
85
|
+
if existing is not None and existing.precedence < precedence:
|
|
86
|
+
return
|
|
87
|
+
self.locals.pop(name, None)
|
|
88
|
+
self.refusals[name] = reason
|
|
89
|
+
|
|
90
|
+
def contains(self, line: int) -> bool:
|
|
91
|
+
return self.start_line <= line <= self.end_line
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class ClassScope:
|
|
96
|
+
fqn: str
|
|
97
|
+
start_line: int
|
|
98
|
+
end_line: int
|
|
99
|
+
parent: str | None = None
|
|
100
|
+
# extends, implements and `use`d traits, all resolved. One list because a method can be
|
|
101
|
+
# declared in any of them and PHP flattens all three into one class.
|
|
102
|
+
ancestors: list[str] = field(default_factory=list)
|
|
103
|
+
properties: dict[str, TypeFact] = field(default_factory=dict)
|
|
104
|
+
refusals: dict[str, str] = field(default_factory=dict)
|
|
105
|
+
methods: list[MethodScope] = field(default_factory=list)
|
|
106
|
+
|
|
107
|
+
def record_property(self, name: str, fact: TypeFact) -> None:
|
|
108
|
+
if name in self.refusals:
|
|
109
|
+
return
|
|
110
|
+
existing = self.properties.get(name)
|
|
111
|
+
if existing is not None and existing.precedence <= fact.precedence:
|
|
112
|
+
return
|
|
113
|
+
self.properties[name] = fact
|
|
114
|
+
|
|
115
|
+
def refuse_property(self, name: str, reason: str, precedence: int) -> None:
|
|
116
|
+
"""See `MethodScope.refuse_local`: a stronger source's fact outranks a weaker doubt."""
|
|
117
|
+
existing = self.properties.get(name)
|
|
118
|
+
if existing is not None and existing.precedence < precedence:
|
|
119
|
+
return
|
|
120
|
+
self.properties.pop(name, None)
|
|
121
|
+
self.refusals[name] = reason
|
|
122
|
+
|
|
123
|
+
def method_at(self, line: int) -> MethodScope | None:
|
|
124
|
+
for method in self.methods:
|
|
125
|
+
if method.contains(line):
|
|
126
|
+
return method
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
def contains(self, line: int) -> bool:
|
|
130
|
+
return self.start_line <= line <= self.end_line
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class FileFacts:
|
|
135
|
+
"""Everything one file proves about itself."""
|
|
136
|
+
|
|
137
|
+
path: str
|
|
138
|
+
namespace: str = ""
|
|
139
|
+
imports: dict[str, str] = field(default_factory=dict)
|
|
140
|
+
classes: list[ClassScope] = field(default_factory=list)
|
|
141
|
+
# The receivers graphify does not send: see `call_sites` for why they are read here.
|
|
142
|
+
calls: CallSiteIndex = field(default_factory=CallSiteIndex)
|
|
143
|
+
# Route attributes declared in this file. Not a type fact, carried here because the file
|
|
144
|
+
# is what owns them and one parse should answer every question about it.
|
|
145
|
+
routes: list[RouteDeclaration] = field(default_factory=list)
|
|
146
|
+
# Line spans of every `new class { ... }`. The walker reads nothing inside one, so a site
|
|
147
|
+
# there has no facts of its own and must not be answered with the enclosing class's.
|
|
148
|
+
anonymous_classes: list[tuple[int, int]] = field(default_factory=list)
|
|
149
|
+
|
|
150
|
+
def in_anonymous_class(self, line: int) -> bool:
|
|
151
|
+
return any(start <= line <= end for start, end in self.anonymous_classes)
|
|
152
|
+
|
|
153
|
+
def class_at(self, line: int) -> ClassScope | None:
|
|
154
|
+
"""The innermost class whose body covers `line`.
|
|
155
|
+
|
|
156
|
+
Innermost rather than first because a file may declare several classes and the spans
|
|
157
|
+
are checked, not assumed disjoint.
|
|
158
|
+
"""
|
|
159
|
+
best: ClassScope | None = None
|
|
160
|
+
for scope in self.classes:
|
|
161
|
+
if not scope.contains(line):
|
|
162
|
+
continue
|
|
163
|
+
if best is None or scope.start_line >= best.start_line:
|
|
164
|
+
best = scope
|
|
165
|
+
return best
|
|
166
|
+
|
|
167
|
+
def class_named(self, fqn: str) -> ClassScope | None:
|
|
168
|
+
for scope in self.classes:
|
|
169
|
+
if scope.fqn == fqn:
|
|
170
|
+
return scope
|
|
171
|
+
return None
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""The walker: one pass over one file, driving whatever collectors are registered.
|
|
2
|
+
|
|
3
|
+
This module owns scope and nothing else. It knows that `namespace` and `use` bind names, that
|
|
4
|
+
a class declaration opens a class scope and a method declaration a method scope, and that a
|
|
5
|
+
line number inside the file falls in one of them. It does not know what a type source is, and
|
|
6
|
+
adding a fifth one must not change a line here — that is why the collectors are a registry
|
|
7
|
+
rather than a chain of `elif`s, and why the four that exist are never named in this file.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from . import nodes
|
|
13
|
+
from .call_sites import index_calls
|
|
14
|
+
from .collectors import COLLECTORS, ScanContext, by_node_kind
|
|
15
|
+
from .facts import ClassScope, FileFacts, MethodScope
|
|
16
|
+
from .names import NameResolver
|
|
17
|
+
from .routes import collect_routes
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def scan_source(path: str, source: bytes, collectors=None) -> FileFacts:
|
|
21
|
+
"""Parse one file and return what it proves about its own receivers."""
|
|
22
|
+
tree = nodes.parse(source)
|
|
23
|
+
names = NameResolver()
|
|
24
|
+
facts = FileFacts(path=path, imports=names.imports)
|
|
25
|
+
registry = by_node_kind(collectors if collectors is not None else COLLECTORS)
|
|
26
|
+
_walk(tree.root_node, facts, names, registry, klass=None, method=None)
|
|
27
|
+
facts.namespace = names.namespace
|
|
28
|
+
# A second pass rather than a hook in `_walk`: locating calls is not scope work, and
|
|
29
|
+
# keeping it out means the walker still has one reason to change.
|
|
30
|
+
facts.calls = index_calls(tree.root_node)
|
|
31
|
+
facts.routes = collect_routes(tree.root_node, names, path)
|
|
32
|
+
return facts
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def scan_file(path: str, collectors=None) -> FileFacts:
|
|
36
|
+
with open(path, "rb") as handle:
|
|
37
|
+
return scan_source(path, handle.read(), collectors)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _walk(node, facts: FileFacts, names: NameResolver, registry, klass, method) -> None:
|
|
41
|
+
kind = node.type
|
|
42
|
+
|
|
43
|
+
if _is_anonymous_class(node):
|
|
44
|
+
# Its members belong to a class with no name, so attributing them to the enclosing
|
|
45
|
+
# class would invent properties the enclosing class does not have. The span is kept
|
|
46
|
+
# so a call site inside it is refused rather than read against the enclosing class.
|
|
47
|
+
facts.anonymous_classes.append(nodes.line_span(node))
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
if kind == "namespace_definition":
|
|
51
|
+
_read_namespace(node, names)
|
|
52
|
+
elif kind == "namespace_use_declaration":
|
|
53
|
+
_read_imports(node, names)
|
|
54
|
+
elif kind in nodes.CLASS_KINDS:
|
|
55
|
+
klass = _open_class(node, facts, names)
|
|
56
|
+
method = None
|
|
57
|
+
elif kind == "method_declaration" and klass is not None:
|
|
58
|
+
method = _open_method(node, klass)
|
|
59
|
+
elif kind == "use_declaration" and klass is not None:
|
|
60
|
+
# A trait `use` inside a class body, not a namespace import: the methods it brings in
|
|
61
|
+
# are declared by this class as far as any caller can tell.
|
|
62
|
+
klass.ancestors.extend(_names_in(node, names))
|
|
63
|
+
|
|
64
|
+
for collector in registry.get(kind, ()):
|
|
65
|
+
collector.collect(node, ScanContext(facts=facts, names=names, klass=klass, method=method))
|
|
66
|
+
|
|
67
|
+
for child in node.children:
|
|
68
|
+
_walk(child, facts, names, registry, klass, method)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _is_anonymous_class(node) -> bool:
|
|
72
|
+
"""`new class { ... }`, which the grammar hangs under an `object_creation_expression`."""
|
|
73
|
+
if node.type == "anonymous_class":
|
|
74
|
+
return True
|
|
75
|
+
return (
|
|
76
|
+
node.type == "object_creation_expression"
|
|
77
|
+
and nodes.child(node, "declaration_list") is not None
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _read_namespace(node, names: NameResolver) -> None:
|
|
82
|
+
name_node = nodes.child(node, "namespace_name", "qualified_name", "name")
|
|
83
|
+
if name_node is None:
|
|
84
|
+
return
|
|
85
|
+
# A braced `namespace X { ... }` block is read the same way, which is wrong for the rare
|
|
86
|
+
# file holding two of them. Two namespaces in one file is disallowed by PSR-4 and absent
|
|
87
|
+
# from the reference application, so the simpler reading stands until one turns up.
|
|
88
|
+
names.namespace = nodes.node_text(name_node).strip().strip(";").strip().strip("\\")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _read_imports(node, names: NameResolver) -> None:
|
|
92
|
+
"""`use A\\B\\C;` and `use A\\B\\C as D;`.
|
|
93
|
+
|
|
94
|
+
Grouped and function/const imports are not read: a grouped `use A\\{B, C};` nests its
|
|
95
|
+
clauses differently, and a function import binds a name that is never a call receiver.
|
|
96
|
+
"""
|
|
97
|
+
for clause in nodes.children(node, "namespace_use_clause"):
|
|
98
|
+
target = nodes.child(clause, "qualified_name", "name")
|
|
99
|
+
if target is None:
|
|
100
|
+
continue
|
|
101
|
+
fqn = nodes.node_text(target).strip().lstrip("\\")
|
|
102
|
+
if not fqn:
|
|
103
|
+
continue
|
|
104
|
+
names.add_import(_alias_of(clause) or fqn.rsplit("\\", 1)[-1], fqn)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _alias_of(clause) -> str:
|
|
108
|
+
"""The name `use ... as X` binds, or "" when the clause has no alias.
|
|
109
|
+
|
|
110
|
+
The alias is read as the last `name` under the clause rather than the first, because
|
|
111
|
+
`use Foo as Bar;` puts two bare `name` children side by side and the first one is the
|
|
112
|
+
imported class. Both clause shapes are handled: some grammar builds wrap the alias in a
|
|
113
|
+
`namespace_aliasing_clause`, others leave the `as` token flat among the children.
|
|
114
|
+
"""
|
|
115
|
+
aliasing = nodes.child(clause, "namespace_aliasing_clause")
|
|
116
|
+
if aliasing is not None:
|
|
117
|
+
return nodes.node_text(nodes.child(aliasing, "name"))
|
|
118
|
+
if nodes.child(clause, "as") is None:
|
|
119
|
+
return ""
|
|
120
|
+
name_children = nodes.children(clause, "name")
|
|
121
|
+
return nodes.node_text(name_children[-1]) if name_children else ""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _open_class(node, facts: FileFacts, names: NameResolver) -> ClassScope:
|
|
125
|
+
start, end = nodes.line_span(node)
|
|
126
|
+
scope = ClassScope(
|
|
127
|
+
fqn=names.qualify_declaration(nodes.name_of(node)), start_line=start, end_line=end
|
|
128
|
+
)
|
|
129
|
+
base = nodes.child(node, "base_clause")
|
|
130
|
+
if base is not None:
|
|
131
|
+
inherited = _names_in(base, names)
|
|
132
|
+
scope.ancestors.extend(inherited)
|
|
133
|
+
if inherited:
|
|
134
|
+
scope.parent = inherited[0]
|
|
135
|
+
implemented = nodes.child(node, "class_interface_clause")
|
|
136
|
+
if implemented is not None:
|
|
137
|
+
# An interface declares the method too, so it answers "does this member exist" even
|
|
138
|
+
# though it never answers "what runs".
|
|
139
|
+
scope.ancestors.extend(_names_in(implemented, names))
|
|
140
|
+
facts.classes.append(scope)
|
|
141
|
+
return scope
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _names_in(node, names: NameResolver) -> list[str]:
|
|
145
|
+
"""Every class name listed in a clause, resolved. `extends A, B` and `use T, U` both list."""
|
|
146
|
+
out = []
|
|
147
|
+
for child in nodes.children(node, "name", "qualified_name"):
|
|
148
|
+
text = nodes.node_text(child).strip()
|
|
149
|
+
if text:
|
|
150
|
+
out.append(names.resolve(text))
|
|
151
|
+
return out
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _open_method(node, klass: ClassScope) -> MethodScope:
|
|
155
|
+
start, end = nodes.line_span(node)
|
|
156
|
+
scope = MethodScope(name=nodes.name_of(node), start_line=start, end_line=end)
|
|
157
|
+
klass.methods.append(scope)
|
|
158
|
+
return scope
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Turning a source-level type name into a fully qualified one.
|
|
2
|
+
|
|
3
|
+
Its own module because this is where a resolver fabricates edges. A name written `Emails` in
|
|
4
|
+
one file and `Emails` in another is two different classes unless the file's `namespace` and
|
|
5
|
+
`use` lines say otherwise, and a resolver that matches on the short name links them anyway.
|
|
6
|
+
Six upstream issues across four code-graph projects trace their false edges to exactly that
|
|
7
|
+
fallback, so there is no fallback here: a name this file cannot see stays unqualified against
|
|
8
|
+
the file's own namespace and simply fails to match anything real.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class NameResolver:
|
|
18
|
+
"""The `namespace` and `use` lines of one file, and nothing else.
|
|
19
|
+
|
|
20
|
+
Deliberately holds no corpus-wide index. Without one it is not possible to answer "is
|
|
21
|
+
there a class called `Emails` somewhere?", which is the question whose answer is always a
|
|
22
|
+
guess.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
namespace: str = ""
|
|
26
|
+
imports: dict[str, str] = field(default_factory=dict) # alias -> FQN
|
|
27
|
+
|
|
28
|
+
def resolve(self, name: str) -> str:
|
|
29
|
+
"""PHP's own order: absolute, then alias, then relative to the file's namespace."""
|
|
30
|
+
if not name:
|
|
31
|
+
return name
|
|
32
|
+
if name.startswith("\\"):
|
|
33
|
+
return name.lstrip("\\")
|
|
34
|
+
head, _, tail = name.partition("\\")
|
|
35
|
+
alias = self.imports.get(head)
|
|
36
|
+
if alias is not None:
|
|
37
|
+
return f"{alias}\\{tail}" if tail else alias
|
|
38
|
+
if self.namespace:
|
|
39
|
+
return f"{self.namespace}\\{name}"
|
|
40
|
+
return name
|
|
41
|
+
|
|
42
|
+
def qualify_declaration(self, short: str) -> str:
|
|
43
|
+
"""The FQN of a class declared in this file."""
|
|
44
|
+
if not short:
|
|
45
|
+
return short
|
|
46
|
+
return f"{self.namespace}\\{short}" if self.namespace else short
|
|
47
|
+
|
|
48
|
+
def add_import(self, alias: str, fqn: str) -> None:
|
|
49
|
+
self.imports[alias] = fqn.lstrip("\\")
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Tree-sitter primitives shared by the collectors.
|
|
2
|
+
|
|
3
|
+
Kept separate from the collectors so that a collector is about one type source and nothing
|
|
4
|
+
else: the moment `type_name` lived next to the walker, every new source meant editing the
|
|
5
|
+
walker too.
|
|
6
|
+
|
|
7
|
+
Nothing here knows what a type source is, and nothing here knows about `ports`. The whole
|
|
8
|
+
module is "ask the grammar a question", so it changes when tree-sitter-php changes and for no
|
|
9
|
+
other reason.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
try: # both ship with graphify, so this is reuse rather than a new dependency
|
|
15
|
+
import tree_sitter as ts
|
|
16
|
+
import tree_sitter_php as tsp
|
|
17
|
+
except ImportError: # pragma: no cover - exercised only on a broken install
|
|
18
|
+
ts = None
|
|
19
|
+
tsp = None
|
|
20
|
+
|
|
21
|
+
# Type positions in the grammar. A declaration carries exactly one of these as a child, so the
|
|
22
|
+
# collectors name the set once here instead of repeating it at every declaration site.
|
|
23
|
+
TYPE_KINDS = (
|
|
24
|
+
"named_type",
|
|
25
|
+
"union_type",
|
|
26
|
+
"optional_type",
|
|
27
|
+
"primitive_type",
|
|
28
|
+
"intersection_type",
|
|
29
|
+
"disjunctive_normal_form_type", # `(A&B)|C`, a disjunction whose branches are intersections
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
CLASS_KINDS = (
|
|
33
|
+
"class_declaration",
|
|
34
|
+
"trait_declaration",
|
|
35
|
+
"interface_declaration",
|
|
36
|
+
"enum_declaration",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# A closure's parameters and `use` clause bind names of their own, and its body is not the
|
|
40
|
+
# enclosing method's body. Both grammar names for the `function () {}` form are listed because
|
|
41
|
+
# tree-sitter-php renamed the node between releases and graphify does not pin the version.
|
|
42
|
+
CLOSURE_KINDS = (
|
|
43
|
+
"anonymous_function",
|
|
44
|
+
"anonymous_function_creation_expression",
|
|
45
|
+
"arrow_function",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
_PARSER = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def tree_sitter_available() -> tuple[bool, str]:
|
|
52
|
+
if ts is None or tsp is None:
|
|
53
|
+
return False, "tree_sitter and tree_sitter_php are not importable"
|
|
54
|
+
return True, ""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def parser():
|
|
58
|
+
"""The one parser instance for this process.
|
|
59
|
+
|
|
60
|
+
Reused rather than rebuilt per file: constructing the Language and Parser dominates the
|
|
61
|
+
cost of parsing a single small file, and a build walks every PHP file in the repository.
|
|
62
|
+
"""
|
|
63
|
+
global _PARSER
|
|
64
|
+
if _PARSER is None:
|
|
65
|
+
ok, why = tree_sitter_available()
|
|
66
|
+
if not ok:
|
|
67
|
+
raise RuntimeError(why)
|
|
68
|
+
_PARSER = ts.Parser(ts.Language(tsp.language_php()))
|
|
69
|
+
return _PARSER
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def parse(source: bytes):
|
|
73
|
+
return parser().parse(source)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def node_text(node) -> str:
|
|
77
|
+
"""Source text of a node.
|
|
78
|
+
|
|
79
|
+
py-tree-sitter keeps the bytes it parsed on the node, so nothing downstream has to carry
|
|
80
|
+
the buffer alongside every node it handles.
|
|
81
|
+
"""
|
|
82
|
+
if node is None:
|
|
83
|
+
return ""
|
|
84
|
+
raw = node.text
|
|
85
|
+
return raw.decode("utf-8", "replace") if raw is not None else ""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def child(node, *kinds):
|
|
89
|
+
"""First direct child of any of `kinds`, or None.
|
|
90
|
+
|
|
91
|
+
Direct children only. A recursive search would reach into a nested declaration and read a
|
|
92
|
+
type belonging to another scope, which is the mistake this whole package exists to avoid.
|
|
93
|
+
"""
|
|
94
|
+
if node is None:
|
|
95
|
+
return None
|
|
96
|
+
for candidate in node.children:
|
|
97
|
+
if candidate.type in kinds:
|
|
98
|
+
return candidate
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def children(node, *kinds):
|
|
103
|
+
if node is None:
|
|
104
|
+
return []
|
|
105
|
+
return [c for c in node.children if c.type in kinds]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def name_of(node) -> str:
|
|
109
|
+
"""The declared identifier of a class or method declaration."""
|
|
110
|
+
return node_text(child(node, "name"))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def variable_of(node) -> str:
|
|
114
|
+
"""The bare name of a `variable_name` child, with the sigil removed."""
|
|
115
|
+
return node_text(child(node, "variable_name")).lstrip("$")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def line_span(node) -> tuple[int, int]:
|
|
119
|
+
"""1-based inclusive line span, matching how every other tool reports a location."""
|
|
120
|
+
return node.start_point[0] + 1, node.end_point[0] + 1
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def type_node(node):
|
|
124
|
+
"""The type annotation of a declaration, or None when it is untyped."""
|
|
125
|
+
return child(node, *TYPE_KINDS)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def is_union(node) -> bool:
|
|
129
|
+
"""Is this type position an alternation — the object is ONE OF several classes?
|
|
130
|
+
|
|
131
|
+
`A|B` and `(A&B)|C` both are: there are two candidate targets and nothing in the type says
|
|
132
|
+
which. An intersection is deliberately not here. `A&B` is one object that is both things
|
|
133
|
+
at once, so a method declared in only one constituent is an unambiguous target, and
|
|
134
|
+
`intersection_members` handles it instead.
|
|
135
|
+
"""
|
|
136
|
+
if node is None:
|
|
137
|
+
return False
|
|
138
|
+
if node.type in ("union_type", "disjunctive_normal_form_type"):
|
|
139
|
+
return True
|
|
140
|
+
if node.type == "optional_type":
|
|
141
|
+
return any(is_union(c) for c in node.children)
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def intersection_members(node) -> list[str]:
|
|
146
|
+
"""The class names of an `A&B` type, or [] when this is not an intersection.
|
|
147
|
+
|
|
148
|
+
Only class names are returned: `A&int` is not expressible in PHP, so a member that reads
|
|
149
|
+
as a keyword is a parse this code does not understand and is dropped rather than guessed.
|
|
150
|
+
"""
|
|
151
|
+
if node is None:
|
|
152
|
+
return []
|
|
153
|
+
if node.type == "optional_type":
|
|
154
|
+
for child in node.children:
|
|
155
|
+
found = intersection_members(child)
|
|
156
|
+
if found:
|
|
157
|
+
return found
|
|
158
|
+
return []
|
|
159
|
+
if node.type != "intersection_type":
|
|
160
|
+
return []
|
|
161
|
+
names = []
|
|
162
|
+
for child in node.children:
|
|
163
|
+
name = type_name(child)
|
|
164
|
+
if name:
|
|
165
|
+
names.append(name)
|
|
166
|
+
return names
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def type_name(node) -> str | None:
|
|
170
|
+
"""Return a single class name from a type node, or None when it is not one class.
|
|
171
|
+
|
|
172
|
+
Nullable `?Foo` yields `Foo`: null is not an object target, so the call still reaches
|
|
173
|
+
`Foo::method` when it happens at all. A union `Foo|Bar` yields None — both alternatives
|
|
174
|
+
are real targets and this version cannot express two, so it records nothing rather than
|
|
175
|
+
picking one. `array`, `string`, `int` and friends yield None: they own no methods.
|
|
176
|
+
|
|
177
|
+
`self`, `static` and `parent` yield None as well. They name a declaration, not the runtime
|
|
178
|
+
class, and treating them as a class is how late static binding turns into a wrong edge.
|
|
179
|
+
"""
|
|
180
|
+
if node is None:
|
|
181
|
+
return None
|
|
182
|
+
if node.type in (
|
|
183
|
+
"union_type",
|
|
184
|
+
"disjunctive_normal_form_type",
|
|
185
|
+
"intersection_type",
|
|
186
|
+
"primitive_type",
|
|
187
|
+
):
|
|
188
|
+
return None
|
|
189
|
+
if node.type in ("optional_type", "nullable_type"):
|
|
190
|
+
for candidate in node.children:
|
|
191
|
+
found = type_name(candidate)
|
|
192
|
+
if found:
|
|
193
|
+
return found
|
|
194
|
+
return None
|
|
195
|
+
if node.type in ("named_type", "qualified_name", "name"):
|
|
196
|
+
raw = node_text(node).strip().lstrip("?").strip()
|
|
197
|
+
if not raw:
|
|
198
|
+
return None
|
|
199
|
+
# PHP keyword types and relative scopes are all lower case; user classes conventionally
|
|
200
|
+
# are not. The check is on the first character so that an unknown future keyword is
|
|
201
|
+
# refused rather than fabricated into a class.
|
|
202
|
+
if raw[0].islower():
|
|
203
|
+
return None
|
|
204
|
+
return raw
|
|
205
|
+
for candidate in node.children:
|
|
206
|
+
found = type_name(candidate)
|
|
207
|
+
if found:
|
|
208
|
+
return found
|
|
209
|
+
return None
|