arch-researcher/code_analyzer.py

258 lines
8.3 KiB
Python
Raw Permalink Normal View History

2026-04-27 18:44:22 +00:00
"""
Встроенный анализатор сложности кода не требует 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