graphlens-php 0.7.0__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.
@@ -0,0 +1,109 @@
1
+ """Namespace resolution and PSR-4 source-root detection for PHP."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from pathlib import Path
10
+
11
+
12
+ def load_psr4_map(project_root: Path) -> dict[str, list[Path]]:
13
+ r"""
14
+ Return the project's PSR-4 namespace → directories map.
15
+
16
+ Reads both ``autoload`` and ``autoload-dev`` so that test namespaces are
17
+ treated as internal. Namespace keys are returned without their trailing
18
+ backslash (``"App\\"`` → ``"App"``). Directories are resolved relative to
19
+ ``project_root``. Returns ``{}`` on any error (never raises).
20
+ """
21
+ composer = project_root / "composer.json"
22
+ if not composer.exists():
23
+ return {}
24
+ try:
25
+ data = json.loads(composer.read_text(encoding="utf-8"))
26
+ except (OSError, json.JSONDecodeError):
27
+ return {}
28
+ if not isinstance(data, dict):
29
+ return {}
30
+
31
+ out: dict[str, list[Path]] = {}
32
+ for section in ("autoload", "autoload-dev"):
33
+ block = data.get(section)
34
+ if not isinstance(block, dict):
35
+ continue
36
+ psr4 = block.get("psr-4")
37
+ if not isinstance(psr4, dict):
38
+ continue
39
+ for prefix, dirs in psr4.items():
40
+ if not isinstance(prefix, str): # pragma: no cover
41
+ continue
42
+ namespace = prefix.rstrip("\\")
43
+ dir_list = [dirs] if isinstance(dirs, str) else dirs
44
+ if not isinstance(dir_list, list):
45
+ continue
46
+ resolved = [
47
+ (project_root / d) for d in dir_list if isinstance(d, str)
48
+ ]
49
+ out.setdefault(namespace, []).extend(resolved)
50
+ return out
51
+
52
+
53
+ def internal_namespace_tops(project_root: Path) -> set[str]:
54
+ """Return the top-level segment of every PSR-4 namespace prefix."""
55
+ tops: set[str] = set()
56
+ for namespace in load_psr4_map(project_root):
57
+ if namespace:
58
+ tops.add(namespace.split("\\", maxsplit=1)[0])
59
+ return tops
60
+
61
+
62
+ def find_source_roots(
63
+ project_root: Path,
64
+ files: list[Path], # noqa: ARG001
65
+ ) -> list[Path]:
66
+ """
67
+ Detect PHP source roots from the PSR-4 autoload directories.
68
+
69
+ Every directory referenced by a PSR-4 prefix becomes a source root, with
70
+ ``project_root`` appended last as a catch-all so files outside any
71
+ declared PSR-4 tree are still attributable.
72
+ """
73
+ roots: list[Path] = []
74
+ for dirs in load_psr4_map(project_root).values():
75
+ for directory in dirs:
76
+ if directory.is_dir() and directory not in roots:
77
+ roots.append(directory)
78
+ if project_root not in roots:
79
+ roots.append(project_root)
80
+ return roots
81
+
82
+
83
+ def path_to_namespace(file_path: Path, project_root: Path) -> str:
84
+ r"""
85
+ Map a file path to its PSR-4 namespace (the file's containing namespace).
86
+
87
+ This is a *fallback* used only when a file declares no ``namespace``
88
+ statement of its own — the in-source declaration is always authoritative
89
+ when present. For ``psr-4 {"App\\": "src/"}`` the file
90
+ ``src/Service/UserService.php`` maps to namespace ``App\\Service``.
91
+
92
+ Returns ``""`` (the global namespace) when no PSR-4 prefix matches.
93
+ """
94
+ psr4 = load_psr4_map(project_root)
95
+ best_namespace = ""
96
+ best_len = -1
97
+ for namespace, dirs in psr4.items():
98
+ for directory in dirs:
99
+ try:
100
+ relative = file_path.parent.relative_to(directory)
101
+ except ValueError:
102
+ continue
103
+ depth = len(directory.parts)
104
+ if depth <= best_len:
105
+ continue
106
+ sub = [p for p in relative.parts if p not in (".", "")]
107
+ best_namespace = "\\".join([namespace, *sub]) if sub else namespace
108
+ best_len = depth
109
+ return best_namespace
@@ -0,0 +1,76 @@
1
+ """PHP project detection: marker files and project name extraction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import TYPE_CHECKING
7
+
8
+ from graphlens.utils import collect_marker_roots
9
+
10
+ if TYPE_CHECKING:
11
+ from pathlib import Path
12
+
13
+ PHP_MARKERS: tuple[str, ...] = (
14
+ "composer.json",
15
+ )
16
+
17
+ EXCLUDED_DIRS: frozenset[str] = frozenset({
18
+ "vendor", "node_modules", ".git", "var", "cache",
19
+ "build", "dist", ".phpunit.cache",
20
+ })
21
+
22
+
23
+ def is_php_project(project_root: Path) -> bool:
24
+ """
25
+ Return True if the directory contains a PHP project.
26
+
27
+ Detection order:
28
+ 1. PHP-specific marker files (composer.json)
29
+ 2. Fallback: any ``.php`` file exists anywhere under project_root
30
+
31
+ The fallback handles multi-language monorepos and plain PHP projects
32
+ that ship no composer manifest.
33
+ """
34
+ if (project_root / "composer.json").exists():
35
+ return True
36
+ return any(project_root.rglob("*.php"))
37
+
38
+
39
+ def find_php_roots(search_root: Path) -> list[Path]:
40
+ """
41
+ Find the actual PHP project roots within search_root.
42
+
43
+ Walks for ``composer.json`` markers and returns their parent
44
+ directories — one per distinct PHP sub-project. A marker at
45
+ ``search_root`` does not hide nested marker roots, so a monorepo that is
46
+ itself a project and also contains PHP sub-packages yields every root.
47
+
48
+ Falls back to ``[search_root]`` when no markers are found anywhere (a
49
+ directory that contains only bare ``.php`` scripts with no manifest).
50
+ """
51
+ return collect_marker_roots(
52
+ search_root,
53
+ PHP_MARKERS,
54
+ excluded_dirs=EXCLUDED_DIRS,
55
+ )
56
+
57
+
58
+ def detect_project_name(project_root: Path) -> str:
59
+ """
60
+ Extract the project name.
61
+
62
+ Resolution order:
63
+ 1. composer.json ``name`` (e.g. ``vendor/package``)
64
+ 2. project_root directory name
65
+ """
66
+ composer = project_root / "composer.json"
67
+ if composer.exists():
68
+ try:
69
+ data = json.loads(composer.read_text(encoding="utf-8"))
70
+ except (OSError, json.JSONDecodeError):
71
+ data = {}
72
+ name = data.get("name") if isinstance(data, dict) else None
73
+ if isinstance(name, str) and name:
74
+ return name
75
+
76
+ return project_root.name