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
|