metripy 0.2.7__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.
Potentially problematic release.
This version of metripy might be problematic. Click here for more details.
- metripy/Application/Analyzer.py +106 -0
- metripy/Application/Application.py +54 -0
- metripy/Application/Config/Config.py +13 -0
- metripy/Application/Config/File/ConfigFileReaderFactory.py +24 -0
- metripy/Application/Config/File/ConfigFileReaderInterface.py +14 -0
- metripy/Application/Config/File/JsonConfigFileReader.py +82 -0
- metripy/Application/Config/GitConfig.py +10 -0
- metripy/Application/Config/Parser.py +31 -0
- metripy/Application/Config/ProjectConfig.py +27 -0
- metripy/Application/Config/ReportConfig.py +10 -0
- metripy/Application/__init__.py +0 -0
- metripy/Component/Debug/Debugger.py +20 -0
- metripy/Component/File/Finder.py +37 -0
- metripy/Component/Output/CliOutput.py +49 -0
- metripy/Component/Output/ProgressBar.py +27 -0
- metripy/Dependency/Composer/Composer.py +30 -0
- metripy/Dependency/Composer/Packegist.py +55 -0
- metripy/Dependency/Dependency.py +30 -0
- metripy/Dependency/Npm/Npm.py +30 -0
- metripy/Dependency/Npm/NpmOrg.py +47 -0
- metripy/Dependency/Pip/Pip.py +69 -0
- metripy/Dependency/Pip/PyPi.py +49 -0
- metripy/Git/GitAnalyzer.py +86 -0
- metripy/LangAnalyzer/AbstractLangAnalyzer.py +65 -0
- metripy/LangAnalyzer/Generic/HalSteadAnalyzer.py +58 -0
- metripy/LangAnalyzer/Generic/__init__.py +0 -0
- metripy/LangAnalyzer/Php/PhpAnalyzer.py +193 -0
- metripy/LangAnalyzer/Php/PhpBasicAstParser.py +56 -0
- metripy/LangAnalyzer/Php/PhpBasicLocAnalyzer.py +174 -0
- metripy/LangAnalyzer/Php/PhpHalSteadAnalyzer.py +44 -0
- metripy/LangAnalyzer/Python/PythonAnalyzer.py +129 -0
- metripy/LangAnalyzer/Typescript/TypescriptAnalyzer.py +208 -0
- metripy/LangAnalyzer/Typescript/TypescriptAstParser.py +68 -0
- metripy/LangAnalyzer/Typescript/TypescriptBasicComplexityAnalyzer.py +114 -0
- metripy/LangAnalyzer/Typescript/TypescriptBasicLocAnalyzer.py +69 -0
- metripy/LangAnalyzer/Typescript/TypescriptHalSteadAnalyzer.py +55 -0
- metripy/LangAnalyzer/__init__.py +0 -0
- metripy/Metric/Code/AggregatedMetrics.py +42 -0
- metripy/Metric/Code/FileMetrics.py +33 -0
- metripy/Metric/Code/ModuleMetrics.py +32 -0
- metripy/Metric/Code/SegmentedMetrics.py +65 -0
- metripy/Metric/FileTree/FileTree.py +15 -0
- metripy/Metric/FileTree/FileTreeParser.py +42 -0
- metripy/Metric/Git/GitCodeHotspot.py +37 -0
- metripy/Metric/Git/GitContributor.py +37 -0
- metripy/Metric/Git/GitKnowledgeSilo.py +27 -0
- metripy/Metric/Git/GitMetrics.py +148 -0
- metripy/Metric/ProjectMetrics.py +55 -0
- metripy/Report/Csv/Reporter.py +12 -0
- metripy/Report/Html/Reporter.py +210 -0
- metripy/Report/Json/AbstractJsonReporter.py +11 -0
- metripy/Report/Json/GitJsonReporter.py +21 -0
- metripy/Report/Json/JsonReporter.py +12 -0
- metripy/Report/ReporterFactory.py +22 -0
- metripy/Report/ReporterInterface.py +17 -0
- metripy/Tree/ClassNode.py +32 -0
- metripy/Tree/FunctionNode.py +49 -0
- metripy/Tree/ModuleNode.py +42 -0
- metripy/__init__.py +0 -0
- metripy/metripy.py +15 -0
- metripy-0.2.7.dist-info/METADATA +113 -0
- metripy-0.2.7.dist-info/RECORD +66 -0
- metripy-0.2.7.dist-info/WHEEL +5 -0
- metripy-0.2.7.dist-info/entry_points.txt +2 -0
- metripy-0.2.7.dist-info/licenses/LICENSE +21 -0
- metripy-0.2.7.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
|
|
3
|
+
from metripy.Dependency.Dependency import Dependency
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class NpmOrg:
|
|
7
|
+
def get_info(self, dependency: Dependency) -> Dependency | None:
|
|
8
|
+
if not dependency.name:
|
|
9
|
+
return None
|
|
10
|
+
|
|
11
|
+
uri = f"https://registry.npmjs.org/{dependency.name}"
|
|
12
|
+
response = requests.get(uri)
|
|
13
|
+
|
|
14
|
+
if response.status_code != 200:
|
|
15
|
+
print(f"Package {dependency.name} not found on npm.org")
|
|
16
|
+
return None
|
|
17
|
+
|
|
18
|
+
data = response.json()
|
|
19
|
+
|
|
20
|
+
# Basic metadata
|
|
21
|
+
dependency.type = "npm"
|
|
22
|
+
dependency.description = data.get("description", "")
|
|
23
|
+
dependency.repository = data.get("repository", {}).get("url", "")
|
|
24
|
+
dependency.homepage = data.get("homepage", "")
|
|
25
|
+
dependency.license = [data.get("license")] if data.get("license") else []
|
|
26
|
+
|
|
27
|
+
# Version info
|
|
28
|
+
latest_version = data.get("dist-tags", {}).get("latest", "")
|
|
29
|
+
dependency.latest = latest_version
|
|
30
|
+
|
|
31
|
+
# npm doesn't provide download stats in the registry API
|
|
32
|
+
dependency.github_stars = "??"
|
|
33
|
+
dependency.downloads_total = "??"
|
|
34
|
+
dependency.downloads_monthly = "??"
|
|
35
|
+
dependency.downloads_daily = "??"
|
|
36
|
+
|
|
37
|
+
# Determine status
|
|
38
|
+
if dependency.version == dependency.latest:
|
|
39
|
+
dependency.status = "latest"
|
|
40
|
+
else:
|
|
41
|
+
dependency.status = "outdated"
|
|
42
|
+
|
|
43
|
+
# build zip url
|
|
44
|
+
if latest_version:
|
|
45
|
+
dependency.zip = f"https://registry.npmjs.org/{dependency.name}/-/{dependency.name}-{latest_version}.tgz"
|
|
46
|
+
|
|
47
|
+
return dependency
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
import toml
|
|
5
|
+
|
|
6
|
+
from metripy.Dependency.Dependency import Dependency
|
|
7
|
+
from metripy.Dependency.Pip.PyPi import PyPi
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Pip:
|
|
11
|
+
def get_dependencies(self, path: str) -> list[Dependency]:
|
|
12
|
+
try:
|
|
13
|
+
requirements = self.get_from_requirements_txt(path)
|
|
14
|
+
except FileNotFoundError:
|
|
15
|
+
requirements = self.get_from_pyproject_toml(path)
|
|
16
|
+
|
|
17
|
+
pypi = PyPi()
|
|
18
|
+
packages = []
|
|
19
|
+
for dependency in requirements:
|
|
20
|
+
package = pypi.get_info(dependency)
|
|
21
|
+
packages.append(package)
|
|
22
|
+
|
|
23
|
+
return [item for item in packages if item is not None]
|
|
24
|
+
|
|
25
|
+
def get_from_requirements_txt(self, path: str) -> list[Dependency]:
|
|
26
|
+
requirements = []
|
|
27
|
+
|
|
28
|
+
pattern = re.compile(r"([a-zA-Z0-9_\-]+)([<>=!~]+[^\s]+)?")
|
|
29
|
+
with open(os.path.join(path, "requirements.txt"), "r") as file:
|
|
30
|
+
lines = file.readlines()
|
|
31
|
+
for line in lines:
|
|
32
|
+
|
|
33
|
+
line = line.strip()
|
|
34
|
+
if line and not line.startswith("#"):
|
|
35
|
+
match = pattern.match(line)
|
|
36
|
+
if match:
|
|
37
|
+
name = match.group(1)
|
|
38
|
+
version = match.group(2) if match.group(2) else None
|
|
39
|
+
requirements.append(Dependency(name, version))
|
|
40
|
+
return requirements
|
|
41
|
+
|
|
42
|
+
def get_from_pyproject_toml(self, path: str) -> list[Dependency]:
|
|
43
|
+
dependencies = []
|
|
44
|
+
|
|
45
|
+
with open(os.path.join(path, "pyproject.toml"), "r") as f:
|
|
46
|
+
data = toml.load(f)
|
|
47
|
+
|
|
48
|
+
# For PEP 621 / setuptools projects
|
|
49
|
+
if "project" in data:
|
|
50
|
+
deps = data["project"].get("dependencies", [])
|
|
51
|
+
for dep in deps:
|
|
52
|
+
# dep is a string like "requests>=2.32.5"
|
|
53
|
+
# You can split it if needed
|
|
54
|
+
if "==" in dep:
|
|
55
|
+
name, version = dep.split("==")
|
|
56
|
+
elif ">=" in dep:
|
|
57
|
+
name, version = dep.split(">=")
|
|
58
|
+
else:
|
|
59
|
+
name, version = dep, None
|
|
60
|
+
dependencies.append(
|
|
61
|
+
Dependency(name.strip(), version.strip() if version else None)
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
return dependencies
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
if __name__ == "__main__":
|
|
68
|
+
pip = Pip()
|
|
69
|
+
pip.get_dependencies("./")
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
|
|
3
|
+
from metripy.Dependency.Dependency import Dependency
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PyPi:
|
|
7
|
+
def get_info(self, dependency: Dependency) -> Dependency | None:
|
|
8
|
+
uri = f"https://pypi.org/pypi/{dependency.name}/json"
|
|
9
|
+
x = requests.get(uri)
|
|
10
|
+
data = x.json()
|
|
11
|
+
|
|
12
|
+
info = data.get("info", {})
|
|
13
|
+
releases = data.get("releases", {})
|
|
14
|
+
|
|
15
|
+
if not info:
|
|
16
|
+
print(f"Package '{dependency.name}' has no info section")
|
|
17
|
+
return dependency
|
|
18
|
+
|
|
19
|
+
dependency.description = info.get("summary")
|
|
20
|
+
dependency.repository = info.get("project_url") or info.get("home_page")
|
|
21
|
+
if info.get("license"):
|
|
22
|
+
dependency.license = [info.get("license")]
|
|
23
|
+
else:
|
|
24
|
+
dependency.license = []
|
|
25
|
+
dependency.homepage = info.get("home_page")
|
|
26
|
+
|
|
27
|
+
# PyPI doesn't provide GitHub stars or download counts directly
|
|
28
|
+
dependency.github_stars = "??"
|
|
29
|
+
dependency.downloads_total = "??"
|
|
30
|
+
dependency.downloads_monthly = "??"
|
|
31
|
+
dependency.downloads_daily = "??"
|
|
32
|
+
|
|
33
|
+
# Determine latest version
|
|
34
|
+
latest_version = info.get("version")
|
|
35
|
+
dependency.latest = latest_version
|
|
36
|
+
|
|
37
|
+
# Compare with current version
|
|
38
|
+
if dependency.version == latest_version:
|
|
39
|
+
dependency.status = "latest"
|
|
40
|
+
else:
|
|
41
|
+
dependency.status = "outdated"
|
|
42
|
+
|
|
43
|
+
# Get distribution URL (e.g., wheel or sdist)
|
|
44
|
+
if latest_version in releases:
|
|
45
|
+
release_files = releases[latest_version]
|
|
46
|
+
if release_files:
|
|
47
|
+
dependency.zip = release_files[0].get("url")
|
|
48
|
+
|
|
49
|
+
return dependency
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
from git import Repo
|
|
5
|
+
|
|
6
|
+
from metripy.Application.Config.GitConfig import GitConfig
|
|
7
|
+
from metripy.Metric.Git.GitMetrics import GitMetrics
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GitAnalyzer:
|
|
11
|
+
def __init__(self, git_config: GitConfig):
|
|
12
|
+
print(git_config.repo)
|
|
13
|
+
print(git_config.branch)
|
|
14
|
+
self.repo = Repo(git_config.repo)
|
|
15
|
+
self.branch_name = git_config.branch
|
|
16
|
+
|
|
17
|
+
def analyze(self) -> GitMetrics:
|
|
18
|
+
"""Main analysis method with comprehensive output"""
|
|
19
|
+
|
|
20
|
+
# Calculate first day of this month last year
|
|
21
|
+
now = datetime.now()
|
|
22
|
+
first_of_month_last_year = datetime(now.year - 1, now.month, 1)
|
|
23
|
+
# first_of_month_last_year = datetime(now.year, now.month, 1)
|
|
24
|
+
after_date = first_of_month_last_year.strftime("%Y-%m-%d")
|
|
25
|
+
print(f"analyzing from {after_date}")
|
|
26
|
+
|
|
27
|
+
return self.get_metrics(after_date)
|
|
28
|
+
|
|
29
|
+
def _is_source_file(self, file_path: str) -> bool:
|
|
30
|
+
"""Check if file is a source code file we want to analyze"""
|
|
31
|
+
source_extensions = {
|
|
32
|
+
".py",
|
|
33
|
+
".js",
|
|
34
|
+
".ts",
|
|
35
|
+
".tsx",
|
|
36
|
+
".php",
|
|
37
|
+
".java",
|
|
38
|
+
".cpp",
|
|
39
|
+
".c",
|
|
40
|
+
".h",
|
|
41
|
+
".rb",
|
|
42
|
+
".go",
|
|
43
|
+
".rs",
|
|
44
|
+
}
|
|
45
|
+
return any(file_path.endswith(ext) for ext in source_extensions)
|
|
46
|
+
|
|
47
|
+
def get_metrics(self, after: str) -> GitMetrics:
|
|
48
|
+
commits_per_month = {}
|
|
49
|
+
chrun_per_month = defaultdict(lambda: {"added": 0, "removed": 0})
|
|
50
|
+
file_contributors = defaultdict(lambda: {"contributors": set(), "commits": 0})
|
|
51
|
+
contributor_stats = defaultdict(
|
|
52
|
+
lambda: {"commits": 0, "lines_added": 0, "lines_removed": 0}
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
for commit in self.repo.iter_commits(
|
|
56
|
+
self.branch_name, no_merges=True, after=after
|
|
57
|
+
):
|
|
58
|
+
month = commit.committed_datetime.strftime("%Y-%m")
|
|
59
|
+
author = commit.author.name
|
|
60
|
+
|
|
61
|
+
if month not in commits_per_month.keys():
|
|
62
|
+
commits_per_month[month] = 0
|
|
63
|
+
commits_per_month[month] += 1
|
|
64
|
+
|
|
65
|
+
stats = commit.stats.total
|
|
66
|
+
insertions = stats.get("insertions", 0)
|
|
67
|
+
deletions = stats.get("deletions", 0)
|
|
68
|
+
chrun_per_month[month]["added"] += insertions
|
|
69
|
+
chrun_per_month[month]["removed"] += deletions
|
|
70
|
+
|
|
71
|
+
contributor_stats[author]["commits"] += 1
|
|
72
|
+
contributor_stats[author]["lines_added"] += insertions
|
|
73
|
+
contributor_stats[author]["lines_removed"] += deletions
|
|
74
|
+
|
|
75
|
+
for file_path in commit.stats.files:
|
|
76
|
+
if self._is_source_file(file_path):
|
|
77
|
+
file_contributors[file_path]["contributors"].add(author)
|
|
78
|
+
file_contributors[file_path]["commits"] += 1
|
|
79
|
+
|
|
80
|
+
return GitMetrics(
|
|
81
|
+
analysis_start_date=after,
|
|
82
|
+
commit_stats_per_month=commits_per_month,
|
|
83
|
+
churn_per_month=chrun_per_month,
|
|
84
|
+
contributor_stats=contributor_stats,
|
|
85
|
+
file_contributors=file_contributors,
|
|
86
|
+
)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
from metripy.Metric.Code.FileMetrics import FileMetrics
|
|
4
|
+
from metripy.Tree.ModuleNode import ModuleNode
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AbstractLangAnalyzer(ABC):
|
|
8
|
+
def __init__(self):
|
|
9
|
+
self.files: list[str] = []
|
|
10
|
+
self.modules: dict[str, ModuleNode] = {}
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def set_files(self, files: list[str]) -> None:
|
|
14
|
+
raise NotImplementedError
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
def is_needed(self) -> bool:
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
def before_run(self) -> None:
|
|
21
|
+
# build cache
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
def after_run(self) -> None:
|
|
25
|
+
# clear cache
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def get_lang_name(self) -> str:
|
|
30
|
+
raise NotImplementedError
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def run(self) -> None:
|
|
34
|
+
raise NotImplementedError
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
def get_metrics(self) -> list[FileMetrics]:
|
|
38
|
+
metrics = []
|
|
39
|
+
for module in self.modules.values():
|
|
40
|
+
full_name = module.full_name
|
|
41
|
+
|
|
42
|
+
if len(module.functions) > 0:
|
|
43
|
+
avgCcPerFunction = sum(
|
|
44
|
+
function.complexity for function in module.functions
|
|
45
|
+
) / len(module.functions)
|
|
46
|
+
avgLocPerFunction = (
|
|
47
|
+
module.lloc - module.comments - len(module.functions)
|
|
48
|
+
) / len(module.functions)
|
|
49
|
+
else:
|
|
50
|
+
avgCcPerFunction = 0
|
|
51
|
+
avgLocPerFunction = 0
|
|
52
|
+
maintainabilityIndex = module.maintainability_index
|
|
53
|
+
|
|
54
|
+
file_metric = FileMetrics(
|
|
55
|
+
full_name=full_name,
|
|
56
|
+
loc=module.loc,
|
|
57
|
+
avgCcPerFunction=avgCcPerFunction,
|
|
58
|
+
maintainabilityIndex=maintainabilityIndex,
|
|
59
|
+
avgLocPerFunction=avgLocPerFunction,
|
|
60
|
+
class_nodes=module.classes,
|
|
61
|
+
function_nodes=module.functions,
|
|
62
|
+
)
|
|
63
|
+
metrics.append(file_metric)
|
|
64
|
+
|
|
65
|
+
return metrics
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import re
|
|
3
|
+
from collections import Counter
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class HalSteadAnalyzer:
|
|
7
|
+
def __init__(self, operators: set):
|
|
8
|
+
self.operators: set = operators
|
|
9
|
+
|
|
10
|
+
def calculate_halstead_metrics(self, code: str):
|
|
11
|
+
# Tokenize the code
|
|
12
|
+
tokens = re.findall(r"\b\w+\b|[^\s\w]", code)
|
|
13
|
+
|
|
14
|
+
# Count operators and operands
|
|
15
|
+
operator_counts = Counter()
|
|
16
|
+
operand_counts = Counter()
|
|
17
|
+
|
|
18
|
+
for token in tokens:
|
|
19
|
+
if token in self.operators:
|
|
20
|
+
operator_counts[token] += 1
|
|
21
|
+
elif re.match(r"\b\w+\b", token):
|
|
22
|
+
operand_counts[token] += 1
|
|
23
|
+
|
|
24
|
+
# Halstead metrics
|
|
25
|
+
n1 = len(operator_counts) # distinct operators
|
|
26
|
+
n2 = len(operand_counts) # distinct operands
|
|
27
|
+
N1 = sum(operator_counts.values()) # total operators
|
|
28
|
+
N2 = sum(operand_counts.values()) # total operands
|
|
29
|
+
|
|
30
|
+
vocabulary = n1 + n2
|
|
31
|
+
length = N1 + N2
|
|
32
|
+
volume = length * math.log2(vocabulary) if vocabulary > 0 else 0
|
|
33
|
+
difficulty = (n1 / 2) * (N2 / n2) if n2 > 0 else 0
|
|
34
|
+
effort = difficulty * volume
|
|
35
|
+
|
|
36
|
+
calculated_length = 0
|
|
37
|
+
if n1 > 0:
|
|
38
|
+
calculated_length += n1 * math.log2(n1)
|
|
39
|
+
if n2 > 0:
|
|
40
|
+
calculated_length += n2 * math.log2(n2)
|
|
41
|
+
|
|
42
|
+
bugs = (effort ** (2 / 3)) / 3000 if effort > 0 else 0
|
|
43
|
+
time = effort / 18 if effort > 0 else 0
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
"n1": n1, # distinct operators
|
|
47
|
+
"n2": n2, # distinct operands
|
|
48
|
+
"N1": N1, # total operators
|
|
49
|
+
"N2": N2, # total operands
|
|
50
|
+
"vocabulary": vocabulary,
|
|
51
|
+
"length": length,
|
|
52
|
+
"volume": volume,
|
|
53
|
+
"difficulty": difficulty,
|
|
54
|
+
"effort": effort,
|
|
55
|
+
"calculated_length": calculated_length,
|
|
56
|
+
"bugs": bugs,
|
|
57
|
+
"time": time,
|
|
58
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import lizard
|
|
5
|
+
|
|
6
|
+
from metripy.Component.Output.ProgressBar import ProgressBar
|
|
7
|
+
from metripy.LangAnalyzer.AbstractLangAnalyzer import AbstractLangAnalyzer
|
|
8
|
+
from metripy.LangAnalyzer.Php.PhpBasicAstParser import PhpBasicAstParser
|
|
9
|
+
from metripy.LangAnalyzer.Php.PhpBasicLocAnalyzer import PhpBasicLocAnalyzer
|
|
10
|
+
from metripy.LangAnalyzer.Php.PhpHalSteadAnalyzer import PhpHalSteadAnalyzer
|
|
11
|
+
from metripy.Metric.Code.FileMetrics import FileMetrics
|
|
12
|
+
from metripy.Tree.ClassNode import ClassNode
|
|
13
|
+
from metripy.Tree.FunctionNode import FunctionNode
|
|
14
|
+
from metripy.Tree.ModuleNode import ModuleNode
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PhpAnalyzer(AbstractLangAnalyzer):
|
|
18
|
+
def __init__(self):
|
|
19
|
+
super().__init__()
|
|
20
|
+
self.loc_analyzer = PhpBasicLocAnalyzer()
|
|
21
|
+
self.halstead_analyzer = PhpHalSteadAnalyzer()
|
|
22
|
+
|
|
23
|
+
def get_lang_name(self) -> str:
|
|
24
|
+
return "PHP"
|
|
25
|
+
|
|
26
|
+
def set_files(self, files: list[str]) -> None:
|
|
27
|
+
self.files = list(filter(lambda file: file.endswith(".php"), files))
|
|
28
|
+
|
|
29
|
+
def is_needed(self) -> bool:
|
|
30
|
+
return len(self.files) > 0
|
|
31
|
+
|
|
32
|
+
def run(self, progress_bar: ProgressBar) -> None:
|
|
33
|
+
for file in self.files:
|
|
34
|
+
with open(file, "r") as f:
|
|
35
|
+
code = f.read()
|
|
36
|
+
self.analyze(code, file)
|
|
37
|
+
progress_bar.advance()
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def full_name(
|
|
41
|
+
filename: str, item_name: str | None = None, class_name: str | None = None
|
|
42
|
+
) -> str:
|
|
43
|
+
if class_name is None:
|
|
44
|
+
if item_name is None:
|
|
45
|
+
return filename
|
|
46
|
+
return f"{filename}:{item_name}"
|
|
47
|
+
return f"{filename}:{class_name}:{item_name}"
|
|
48
|
+
|
|
49
|
+
def analyze(self, code: str, filename: str) -> None:
|
|
50
|
+
file_stem = Path(filename).stem
|
|
51
|
+
structure = PhpBasicAstParser.parse_php_structure(code)
|
|
52
|
+
|
|
53
|
+
lizard_result = lizard.analyze_file(filename)
|
|
54
|
+
complexity_data = {
|
|
55
|
+
func.name: {
|
|
56
|
+
"complexity": func.cyclomatic_complexity,
|
|
57
|
+
"start_line": func.start_line,
|
|
58
|
+
"end_line": func.end_line,
|
|
59
|
+
}
|
|
60
|
+
for func in lizard_result.function_list
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
classes: dict[str, ClassNode] = {}
|
|
64
|
+
functions: dict[str, FunctionNode] = {}
|
|
65
|
+
for obj in structure:
|
|
66
|
+
if obj["type"] == "class":
|
|
67
|
+
full_name = self.full_name(filename, obj["name"])
|
|
68
|
+
classes[full_name] = ClassNode(
|
|
69
|
+
full_name,
|
|
70
|
+
obj["name"],
|
|
71
|
+
obj["line"],
|
|
72
|
+
0,
|
|
73
|
+
0, # gets filled in later, based on methods
|
|
74
|
+
)
|
|
75
|
+
elif obj["type"] == "method" or obj["type"] == "function":
|
|
76
|
+
full_name = self.full_name(filename, obj["name"])
|
|
77
|
+
try:
|
|
78
|
+
function_node = FunctionNode(
|
|
79
|
+
full_name,
|
|
80
|
+
obj["name"],
|
|
81
|
+
obj["line"],
|
|
82
|
+
0,
|
|
83
|
+
complexity_data[f"{file_stem}::{obj['name']}"]["complexity"],
|
|
84
|
+
)
|
|
85
|
+
function_node.line_end = complexity_data[
|
|
86
|
+
f"{file_stem}::{obj['name']}"
|
|
87
|
+
]["end_line"]
|
|
88
|
+
except KeyError:
|
|
89
|
+
# no complexity data, function must be empty
|
|
90
|
+
function_node = FunctionNode(
|
|
91
|
+
full_name,
|
|
92
|
+
obj["name"],
|
|
93
|
+
obj["line"],
|
|
94
|
+
0,
|
|
95
|
+
0,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if obj["type"] == "method":
|
|
99
|
+
class_name = obj["class"]
|
|
100
|
+
full_class_name = self.full_name(filename, class_name)
|
|
101
|
+
class_node = classes.get(full_class_name)
|
|
102
|
+
if class_node is not None:
|
|
103
|
+
class_node.functions.append(function_node)
|
|
104
|
+
else:
|
|
105
|
+
raise ValueError(
|
|
106
|
+
f"Class node not found for function {full_class_name}"
|
|
107
|
+
)
|
|
108
|
+
functions[full_name] = function_node
|
|
109
|
+
|
|
110
|
+
# complexity of classes
|
|
111
|
+
for class_node in classes.values():
|
|
112
|
+
class_node.real_complexity = sum(
|
|
113
|
+
[func.complexity for func in class_node.functions]
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
loc_data = self.loc_analyzer.get_loc_metrics(code, filename)
|
|
117
|
+
|
|
118
|
+
full_name = self.full_name(filename)
|
|
119
|
+
module_node = ModuleNode(
|
|
120
|
+
full_name,
|
|
121
|
+
loc_data.get("lines", 0),
|
|
122
|
+
loc_data.get("linesOfCode", 0),
|
|
123
|
+
loc_data.get("logicalLinesOfCode", 0),
|
|
124
|
+
loc_data.get("commentLines", 0),
|
|
125
|
+
0, # multi-line comments - not directly available
|
|
126
|
+
loc_data.get("linesOfCode", 0) - loc_data.get("logicalLinesOfCode", 0),
|
|
127
|
+
loc_data.get("commentLines", 0),
|
|
128
|
+
)
|
|
129
|
+
module_node.classes.extend(classes.values())
|
|
130
|
+
module_node.functions.extend(functions.values())
|
|
131
|
+
|
|
132
|
+
code_lines = code.split("\n")
|
|
133
|
+
for func_name, function_node in functions.items():
|
|
134
|
+
lines = code_lines[function_node.lineno:function_node.line_end]
|
|
135
|
+
function_metrics = self.halstead_analyzer.calculate_halstead_metrics(
|
|
136
|
+
"\n".join(lines)
|
|
137
|
+
)
|
|
138
|
+
function_node.h1 = function_metrics["n1"]
|
|
139
|
+
function_node.h2 = function_metrics["n2"]
|
|
140
|
+
function_node.N1 = function_metrics["N1"]
|
|
141
|
+
function_node.N2 = function_metrics["N2"]
|
|
142
|
+
function_node.vocabulary = function_metrics["vocabulary"]
|
|
143
|
+
function_node.length = function_metrics["length"]
|
|
144
|
+
function_node.volume = function_metrics["volume"]
|
|
145
|
+
function_node.difficulty = function_metrics["difficulty"]
|
|
146
|
+
function_node.effort = function_metrics["effort"]
|
|
147
|
+
function_node.calculated_length = function_metrics["calculated_length"]
|
|
148
|
+
function_node.bugs = function_metrics["bugs"]
|
|
149
|
+
function_node.time = function_metrics["time"]
|
|
150
|
+
|
|
151
|
+
maintainability_index = self._calculate_maintainability_index(
|
|
152
|
+
functions.values(), module_node
|
|
153
|
+
)
|
|
154
|
+
module_node.maintainability_index = maintainability_index
|
|
155
|
+
self.modules[full_name] = module_node
|
|
156
|
+
|
|
157
|
+
def _calculate_maintainability_index(
|
|
158
|
+
self, functions: list[FunctionNode], module_node: ModuleNode
|
|
159
|
+
) -> float:
|
|
160
|
+
"""Calculate maintainability index for PHP"""
|
|
161
|
+
if not functions:
|
|
162
|
+
return 100.0
|
|
163
|
+
|
|
164
|
+
total_volume = sum(func.volume for func in functions)
|
|
165
|
+
total_complexity = sum(func.complexity for func in functions)
|
|
166
|
+
total_length = sum(func.length for func in functions)
|
|
167
|
+
|
|
168
|
+
if total_volume == 0 or total_length == 0:
|
|
169
|
+
return 100.0
|
|
170
|
+
|
|
171
|
+
# PHP maintainability index calculation
|
|
172
|
+
mi_base = max(
|
|
173
|
+
(
|
|
174
|
+
171
|
|
175
|
+
- 5.2 * math.log(total_volume)
|
|
176
|
+
- 0.23 * total_complexity
|
|
177
|
+
- 16.2 * math.log(total_length)
|
|
178
|
+
)
|
|
179
|
+
* 100
|
|
180
|
+
/ 171,
|
|
181
|
+
0,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# Comment weight
|
|
185
|
+
comment_weight = 0
|
|
186
|
+
if module_node.loc > 0:
|
|
187
|
+
comment_ratio = module_node.single_comments / module_node.loc
|
|
188
|
+
comment_weight = 50 * math.sin(math.sqrt(2.4 * comment_ratio))
|
|
189
|
+
|
|
190
|
+
return mi_base + comment_weight
|
|
191
|
+
|
|
192
|
+
def get_metrics(self) -> list[FileMetrics]:
|
|
193
|
+
return super().get_metrics()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class PhpBasicAstParser:
|
|
5
|
+
|
|
6
|
+
@staticmethod
|
|
7
|
+
def parse_php_structure(code: str):
|
|
8
|
+
|
|
9
|
+
# Regex patterns
|
|
10
|
+
class_pattern = re.compile(r"class\s+(\w*)")
|
|
11
|
+
interface_pattern = re.compile(r"interface\s+(\w*)")
|
|
12
|
+
method_pattern = re.compile(r"function\s+(\w+)\s*\([^)]*\)?")
|
|
13
|
+
function_pattern = re.compile(r"function\s+(\w+)\s*\([^)]*\)?")
|
|
14
|
+
|
|
15
|
+
lines = code.split("\n")
|
|
16
|
+
structure = []
|
|
17
|
+
current_class = None
|
|
18
|
+
|
|
19
|
+
for i, line in enumerate(lines):
|
|
20
|
+
class_match = class_pattern.search(line)
|
|
21
|
+
if class_match:
|
|
22
|
+
current_class = class_match.group(1)
|
|
23
|
+
structure.append(
|
|
24
|
+
{"type": "class", "name": current_class, "line": i + 1}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
interface_match = interface_pattern.search(line)
|
|
28
|
+
if interface_match:
|
|
29
|
+
current_class = interface_match.group(1)
|
|
30
|
+
structure.append(
|
|
31
|
+
{
|
|
32
|
+
"type": "class",
|
|
33
|
+
"type_type": "interface",
|
|
34
|
+
"name": current_class,
|
|
35
|
+
"line": i + 1,
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
method_match = method_pattern.search(line)
|
|
40
|
+
if method_match and current_class:
|
|
41
|
+
structure.append(
|
|
42
|
+
{
|
|
43
|
+
"type": "method",
|
|
44
|
+
"name": method_match.group(1),
|
|
45
|
+
"class": current_class,
|
|
46
|
+
"line": i + 1,
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
elif function_pattern.search(line) and not current_class:
|
|
51
|
+
function_name = function_pattern.search(line).group(1)
|
|
52
|
+
structure.append(
|
|
53
|
+
{"type": "function", "name": function_name, "line": i + 1}
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
return structure
|