codemap-php 0.1.0__tar.gz

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.
@@ -0,0 +1,43 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # Build artifacts
7
+ build/
8
+ dist/
9
+ *.egg-info/
10
+ *.egg
11
+ .eggs/
12
+
13
+ # Test / coverage
14
+ .pytest_cache/
15
+ .coverage
16
+ .coverage.*
17
+ htmlcov/
18
+ coverage.xml
19
+ .tox/
20
+ .mypy_cache/
21
+ .ruff_cache/
22
+ .benchmarks/
23
+
24
+ # Virtualenv
25
+ .venv/
26
+ venv/
27
+ env/
28
+
29
+ # uv / pdm lockfiles (commit uv.lock once we settle)
30
+ # uv.lock
31
+
32
+ # IDE
33
+ .idea/
34
+ .vscode/
35
+ *.swp
36
+ *.swo
37
+
38
+ # OS
39
+ .DS_Store
40
+ Thumbs.db
41
+
42
+ # CodeMap own index when dogfooding
43
+ .codemap/
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.4
2
+ Name: codemap-php
3
+ Version: 0.1.0
4
+ Summary: PHP indexer plugin for CodeMap
5
+ Project-URL: Homepage, https://github.com/qxbyte/codemap
6
+ Author: CodeMap Contributors
7
+ License: MIT
8
+ Keywords: codemap,indexer,php,tree-sitter
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Programming Language :: PHP
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Software Development
13
+ Requires-Python: >=3.11
14
+ Requires-Dist: codemap-core<0.2,>=0.1.0
15
+ Requires-Dist: tree-sitter-php>=0.23
16
+ Requires-Dist: tree-sitter>=0.25
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest>=8.0; extra == 'dev'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # codemap-php
22
+
23
+ > A PHP indexer for [CodeMap](https://github.com/qxbyte/codemap),
24
+ > shipped as an independent PyPI package.
25
+
26
+ ## What it captures
27
+
28
+ Backed by `tree-sitter-php`:
29
+
30
+ | AST node | Symbol kind |
31
+ |---|---|
32
+ | `class_declaration` | `class` (with `extra.php_kind=class`) |
33
+ | `interface_declaration` | `class` (with `extra.php_kind=interface`) |
34
+ | `trait_declaration` | `class` (with `extra.php_kind=trait`) |
35
+ | `enum_declaration` | `class` (with `extra.php_kind=enum`) |
36
+ | `method_declaration` (inside type) | `method` |
37
+ | `function_definition` (top level) | `function` |
38
+ | `property_declaration` (inside type) | `field` |
39
+ | `const_declaration` (top level) | `variable` |
40
+ | `const_declaration` (inside type) | `field` |
41
+
42
+ `namespace_definition` is captured as `extra.namespace` on every
43
+ symbol-producing type.
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ pip install codemap-php
49
+ ```
50
+
51
+ ## SymbolID encoding
52
+
53
+ ```
54
+ scip-php . . . src/App/User.php/User#hello().
55
+ ```
56
+
57
+ ## Limits
58
+
59
+ * Use statements aren't yet expanded into namespace-resolved edges.
60
+ * PHPDoc annotations are not parsed.
61
+ * Anonymous classes are skipped.
62
+
63
+ ## License
64
+
65
+ MIT.
@@ -0,0 +1,45 @@
1
+ # codemap-php
2
+
3
+ > A PHP indexer for [CodeMap](https://github.com/qxbyte/codemap),
4
+ > shipped as an independent PyPI package.
5
+
6
+ ## What it captures
7
+
8
+ Backed by `tree-sitter-php`:
9
+
10
+ | AST node | Symbol kind |
11
+ |---|---|
12
+ | `class_declaration` | `class` (with `extra.php_kind=class`) |
13
+ | `interface_declaration` | `class` (with `extra.php_kind=interface`) |
14
+ | `trait_declaration` | `class` (with `extra.php_kind=trait`) |
15
+ | `enum_declaration` | `class` (with `extra.php_kind=enum`) |
16
+ | `method_declaration` (inside type) | `method` |
17
+ | `function_definition` (top level) | `function` |
18
+ | `property_declaration` (inside type) | `field` |
19
+ | `const_declaration` (top level) | `variable` |
20
+ | `const_declaration` (inside type) | `field` |
21
+
22
+ `namespace_definition` is captured as `extra.namespace` on every
23
+ symbol-producing type.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pip install codemap-php
29
+ ```
30
+
31
+ ## SymbolID encoding
32
+
33
+ ```
34
+ scip-php . . . src/App/User.php/User#hello().
35
+ ```
36
+
37
+ ## Limits
38
+
39
+ * Use statements aren't yet expanded into namespace-resolved edges.
40
+ * PHPDoc annotations are not parsed.
41
+ * Anonymous classes are skipped.
42
+
43
+ ## License
44
+
45
+ MIT.
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.21"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "codemap-php"
7
+ version = "0.1.0"
8
+ description = "PHP indexer plugin for CodeMap"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "CodeMap Contributors" }]
13
+ keywords = ["codemap", "php", "indexer", "tree-sitter"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: PHP",
18
+ "Topic :: Software Development",
19
+ ]
20
+ dependencies = [
21
+ "codemap-core>=0.1.0,<0.2",
22
+ "tree-sitter>=0.25",
23
+ "tree-sitter-php>=0.23",
24
+ ]
25
+
26
+ [project.optional-dependencies]
27
+ dev = ["pytest>=8.0"]
28
+
29
+ [project.entry-points."codemap.indexers"]
30
+ php = "codemap_php:PhpIndexer"
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/qxbyte/codemap"
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/codemap_php"]
@@ -0,0 +1,8 @@
1
+ """PHP indexer plugin for CodeMap."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from codemap_php.indexer import PhpIndexer
6
+
7
+ __all__ = ["PhpIndexer"]
8
+ __version__ = "0.1.0"
@@ -0,0 +1,293 @@
1
+ """PHP indexer built on tree-sitter-php.
2
+
3
+ Handles all four PHP type declarations (``class`` / ``interface`` /
4
+ ``trait`` / ``enum``) with their respective bodies, plus free
5
+ ``function_definition`` and module-level ``const_declaration``.
6
+ ``namespace_definition`` is honoured as ``extra.namespace`` on every
7
+ type symbol; nested types share the same single namespace (PHP doesn't
8
+ allow nested classes the way Java does, but the tree-sitter grammar
9
+ permits it).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path, PurePosixPath
15
+ from typing import ClassVar
16
+
17
+ import tree_sitter
18
+ import tree_sitter_php
19
+
20
+ from codemap.core.models import Diagnostic, Edge, IndexResult, Range, Symbol
21
+ from codemap.core.symbol import Descriptor, DescriptorKind, SymbolID
22
+ from codemap.indexers.base import IndexContext
23
+
24
+ SCHEME = "scip-php"
25
+ LANG = "php"
26
+
27
+ _PHP_LANG = tree_sitter.Language(tree_sitter_php.language_php())
28
+
29
+ _TYPE_DECLS: dict[str, str] = {
30
+ "class_declaration": "class",
31
+ "interface_declaration": "interface",
32
+ "trait_declaration": "trait",
33
+ "enum_declaration": "enum",
34
+ }
35
+
36
+
37
+ class PhpIndexer:
38
+ name: ClassVar[str] = "php"
39
+ version: ClassVar[str] = "0.1.0"
40
+ file_patterns: ClassVar[list[str]] = ["*.php"]
41
+ languages: ClassVar[list[str]] = [LANG]
42
+
43
+ def supports(self, path: Path) -> bool:
44
+ return path.suffix == ".php"
45
+
46
+ def index_file(
47
+ self,
48
+ path: Path,
49
+ source: bytes,
50
+ ctx: IndexContext,
51
+ ) -> IndexResult:
52
+ try:
53
+ source.decode("utf-8")
54
+ except UnicodeDecodeError as exc:
55
+ return IndexResult(
56
+ diagnostics=[
57
+ Diagnostic(
58
+ severity="error",
59
+ file=ctx.relative_path,
60
+ code="PHP002",
61
+ message=f"not valid UTF-8: {exc}",
62
+ producer=self.name,
63
+ )
64
+ ]
65
+ )
66
+ parser = tree_sitter.Parser(_PHP_LANG)
67
+ tree = parser.parse(source)
68
+ visitor = _Visitor(ctx.relative_path)
69
+ visitor.visit(tree.root_node)
70
+ diagnostics = list(visitor.diagnostics)
71
+ if tree.root_node.has_error:
72
+ diagnostics.append(
73
+ Diagnostic(
74
+ severity="warning",
75
+ file=ctx.relative_path,
76
+ range=Range(start_line=1, end_line=1),
77
+ code="PHP001",
78
+ message="tree-sitter reported parse errors; symbols may be incomplete",
79
+ producer=self.name,
80
+ )
81
+ )
82
+ return IndexResult(
83
+ symbols=visitor.symbols,
84
+ edges=visitor.edges,
85
+ diagnostics=diagnostics,
86
+ )
87
+
88
+
89
+ class _Visitor:
90
+ def __init__(self, relative_path: PurePosixPath) -> None:
91
+ self.relative_path = relative_path
92
+ self.symbols: list[Symbol] = []
93
+ self.edges: list[Edge] = []
94
+ self.diagnostics: list[Diagnostic] = []
95
+ self._type_stack: list[str] = []
96
+ self._namespace: str = ""
97
+
98
+ def visit(self, node: tree_sitter.Node) -> None:
99
+ kind = node.type
100
+ if kind == "namespace_definition":
101
+ self._set_namespace(node)
102
+ return
103
+ if kind in _TYPE_DECLS:
104
+ self._visit_type(node, php_kind=_TYPE_DECLS[kind])
105
+ return
106
+ if kind == "function_definition":
107
+ self._visit_function(node)
108
+ return
109
+ if kind == "method_declaration" and self._type_stack:
110
+ self._visit_method(node)
111
+ return
112
+ if kind == "property_declaration" and self._type_stack:
113
+ self._visit_property(node)
114
+ return
115
+ if kind == "const_declaration":
116
+ self._visit_const(node)
117
+ return
118
+ for child in node.children:
119
+ self.visit(child)
120
+
121
+ # ------------------------------------------------- namespace
122
+
123
+ def _set_namespace(self, node: tree_sitter.Node) -> None:
124
+ for child in node.children:
125
+ if child.type == "namespace_name":
126
+ self._namespace = _node_text(child)
127
+ return
128
+
129
+ # ------------------------------------------------------- types
130
+
131
+ def _visit_type(self, node: tree_sitter.Node, *, php_kind: str) -> None:
132
+ name = _name_child(node)
133
+ if name is None:
134
+ return
135
+ sid = self._make_id(name, kind=DescriptorKind.TYPE)
136
+ extra: dict[str, str] = {"php_kind": php_kind}
137
+ if self._namespace:
138
+ extra["namespace"] = self._namespace
139
+ self.symbols.append(
140
+ Symbol(
141
+ id=sid,
142
+ kind="class",
143
+ language=LANG,
144
+ file=self.relative_path,
145
+ range=_node_range(node),
146
+ extra=extra,
147
+ )
148
+ )
149
+ body = _declaration_list(node)
150
+ if body is None:
151
+ return
152
+ self._type_stack.append(name)
153
+ try:
154
+ for child in body.children:
155
+ self.visit(child)
156
+ finally:
157
+ self._type_stack.pop()
158
+
159
+ # --------------------------------------------------- functions
160
+
161
+ def _visit_function(self, node: tree_sitter.Node) -> None:
162
+ if self._type_stack:
163
+ # Function defined inside a class body (rare but legal in some
164
+ # grammars) — treat as method.
165
+ self._visit_method(node)
166
+ return
167
+ name = _name_child(node)
168
+ if name is None:
169
+ return
170
+ sid = self._make_id(name, kind=DescriptorKind.METHOD)
171
+ self.symbols.append(
172
+ Symbol(
173
+ id=sid,
174
+ kind="function",
175
+ language=LANG,
176
+ file=self.relative_path,
177
+ range=_node_range(node),
178
+ signature=f"function {name}()",
179
+ )
180
+ )
181
+
182
+ def _visit_method(self, node: tree_sitter.Node) -> None:
183
+ name = _name_child(node)
184
+ if name is None:
185
+ return
186
+ sid = self._make_id(name, kind=DescriptorKind.METHOD)
187
+ self.symbols.append(
188
+ Symbol(
189
+ id=sid,
190
+ kind="method",
191
+ language=LANG,
192
+ file=self.relative_path,
193
+ range=_node_range(node),
194
+ signature=f"function {name}()",
195
+ )
196
+ )
197
+
198
+ # --------------------------------------------------- properties
199
+
200
+ def _visit_property(self, node: tree_sitter.Node) -> None:
201
+ # property_declaration > property_element > variable_name
202
+ for child in node.children:
203
+ if child.type == "property_element":
204
+ for grand in child.children:
205
+ if grand.type == "variable_name":
206
+ # variable_name has a single name child (without '$')
207
+ for great in grand.children:
208
+ if great.type == "name":
209
+ name = _node_text(great)
210
+ if not name:
211
+ return
212
+ sid = self._make_id(name, kind=DescriptorKind.TERM)
213
+ self.symbols.append(
214
+ Symbol(
215
+ id=sid,
216
+ kind="field",
217
+ language=LANG,
218
+ file=self.relative_path,
219
+ range=_node_range(node),
220
+ )
221
+ )
222
+ return
223
+
224
+ # ------------------------------------------------ const decls
225
+
226
+ def _visit_const(self, node: tree_sitter.Node) -> None:
227
+ # const_declaration > const_element > name
228
+ for child in node.children:
229
+ if child.type != "const_element":
230
+ continue
231
+ for grand in child.children:
232
+ if grand.type == "name":
233
+ name = _node_text(grand)
234
+ if not name:
235
+ return
236
+ sym_kind: str = "field" if self._type_stack else "variable"
237
+ sid = self._make_id(name, kind=DescriptorKind.TERM)
238
+ self.symbols.append(
239
+ Symbol(
240
+ id=sid,
241
+ kind=sym_kind, # type: ignore[arg-type]
242
+ language=LANG,
243
+ file=self.relative_path,
244
+ range=_node_range(node),
245
+ )
246
+ )
247
+ return
248
+
249
+ # -------------------------------------------------------- helpers
250
+
251
+ def _make_id(self, name: str, *, kind: DescriptorKind) -> SymbolID:
252
+ descriptors = list(_path_namespaces(self.relative_path))
253
+ descriptors.extend(Descriptor(name=t, kind=DescriptorKind.TYPE) for t in self._type_stack)
254
+ descriptors.append(Descriptor(name=name, kind=kind))
255
+ return SymbolID(scheme=SCHEME, descriptors=tuple(descriptors))
256
+
257
+
258
+ # ---------------------------------------------------------------------------
259
+ # Pure helpers
260
+ # ---------------------------------------------------------------------------
261
+
262
+
263
+ def _path_namespaces(path: PurePosixPath) -> list[Descriptor]:
264
+ return [Descriptor(name=part, kind=DescriptorKind.NAMESPACE) for part in path.parts]
265
+
266
+
267
+ def _node_range(node: tree_sitter.Node) -> Range:
268
+ sr, sc = node.start_point
269
+ er, ec = node.end_point
270
+ return Range(
271
+ start_line=sr + 1,
272
+ start_col=sc,
273
+ end_line=max(er + 1, sr + 1),
274
+ end_col=ec,
275
+ )
276
+
277
+
278
+ def _node_text(node: tree_sitter.Node) -> str:
279
+ return node.text.decode("utf-8") if node.text is not None else ""
280
+
281
+
282
+ def _name_child(node: tree_sitter.Node) -> str | None:
283
+ for child in node.children:
284
+ if child.type == "name":
285
+ return _node_text(child)
286
+ return None
287
+
288
+
289
+ def _declaration_list(node: tree_sitter.Node) -> tree_sitter.Node | None:
290
+ for child in node.children:
291
+ if child.type in {"declaration_list", "enum_declaration_list"}:
292
+ return child
293
+ return None
File without changes
@@ -0,0 +1,169 @@
1
+ """Unit tests for the PHP indexer plugin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import textwrap
6
+ from pathlib import Path, PurePosixPath
7
+
8
+ from codemap_php import PhpIndexer
9
+ from codemap_php.indexer import SCHEME
10
+
11
+ from codemap.core.models import IndexResult
12
+ from codemap.indexers.base import IndexContext
13
+
14
+
15
+ def _index(source: str, *, path: str = "src/User.php") -> IndexResult:
16
+ code = textwrap.dedent(source).lstrip("\n")
17
+ return PhpIndexer().index_file(
18
+ Path(path),
19
+ code.encode("utf-8"),
20
+ IndexContext(
21
+ project_root=Path("/tmp/proj"),
22
+ relative_path=PurePosixPath(path),
23
+ language="php",
24
+ ),
25
+ )
26
+
27
+
28
+ def test_indexer_metadata() -> None:
29
+ ix = PhpIndexer()
30
+ assert ix.name == "php"
31
+ assert ix.languages == ["php"]
32
+ assert ix.supports(Path("a.php"))
33
+ assert not ix.supports(Path("a.py"))
34
+
35
+
36
+ def test_scheme_is_consistent() -> None:
37
+ r = _index(
38
+ """
39
+ <?php
40
+ class A {}
41
+ function f() {}
42
+ const X = 1;
43
+ """
44
+ )
45
+ for s in r.symbols:
46
+ assert str(s.id).startswith(f"{SCHEME} ")
47
+
48
+
49
+ def test_class_declaration() -> None:
50
+ r = _index(
51
+ """
52
+ <?php
53
+ class User {
54
+ public string $name;
55
+ public function hello(): string { return $this->name; }
56
+ }
57
+ """
58
+ )
59
+ classes = [s for s in r.symbols if s.kind == "class"]
60
+ assert classes[0].extra.get("php_kind") == "class"
61
+ methods = [s for s in r.symbols if s.kind == "method"]
62
+ assert any("User#hello()." in str(m.id) for m in methods)
63
+ fields = [s for s in r.symbols if s.kind == "field"]
64
+ assert any("User#name." in str(f.id) for f in fields)
65
+
66
+
67
+ def test_interface_declaration() -> None:
68
+ r = _index(
69
+ """
70
+ <?php
71
+ interface Greeter {
72
+ public function hello(): string;
73
+ }
74
+ """
75
+ )
76
+ cls = next(s for s in r.symbols if s.kind == "class")
77
+ assert cls.extra.get("php_kind") == "interface"
78
+
79
+
80
+ def test_trait_declaration() -> None:
81
+ r = _index(
82
+ """
83
+ <?php
84
+ trait Loggable {
85
+ public function log() {}
86
+ }
87
+ """
88
+ )
89
+ cls = next(s for s in r.symbols if s.kind == "class")
90
+ assert cls.extra.get("php_kind") == "trait"
91
+
92
+
93
+ def test_namespace_captured_in_extra() -> None:
94
+ r = _index(
95
+ """
96
+ <?php
97
+ namespace App\\Models;
98
+ class User {}
99
+ """
100
+ )
101
+ cls = next(s for s in r.symbols if s.kind == "class")
102
+ assert "App" in cls.extra.get("namespace", "")
103
+
104
+
105
+ def test_free_function() -> None:
106
+ r = _index("<?php\nfunction helper(int $x): int { return $x + 1; }")
107
+ funcs = [s for s in r.symbols if s.kind == "function"]
108
+ assert len(funcs) == 1
109
+ assert "helper" in str(funcs[0].id)
110
+
111
+
112
+ def test_top_level_const_is_variable() -> None:
113
+ r = _index("<?php\nconst MAX = 10;")
114
+ vars_ = [s for s in r.symbols if s.kind == "variable"]
115
+ assert len(vars_) == 1
116
+
117
+
118
+ def test_const_inside_class_is_field() -> None:
119
+ r = _index(
120
+ """
121
+ <?php
122
+ class A {
123
+ const DEFAULT = 1;
124
+ }
125
+ """
126
+ )
127
+ fields = [s for s in r.symbols if s.kind == "field"]
128
+ assert any("DEFAULT" in str(f.id) for f in fields)
129
+
130
+
131
+ def test_static_method_recorded() -> None:
132
+ r = _index(
133
+ """
134
+ <?php
135
+ class User {
136
+ public static function create(string $name): User {
137
+ return new User();
138
+ }
139
+ }
140
+ """
141
+ )
142
+ methods = [s for s in r.symbols if s.kind == "method"]
143
+ assert any("create" in str(m.id) for m in methods)
144
+
145
+
146
+ def test_symbol_id_uses_path_namespaces() -> None:
147
+ r = _index("<?php\nclass A {}", path="src/App/A.php")
148
+ cls = next(s for s in r.symbols if s.kind == "class")
149
+ assert str(cls.id) == "scip-php . . . src/App/A.php/A#"
150
+
151
+
152
+ def test_empty_file_yields_no_symbols() -> None:
153
+ r = _index("")
154
+ assert r.symbols == []
155
+
156
+
157
+ def test_invalid_utf8_yields_diagnostic() -> None:
158
+ ix = PhpIndexer()
159
+ r = ix.index_file(
160
+ Path("bad.php"),
161
+ b"\xff\xfe<?php",
162
+ IndexContext(
163
+ project_root=Path("/tmp/proj"),
164
+ relative_path=PurePosixPath("bad.php"),
165
+ language="php",
166
+ ),
167
+ )
168
+ assert r.symbols == []
169
+ assert r.diagnostics[0].code == "PHP002"