Skip to content
Open
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
75 changes: 45 additions & 30 deletions aibolit/metrics/npath/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import math
import os
import re
import shutil
import subprocess
import tempfile
Expand All @@ -27,6 +28,7 @@ def value(self, showoutput=False):

if len(self.input) == 0:
raise ValueError('Empty file for analysis')
root = None
try:
root = os.path.join(tempfile.gettempdir(), uuid.uuid4().hex)
dirName = os.path.join(root, 'src/main/java')
Expand All @@ -49,38 +51,51 @@ def value(self, showoutput=False):
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
if os.path.isfile(f'{root}/target/pmd.xml'):
res = self.__parseFile(root)
return res
return self._parse_report_file(f'{root}/target/pmd.xml', root)
raise Exception(' '.join(['File', self.input, 'analyze failed']))
finally:
shutil.rmtree(root)

def __parseFile(self, root):
result = {'data': [], 'errors': []}
content = []
with open(f'{root}/target/pmd.xml', 'r', encoding='utf-8') as file:
content = file.read()
soup = BeautifulSoup(content, features='xml')
files = soup.find_all('file')
for file in files:
out = file.violation.string
name = file['name']
pos1 = name.find(f'{root}/src/main/java/')
pos1 = pos1 + len(f'{root}/src/main/java/')
name = name[pos1:]
s = 'NPath complexity of '
pos1 = out.find(s)
pos1 = pos1 + len(s)
complexity = int(out[pos1:])
result['data'].append({'file': name, 'complexity': complexity})
errors = soup.find_all('error')
for error in errors:
name = error['filename']
pos1 = name.find(f'{root}/src/main/java/')
pos1 = pos1 + len(f'{root}/src/main/java/')
name = name[pos1:]
raise Exception(error['msg'])
return result
if root and os.path.isdir(root):
shutil.rmtree(root)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
@staticmethod
def _parse_report_file(report_path: str, root: str):
"""Read a PMD XML report from disk and parse its NPath results."""
with open(report_path, 'r', encoding='utf-8') as file:
return NPathMetric.parse_report(file.read(), root)

@staticmethod
def parse_report(content: str, root: str):
"""Parse PMD XML output into the metric result structure."""
data: list[dict[str, int | str]] = []
soup = BeautifulSoup(content, features='xml')
for file_tag in soup.find_all('file'):
out = file_tag.violation.get_text(strip=True) if file_tag.violation else ''
match = re.search(r'NPath complexity of\s+(\d+)', out)
if not match:
raise ValueError(
f'Unexpected PMD violation format for {file_tag["name"]}: {out}'
)
name = NPathMetric._relative_source_name(file_tag['name'], root)
complexity = int(match.group(1))
data.append({'file': name, 'complexity': complexity})
error_tags = soup.find_all('error')
for error in error_tags:
name = NPathMetric._relative_source_name(error['filename'], root)
raise Exception(f'{name}: {error["msg"]}')
return {'data': data, 'errors': []}

@staticmethod
def _relative_source_name(path: str, root: str) -> str:
"""Trim the generated PMD source prefix from a reported file path."""
normalized_path = os.path.normpath(path).replace('\\', '/')
normalized_prefix = os.path.normpath(
os.path.join(root, 'src', 'main', 'java')
).replace('\\', '/') + '/'
pos1 = normalized_path.find(normalized_prefix)
if pos1 == -1:
return path
pos1 = pos1 + len(normalized_prefix)
return normalized_path[pos1:]


class MvnFreeNPathMetric:
Expand Down
87 changes: 87 additions & 0 deletions test/metrics/npath/test_report_parsing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# SPDX-FileCopyrightText: Copyright (c) 2019-2026 Aibolit
# SPDX-License-Identifier: MIT

import pytest

from aibolit.metrics.npath import main as npath_main
from aibolit.metrics.npath.main import NPathMetric


def test_parse_report_reads_complexity_from_xml():
"""PMD XML content should be parsed without touching the filesystem."""
result = NPathMetric.parse_report(
'''
<pmd>
<file name="/tmp/npath/src/main/java/test/metrics/npath/Foo.java">
<violation>NPath complexity of 200</violation>
</file>
</pmd>
''',
'/tmp/npath',
)

assert result == {
'data': [{'file': 'test/metrics/npath/Foo.java', 'complexity': 200}],
'errors': [],
}


def test_parse_report_raises_pmd_error():
"""PMD error entries should surface with source-relative filenames."""
with pytest.raises(Exception, match='test/metrics/npath/ooo.java: PMDException'):
NPathMetric.parse_report(
'''
<pmd>
<error filename="/tmp/npath/src/main/java/test/metrics/npath/ooo.java"
msg="PMDException" />
</pmd>
''',
'/tmp/npath',
)


def test_parse_report_raises_on_malformed_violation():
"""Unexpected violation text should fail with a descriptive parser error."""
with pytest.raises(ValueError, match='Unexpected PMD violation format'):
NPathMetric.parse_report(
'''
<pmd>
<file name="/tmp/npath/src/main/java/test/metrics/npath/Foo.java">
<violation>unexpected</violation>
</file>
</pmd>
''',
'/tmp/npath',
)


def test_parse_report_normalizes_windows_source_paths():
"""Windows-style PMD paths should still be trimmed to source-relative names."""
result = NPathMetric.parse_report(
'''
<pmd>
<file name="C:\\tmp\\npath\\src\\main\\java\\test\\metrics\\npath\\Foo.java">
<violation>NPath complexity of 200</violation>
</file>
</pmd>
''',
'C:\\tmp\\npath',
)

assert result == {
'data': [{'file': 'test/metrics/npath/Foo.java', 'complexity': 200}],
'errors': [],
}


def test_value_preserves_root_initialization_error(monkeypatch):
"""Temporary-root setup errors should not be masked by cleanup."""
metric = NPathMetric('Foo.java')

def fail_gettempdir():
raise RuntimeError('temp root failed')

monkeypatch.setattr(npath_main.tempfile, 'gettempdir', fail_gettempdir)

with pytest.raises(RuntimeError, match='temp root failed'):
metric.value()
Loading