258 lines
8.3 KiB
Python
258 lines
8.3 KiB
Python
|
|
"""
|
|||
|
|
Встроенный анализатор сложности кода — не требует lizard.
|
|||
|
|
|
|||
|
|
Реализует подсчёт цикломатической сложности (McCabe, 1976)
|
|||
|
|
методом подсчёта ветвлений в тексте кода (лексический подход),
|
|||
|
|
аналогично тому, как это делает библиотека lizard.
|
|||
|
|
|
|||
|
|
Поддерживаемые языки: C, C++, JavaScript, TypeScript.
|
|||
|
|
|
|||
|
|
Точность: comparable с lizard при отсутствии установленного Clang.
|
|||
|
|
Для продакшн-использования рекомендуется установить lizard:
|
|||
|
|
pip install lizard
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import re
|
|||
|
|
import tokenize
|
|||
|
|
import io
|
|||
|
|
from dataclasses import dataclass
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import List, Optional
|
|||
|
|
|
|||
|
|
|
|||
|
|
# Ключевые слова ветвления, каждое увеличивает CCN на 1
|
|||
|
|
# По методу McCabe: CCN = 1 + число точек ветвления
|
|||
|
|
_BRANCH_KEYWORDS_CPP = re.compile(
|
|||
|
|
r'\b(if|else\s+if|for|while|do|case|catch|&&|\|\||and|or|\?)\b',
|
|||
|
|
re.MULTILINE,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
_BRANCH_KEYWORDS_JS = re.compile(
|
|||
|
|
r'\b(if|else\s+if|for|while|do|switch|case|catch|&&|\|\||\?(?!:))\b',
|
|||
|
|
re.MULTILINE,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Сигнатуры функций C/C++
|
|||
|
|
_CPP_FUNC = re.compile(
|
|||
|
|
r'^(?:[\w:*&<>\s]+?)\s+(\w+)\s*\(([^)]*)\)\s*(?:const\s*)?(?:override\s*)?(?:noexcept[^{]*)?\{',
|
|||
|
|
re.MULTILINE,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Сигнатуры функций JS/TS
|
|||
|
|
_JS_FUNC = re.compile(
|
|||
|
|
r'(?:'
|
|||
|
|
r'function\s+(\w+)\s*\(([^)]*)\)' # function foo(...)
|
|||
|
|
r'|(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?\(([^)]*)\)\s*=>' # arrow
|
|||
|
|
r'|(\w+)\s*:\s*(?:async\s*)?function\s*\(([^)]*)\)' # method: function
|
|||
|
|
r'|(?:async\s+)?(\w+)\s*\(([^)]*)\)\s*\{' # method shorthand
|
|||
|
|
r')',
|
|||
|
|
re.MULTILINE,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Строки и комментарии — для удаления перед анализом
|
|||
|
|
_CPP_COMMENT_LINE = re.compile(r'//[^\n]*')
|
|||
|
|
_CPP_COMMENT_BLOCK = re.compile(r'/\*.*?\*/', re.DOTALL)
|
|||
|
|
_CPP_STRING = re.compile(r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'')
|
|||
|
|
|
|||
|
|
_JS_TEMPLATE = re.compile(r'`[^`]*`', re.DOTALL)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class FunctionInfo:
|
|||
|
|
"""Результат разбора одной функции."""
|
|||
|
|
name: str
|
|||
|
|
start_line: int
|
|||
|
|
nloc: int
|
|||
|
|
cyclomatic_complexity: int
|
|||
|
|
parameter_count: int
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class FileAnalysisResult:
|
|||
|
|
"""Результат анализа одного файла."""
|
|||
|
|
filename: str
|
|||
|
|
function_list: List[FunctionInfo]
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def average_ccn(self) -> float:
|
|||
|
|
if not self.function_list:
|
|||
|
|
return 0.0
|
|||
|
|
return sum(f.cyclomatic_complexity for f in self.function_list) / len(self.function_list)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def analyze_file(file_path: str) -> Optional[FileAnalysisResult]:
|
|||
|
|
"""
|
|||
|
|
Анализирует один файл и возвращает метрики по функциям.
|
|||
|
|
Возвращает None если файл нечитаем или неподдерживаемого типа.
|
|||
|
|
"""
|
|||
|
|
path = Path(file_path)
|
|||
|
|
suffix = path.suffix.lower()
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|||
|
|
except OSError:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
if suffix in (".cpp", ".cxx", ".cc", ".c", ".hpp", ".hxx", ".h"):
|
|||
|
|
functions = _analyze_cpp(text, file_path)
|
|||
|
|
elif suffix in (".js", ".mjs", ".cjs", ".ts"):
|
|||
|
|
functions = _analyze_js(text, file_path)
|
|||
|
|
else:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
return FileAnalysisResult(filename=file_path, function_list=functions)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# C / C++ анализ
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def _analyze_cpp(text: str, filename: str) -> List[FunctionInfo]:
|
|||
|
|
"""Анализирует C/C++ код."""
|
|||
|
|
# Удаляем строковые литералы и комментарии
|
|||
|
|
clean = _CPP_COMMENT_BLOCK.sub(' ', text)
|
|||
|
|
clean = _CPP_COMMENT_LINE.sub('', clean)
|
|||
|
|
clean = _CPP_STRING.sub('""', clean)
|
|||
|
|
|
|||
|
|
functions: List[FunctionInfo] = []
|
|||
|
|
|
|||
|
|
for match in _CPP_FUNC.finditer(clean):
|
|||
|
|
func_name = match.group(1)
|
|||
|
|
params_str = match.group(2).strip()
|
|||
|
|
|
|||
|
|
# Пропускаем системные/macro-like имена
|
|||
|
|
if func_name in ('if', 'for', 'while', 'switch', 'catch', 'return'):
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
start_pos = match.start()
|
|||
|
|
start_line = text[:start_pos].count('\n') + 1
|
|||
|
|
|
|||
|
|
# Извлекаем тело функции
|
|||
|
|
body = _extract_body(clean, match.end() - 1)
|
|||
|
|
if body is None:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
nloc = _count_nloc(body)
|
|||
|
|
ccn = 1 + len(_BRANCH_KEYWORDS_CPP.findall(body))
|
|||
|
|
params = _count_params(params_str)
|
|||
|
|
|
|||
|
|
functions.append(FunctionInfo(
|
|||
|
|
name=func_name,
|
|||
|
|
start_line=start_line,
|
|||
|
|
nloc=nloc,
|
|||
|
|
cyclomatic_complexity=ccn,
|
|||
|
|
parameter_count=params,
|
|||
|
|
))
|
|||
|
|
|
|||
|
|
return functions
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# JavaScript / TypeScript анализ
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def _analyze_js(text: str, filename: str) -> List[FunctionInfo]:
|
|||
|
|
"""Анализирует JS/TS код."""
|
|||
|
|
# Удаляем шаблонные строки и комментарии
|
|||
|
|
clean = _CPP_COMMENT_BLOCK.sub(' ', text)
|
|||
|
|
clean = _CPP_COMMENT_LINE.sub('', clean)
|
|||
|
|
clean = _JS_TEMPLATE.sub('``', clean)
|
|||
|
|
clean = _CPP_STRING.sub('""', clean)
|
|||
|
|
|
|||
|
|
functions: List[FunctionInfo] = []
|
|||
|
|
seen_positions: set = set()
|
|||
|
|
|
|||
|
|
for match in _JS_FUNC.finditer(clean):
|
|||
|
|
# Определяем имя и параметры из разных групп
|
|||
|
|
name = (
|
|||
|
|
match.group(1) or match.group(3) or
|
|||
|
|
match.group(5) or match.group(7) or "anonymous"
|
|||
|
|
)
|
|||
|
|
params_str = (
|
|||
|
|
match.group(2) or match.group(4) or
|
|||
|
|
match.group(6) or match.group(8) or ""
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
start_pos = match.start()
|
|||
|
|
if start_pos in seen_positions:
|
|||
|
|
continue
|
|||
|
|
seen_positions.add(start_pos)
|
|||
|
|
|
|||
|
|
start_line = text[:start_pos].count('\n') + 1
|
|||
|
|
|
|||
|
|
# Ищем открывающую фигурную скобку
|
|||
|
|
brace_pos = clean.find('{', match.end())
|
|||
|
|
if brace_pos == -1:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
body = _extract_body(clean, brace_pos)
|
|||
|
|
if body is None:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
nloc = _count_nloc(body)
|
|||
|
|
ccn = 1 + len(_BRANCH_KEYWORDS_JS.findall(body))
|
|||
|
|
params = _count_params(params_str)
|
|||
|
|
|
|||
|
|
functions.append(FunctionInfo(
|
|||
|
|
name=name,
|
|||
|
|
start_line=start_line,
|
|||
|
|
nloc=nloc,
|
|||
|
|
cyclomatic_complexity=ccn,
|
|||
|
|
parameter_count=params,
|
|||
|
|
))
|
|||
|
|
|
|||
|
|
return functions
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# Вспомогательные функции
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def _extract_body(text: str, open_brace_pos: int) -> Optional[str]:
|
|||
|
|
"""
|
|||
|
|
Извлекает тело функции, начиная с позиции открывающей скобки.
|
|||
|
|
Возвращает содержимое между { и соответствующей }.
|
|||
|
|
"""
|
|||
|
|
depth = 0
|
|||
|
|
i = open_brace_pos
|
|||
|
|
|
|||
|
|
while i < len(text):
|
|||
|
|
ch = text[i]
|
|||
|
|
if ch == '{':
|
|||
|
|
depth += 1
|
|||
|
|
elif ch == '}':
|
|||
|
|
depth -= 1
|
|||
|
|
if depth == 0:
|
|||
|
|
return text[open_brace_pos + 1:i]
|
|||
|
|
i += 1
|
|||
|
|
|
|||
|
|
return None # Незакрытая скобка
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _count_nloc(body: str) -> int:
|
|||
|
|
"""Считает непустые, неккомментарные строки."""
|
|||
|
|
return sum(
|
|||
|
|
1 for line in body.splitlines()
|
|||
|
|
if line.strip() and not line.strip().startswith('//')
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _count_params(params_str: str) -> int:
|
|||
|
|
"""Считает число параметров по строке параметров."""
|
|||
|
|
params_str = params_str.strip()
|
|||
|
|
if not params_str or params_str in ('void', '...'):
|
|||
|
|
return 0
|
|||
|
|
# Разбиваем по запятой, учитывая вложенные угловые скобки
|
|||
|
|
depth = 0
|
|||
|
|
count = 1
|
|||
|
|
for ch in params_str:
|
|||
|
|
if ch in '<(':
|
|||
|
|
depth += 1
|
|||
|
|
elif ch in '>)':
|
|||
|
|
depth -= 1
|
|||
|
|
elif ch == ',' and depth == 0:
|
|||
|
|
count += 1
|
|||
|
|
return count
|