From 670923dd7890775f669441a9e3c98bba5b047e60 Mon Sep 17 00:00:00 2001 From: TensorTemplar Date: Sat, 31 Jan 2026 18:11:12 +0200 Subject: [PATCH 1/3] Add rust-code-analyis support, update related docs etc --- .coverage | Bin 53248 -> 53248 bytes .github/workflows/ci.yml | 2 +- README.md | 18 +- THIRD_PARTY_NOTICES.md | 32 + coverage.xml | 1291 ++++++++++---------- pyproject.toml | 7 +- scripts/profile_current_impact.sh | 63 + src/slopometry/cli.py | 7 + src/slopometry/core/code_analyzer.py | 153 +++ src/slopometry/core/complexity_analyzer.py | 215 +--- src/slopometry/core/git_tracker.py | 63 + src/slopometry/core/language_config.py | 22 +- src/slopometry/core/language_detector.py | 3 +- src/slopometry/core/models.py | 1 + src/slopometry/core/tokenizer.py | 60 + tests/test_cli.py | 17 +- tests/test_code_analyzer.py | 149 +++ tests/test_complexity_analyzer.py | 167 ++- tests/test_language_guard.py | 19 +- tests/test_tokenizer.py | 77 ++ uv.lock | 55 +- 21 files changed, 1524 insertions(+), 897 deletions(-) create mode 100644 THIRD_PARTY_NOTICES.md create mode 100755 scripts/profile_current_impact.sh create mode 100644 src/slopometry/core/code_analyzer.py create mode 100644 src/slopometry/core/tokenizer.py create mode 100644 tests/test_code_analyzer.py create mode 100644 tests/test_tokenizer.py diff --git a/.coverage b/.coverage index 4397c323196393d20debc75deeaf72cea2cfc56a..a5c32ea42cf2af9c95503336b2bd680baaae5372 100644 GIT binary patch delta 2598 zcmZ9OeNIId;cd#dcfmx}%k*pu;Ly|8UwVdbWyaed%%CPDKQ@MGg?MU#{ADnsbux^ZWju z-|uQ?m+`A7VF{BjcnrJ@+y98mCmguUxKV*>h|&8>_ghcuSGWJYwEqRx&F33|&nJ$xq2&lSjxd%bL47 zf@QK5vq|aFWa3(iQ4Eao=vi{gO>&cCg_4r$vZ|VrlGvYaD|C|V4Ushj1YSLaNOVjnV1?H181q;aTbR}bkKr0nrO4lG0apfeF@C*YJ1pcdV&?GpP zg$aacp%i_QVPpaza!57KOfzGIC{fC_Lu7#H>GrQ@8j&7Ozmm~`XOiYOFEb!5Y%3dS zh$b6W%F;1d%H}4=8_PFU%`LC3t}d%tw;2wQDndp}P)Z#6Tb70qAQJpnPo}fo&NJWlj=PH(RVDf%btgqczQB&1eQ3u`9lyG73^77hso=4d_8KBcb$sNLR zUZ(01DOH)ufKREfDV2(UGpiV${X5=@7mD}kPuUhBRD55kqfJ~nJ6F9}v7dj;yi3>c zhsEW*i|bbYMP9B*<2czB=CQ0r-6lV+bai!v$Oxa8hh~$u{0#B`P{b^eyW#5Rs$ZaI z7I~Oggq-l1LOPO-D4L8GB>*wOz)oBK0w5+7*m12&XB-(c5aa*mfEeL9F%elb-1t-1 zfWpbVd>3HTmY)aMwB@G|bD9X)xK_mhEJh|YlLlCx^-z5Wi>C(J!L`82=`lm+6rX_LdFU;I*(?mX^^x%lcR2 zG~a^;!xqyRF@*+J{L4&d>5u@W`HVW123Gt{4`z#qCr8$D(K z*6Qr}PBwbWR#^k*3BT>T-!@`povk zslHzYM1vO_T{Ljj_swKybpuje&t(vgirR_CBN!gk*F;S82(|Gt47&&1==ypBM;3=}k5b`EsY-js3?_$R|JY%zQI?yU*>L@lHJ%?F4=2 z>n?WU?0U~orO!9x>)SLwc$`Yu-XdAn@!LV#t4F%u{BrByrT?6Ia;s_d)D!D~ui&kJ zXjMV{idD8+ytAkq6v`FvMc%*1~y zkgL>19UJ&Ib(MmeDXd-Tpr#8QROz{><((R&92->5oSas-ZDNUO_ei(`7D_Pm4)vLo zss}l(Cle}NibYh5~ z@n^4AYR1q%M=Tvdt+Ib@^m^Yrv9;-5)8K(AAH0rNpPcH!;c{r2t|KS+geqh(%+iuO zdu(nKg7E@n@7{fi$=7>lynDh{qoZzLR1%V@pt)1LOW`Z{5Iip4fPW@VcyvtSTHJy! zh!1fdPQmlA9Y4nR@hv=vEx1hdVS{)VpT&pq>$nL1&17MPIDucm-S{ZpiR1AG@g5eQ zZ=0e#7L8kU`!55ToRUOGOQMdFBrH;rK!+r@2uYmM?V%vM6dA)L(byzWgi3;~l0=3` zVh@%?v`7+amP9Z~5)!0v`Vi6>X vP)icdOJY??V&){_l?=^sh?N3^0wk0}dPWLaS`wXH60OV+0;hcU(7gWvUUeI_ delta 2429 zcmZ9Odr(yO702)0y}$du_qT7}yUX%eF%>Aj$oK#ikjH|s_&{1ktD;5)i;7c_vP;{E zZySG;w8m7ae^jSUe61#_ir_fuBaSi7R0~?sX(r7yAV_T^1?BeKi^WcN20rKUJLmi! z-(?83n?vp9vrKN)WV1-&E4UF)Q~T7DYMG)b?2NoJ~*43(uTH8opSZ>+1SfhEiRq;o+Y z87P$GS9Qx%Ay|!47%Kc46ig)*c?onf^s1zj$m6nXGMZ;WKFB(a?16lN1o8!x2$8s# z5hwD3m!yv)ckBRifSy_8vjP`sS(r_ro3y6Bu6E;k*j^xxWG*bF zW62K}7Bex>k%X5`J6y=4Xt1rutlUBi9Yrb%tB?y~^NfjS=iA^&amWdg=@XH%A}4Y{ zB*myZS`>@y;N?u@M<8N@$oLro#ZF`eZ~O+16el1HcrixpK(T|yu(O$DuoRJ;5-Y8e zijt*BdATB;oSiQ?*KFRHv8H~@meqA@x4~HoWXS4!PA91WCz8NZ46iO=r$wmGBfksS zkN|OPOtF+=nkU}UiilQSG!!@8U&=Gzq-ifEH{ivLm zln>NX%t>L0>yZD*rO9D7lX)&rR~JdW;sRl&d5FCu{LEa#hRu?KnE>B=?yWN>(h{ts zCzIR3N+xZNxs`;28S?+AWD>brnXijPdL|K=m|oBF}@83z@JT z9%{C-e#h#DwQ%&LA_lYqPp7{wXoW|<00Z|t9&w<=at5>#4rY?O`SG+DIu()G1tO^C zb}*mpFHn(tyk{^2bmAntmc6cb5+`&LPcoL3(s86^Suu*uVZ!vdPB;{^kbyhb=j0Y* zI+~PMRiUUF26Pe*&Vv)hBG+;TG@^TDMGoYwU_c-G`lCfL$gz?EedxIn@az+N4#4}{ z;N**uwS)mp=<5fH?X(59k^NE=7Enm|yZPkp5-U>2R|T^j|ECFMe0y+kf5499d3VN3wNDs=XP;xx$=nB zGQv)^`59VBjcWRk`n<;S{90SiXy;E{sSry~t-f_v`{&5yxF zS^1{>d&ywtc}~|i@?MIf3MtwIpjFey-jGCtNm-(jU`eJ^%*y4cQ1jsHU2|R>rye=G zIt3pj9PVm+h_)qynd-l{na-V}7cmsY7kd#cgdHx@`({4tf&5}SN!y(g?FRE(s%ZmT zMP*Z6_viHeo%%e>oRL6kcYm9Uhnltmg&f;`EIJMr`jYQ$wo+D$@9Qr-bXu(5#Y-0N zN#SDj=E*V%?J47;!Tu4GK<@16P@`apD(#JP0kh*s``)N*Czz8QO+9UY_VUTi`uy^+V)rsPIGpsu!}RVLYICu4h)IGa9aJ5MI<+mUXC z8gJ~SPhH(#*LTi)8Wn4Sz>K7(7w1;hTo^51fw*6-*0?pEJ;a(UDGr@hUC9nX534}GQm#xUU5sq*+1RJX**4j-6vJ!@E#FZT?5w&g*0=grzxXD;4( zqx+~jOCgc{vnymclRIYQwdNPG{aS-8NZ>nsZ@9ZTRn$|7m$IIpLl-(mA_)Tcv*S%w z>Z47&;L*KQ7ay`GcQu*b_lJJVweb4YHARzo2X=yLbw?FQ*MY>ORmb}d{92}-M|w{c zQ^S#Bs_WpQV{;QtmPco-&v*ViCcK#?^1;U}g8tbyq8*H488Ek7N&KN{E0>t{R+G6A zX=xjK?cJRk)zxMK)zYUf-n?@8JA>5Nab1EG1$uq;@rH8H)_vxx=JHT=-{8zoI_}?j zc6!z`4L9{ed-YY6raZX@CJMxS7QfeaRB~}xQGwqeB7uG<2d-v z9#vae7(++-4Wy - + @@ -16,8 +16,61 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -232,7 +285,7 @@ - + @@ -240,269 +293,220 @@ - - + + - - + - - - + + + - - - - + + + + - - - - - - - - + + + + + + + + - + + + + + + - + + + + + - - - - - + + + + + + + + + + + + + + - - - - + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - - - - - + + + + + + + + + + + + + + + - - - + - - - - - + - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + - - + + + + + + + - - - + + + + + + + + + + + - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -1384,7 +1388,7 @@ - + @@ -1497,125 +1501,154 @@ + + - - + + - - + + - - - - + - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - - - - - + + + + - - - - - - - - - - + + + + + + + + + + + + + - + - - + + + + + + + + + - - + + - - - - - - - - - - + + + + + + + + - - - - - - - - - - - - + + + + + + - - - - - - - - + - - - - - - + + + + + + + - - + + + + + + - - - + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2084,39 +2117,40 @@ - + - - - - + + + + - - - - - + + - + - - - - + + + + - + - - - - + + + + - - + + + + + + @@ -2129,32 +2163,32 @@ - + + - - - + + + - + - + - - - + + + - + - - + + - @@ -2443,20 +2477,20 @@ - - + + - - + + - - + + - - + + @@ -2472,7 +2506,7 @@ - + @@ -2493,18 +2527,18 @@ - - - + + + - - + + - - + + @@ -2519,8 +2553,8 @@ - - + + @@ -2528,45 +2562,45 @@ - - + + - - - + + + - - + + - + - + - - + + - - + + - + - + @@ -2579,46 +2613,46 @@ - - - - + + + + - - + + - - + + - - + + - + - - + + - - + + - - + + - + - - + + @@ -2629,11 +2663,11 @@ - + - - + + @@ -2641,8 +2675,8 @@ - - + + @@ -2651,8 +2685,8 @@ - - + + @@ -2672,58 +2706,58 @@ - - + + - - - + + + - - - + + + - - - + + + - - - + + + - - - - + + + + - - - + + + - - + + - - + + @@ -2732,67 +2766,67 @@ - + - - + + - - - - - - - - + + + + + + + + - + - - + + - - + + - - + + - - - + + + - + - + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - + @@ -2804,15 +2838,15 @@ - - - - - - - - - + + + + + + + + + @@ -2822,121 +2856,121 @@ - - + + - - + + - + - - + + - - + + - - + + - - + + - - + + - + - - - + + + - + - - + + - + - + - - - + + + - - + + - - + + - - + + - - + + - - + + - - - + + + - - + + @@ -2944,47 +2978,47 @@ - + - - + + - + - + - + - + - - - + + + - + - - - - - + + + + + - - + + - + - + @@ -2993,8 +3027,8 @@ - - + + @@ -3002,45 +3036,45 @@ - - - + + + - - - + + + - + - - - - - - - - - - + + + + + + + + + + - - - + + + - - + + - - - + + + @@ -3053,33 +3087,33 @@ - - + + - + - + - - + + - - - - + + + + - + - - - + + + - - - + + + @@ -3087,54 +3121,55 @@ - + - + - - + + - + - - + + - + - - + + - - + + - - - + + + - + - - - - + + + + - - - - + + + + - - - + + + - - - + + + + @@ -3974,6 +4009,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pyproject.toml b/pyproject.toml index 52b6ce7..c33f7a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "slopometry" -version = "20260125-2" +version = "2026.1.25.2" description = "Opinionated code quality metrics for code agents and humans" readme = "README.md" requires-python = ">=3.13" @@ -29,7 +29,6 @@ dependencies = [ "pydantic>=2.0", "pydantic-settings>=2.0", "sqlite-utils>=3.0", - "radon>=6.0.1", "toml>=0.10.2", "pydantic-ai>=1.33.0", "pandas>=2.0.0", @@ -38,6 +37,7 @@ dependencies = [ "huggingface-hub>=0.20.0", "coverage>=7.0.0", "tiktoken>=0.7.0", + "rust-code-analysis>=2026.1.31", ] [project.optional-dependencies] @@ -128,3 +128,6 @@ directory = "htmlcov" [tool.pyrefly] search_path = ["src", "tests"] + +[tool.uv] +find-links = ["https://github.com/Droidcraft/rust-code-analysis/releases/expanded_assets/python-2026.1.31"] diff --git a/scripts/profile_current_impact.sh b/scripts/profile_current_impact.sh new file mode 100755 index 0000000..b8ebe0a --- /dev/null +++ b/scripts/profile_current_impact.sh @@ -0,0 +1,63 @@ +#!/bin/bash +# Profile slopometry current-impact command using py-spy +# +# Usage: +# ./scripts/profile_current_impact.sh /path/to/large/repo +# ./scripts/profile_current_impact.sh /path/to/large/repo profile.json +# +# Requirements: +# pip install py-spy (or: uv tool install py-spy) +# +# Output formats: +# - speedscope (.json) - open with https://speedscope.app +# - flamegraph (.svg) - open in browser + +set -e + +REPO_PATH="${1:-.}" +OUTPUT="${2:-profile.json}" + +case "$OUTPUT" in + *.svg) + FORMAT="flamegraph" + ;; + *) + FORMAT="speedscope" + ;; +esac + +echo "Profiling current-impact on: $REPO_PATH" +echo "Output format: $FORMAT" +echo "Output file: $OUTPUT" +echo "" + +if ! command -v py-spy &> /dev/null; then + echo "Error: py-spy not found. Install with:" + echo " pip install py-spy" + echo " # or" + echo " uv tool install py-spy" + exit 1 +fi + +# Run profiling with py-spy +# --subprocesses: Also profile child processes (ProcessPoolExecutor workers) +# --native: Include native (C) frames for full picture +py-spy record \ + --output "$OUTPUT" \ + --format "$FORMAT" \ + --subprocesses \ + --native \ + -- uv run slopometry summoner current-impact \ + --repo-path "$REPO_PATH" \ + --recompute-baseline \ + --no-pager + +echo "" +echo "Profile saved to: $OUTPUT" + +if [ "$FORMAT" = "speedscope" ]; then + echo "Open with: https://speedscope.app (drag and drop the file)" + echo "Or install speedscope: npm install -g speedscope && speedscope $OUTPUT" +else + echo "Open in browser: $OUTPUT" +fi diff --git a/src/slopometry/cli.py b/src/slopometry/cli.py index a14050e..3694c4b 100644 --- a/src/slopometry/cli.py +++ b/src/slopometry/cli.py @@ -2,12 +2,18 @@ import shutil import sys +from importlib.metadata import version import click from slopometry.display.console import console +def get_version() -> str: + """Get package version.""" + return version("slopometry") + + def check_slopometry_in_path() -> bool: """Check if slopometry is available in PATH.""" return shutil.which("slopometry") is not None @@ -21,6 +27,7 @@ def warn_if_not_in_path() -> None: @click.group() +@click.version_option(version=get_version(), prog_name="slopometry") def cli() -> None: """Slopometry - Claude Code session tracker. diff --git a/src/slopometry/core/code_analyzer.py b/src/slopometry/core/code_analyzer.py new file mode 100644 index 0000000..3c89bd3 --- /dev/null +++ b/src/slopometry/core/code_analyzer.py @@ -0,0 +1,153 @@ +"""Unified code complexity analysis using rust-code-analysis Python binding.""" + +import logging +import math +from pathlib import Path + +from slopometry.core.models import FileAnalysisResult +from slopometry.core.tokenizer import count_file_tokens + +logger = logging.getLogger(__name__) + +SUPPORTED_EXTENSIONS = frozenset({".py", ".rs", ".js", ".ts", ".tsx", ".jsx", ".go", ".java", ".cpp", ".c", ".kt"}) + + +class CodeAnalysisError(Exception): + """Raised when code analysis fails.""" + + +class CodeAnalysisNotInstalledError(CodeAnalysisError): + """Raised when rust-code-analysis is not installed.""" + + +def _check_rca_installed() -> None: + """Check if rust-code-analysis is installed.""" + try: + import rust_code_analysis # noqa: F401 + except ImportError as e: + raise CodeAnalysisNotInstalledError( + "rust-code-analysis not installed. Install from wheel or build with maturin." + ) from e + + +def _safe_float(value: float | None) -> float: + """Convert NaN/None to 0.0.""" + if value is None: + return 0.0 + if isinstance(value, float) and (math.isnan(value) or math.isinf(value)): + return 0.0 + return float(value) + + +def _analyze_single_file(file_path: Path) -> FileAnalysisResult: + """Analyze a single source file. + + Module-level function for ProcessPoolExecutor compatibility. + """ + import rust_code_analysis as rca + + from slopometry.core.tokenizer import count_file_tokens + + try: + result = rca.analyze_file(str(file_path)) + m = result.metrics + + cc_sum = sum(f.metrics.cyclomatic.sum for f in result.get_functions()) + + return FileAnalysisResult( + path=str(file_path), + complexity=int(cc_sum), + volume=_safe_float(m.halstead.volume), + difficulty=_safe_float(m.halstead.difficulty), + effort=_safe_float(m.halstead.effort), + mi=_safe_float(m.mi.mi_original), + tokens=count_file_tokens(file_path), + ) + except Exception as e: + return FileAnalysisResult( + path=str(file_path), + complexity=0, + volume=0.0, + difficulty=0.0, + effort=0.0, + mi=0.0, + tokens=0, + error=str(e), + ) + + +class CodeAnalyzer: + """Unified code analyzer using rust-code-analysis Python binding. + + Supports Python, Rust, JavaScript, TypeScript, Go, Java, C/C++, Kotlin. + """ + + def __init__(self) -> None: + """Initialize the analyzer. + + Raises: + CodeAnalysisNotInstalledError: If rust-code-analysis is not installed. + """ + _check_rca_installed() + import rust_code_analysis as rca + + self._rca = rca + + def analyze_file(self, file_path: Path) -> FileAnalysisResult: + """Analyze a single source file. + + Args: + file_path: Path to the source file. + + Returns: + FileAnalysisResult with metrics. + """ + try: + result = self._rca.analyze_file(str(file_path)) + m = result.metrics + + cc_sum = sum(f.metrics.cyclomatic.sum for f in result.get_functions()) + + return FileAnalysisResult( + path=str(file_path), + complexity=int(cc_sum), + volume=_safe_float(m.halstead.volume), + difficulty=_safe_float(m.halstead.difficulty), + effort=_safe_float(m.halstead.effort), + mi=_safe_float(m.mi.mi_original), + tokens=count_file_tokens(file_path), + ) + except Exception as e: + logger.warning("Failed to analyze %s: %s", file_path, e) + return FileAnalysisResult( + path=str(file_path), + complexity=0, + volume=0.0, + difficulty=0.0, + effort=0.0, + mi=0.0, + tokens=0, + error=str(e), + ) + + def analyze_files(self, file_paths: list[Path]) -> list[FileAnalysisResult]: + """Analyze multiple source files sequentially. + + Args: + file_paths: List of paths to analyze. + + Returns: + List of FileAnalysisResult, one per file. + """ + return [self.analyze_file(fp) for fp in file_paths] + + def is_supported(self, file_path: Path) -> bool: + """Check if a file extension is supported. + + Args: + file_path: Path to check. + + Returns: + True if the file extension is supported. + """ + return file_path.suffix.lower() in SUPPORTED_EXTENSIONS diff --git a/src/slopometry/core/complexity_analyzer.py b/src/slopometry/core/complexity_analyzer.py index 09a4812..18a20b7 100644 --- a/src/slopometry/core/complexity_analyzer.py +++ b/src/slopometry/core/complexity_analyzer.py @@ -1,13 +1,12 @@ -"""Cognitive complexity analysis using radon.""" +"""Cognitive complexity analysis using rust-code-analysis.""" import logging import os import time -import warnings from concurrent.futures import ProcessPoolExecutor, as_completed from pathlib import Path -from typing import Any +from slopometry.core.code_analyzer import CodeAnalyzer, _analyze_single_file from slopometry.core.models import ( ComplexityDelta, ComplexityMetrics, @@ -16,75 +15,15 @@ ) from slopometry.core.python_feature_analyzer import PythonFeatureAnalyzer, _count_loc from slopometry.core.settings import settings +from slopometry.core.tokenizer import count_file_tokens logger = logging.getLogger(__name__) -CALCULATOR_VERSION = "2024.1.4" - - -def _get_tiktoken_encoder() -> Any: - """Get tiktoken encoder, falling back if o200k_base encoding not available. - - Returns: - tiktoken Encoder for token counting - """ - import tiktoken - - try: - return tiktoken.get_encoding("o200k_base") - except Exception as e: - logger.debug(f"Falling back to cl100k_base encoding: {e}") - return tiktoken.get_encoding("cl100k_base") - - -def _analyze_single_file_extended(file_path: Path) -> FileAnalysisResult | None: - """Analyze a single Python file for all metrics. - - Module-level function required for ProcessPoolExecutor pickling. - Imports are inside function to avoid serialization issues. - """ - import radon.complexity as cc_lib - import radon.metrics as metrics_lib - - encoder = _get_tiktoken_encoder() - - try: - content = file_path.read_text(encoding="utf-8") - - # Suppress SyntaxWarning from radon parsing third-party code with invalid escapes - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=SyntaxWarning) - blocks = cc_lib.cc_visit(content) - complexity = sum(block.complexity for block in blocks) - - hal = metrics_lib.h_visit(content) - mi = metrics_lib.mi_visit(content, multi=False) - tokens = len(encoder.encode(content, disallowed_special=())) - - return FileAnalysisResult( - path=str(file_path), - complexity=complexity, - volume=hal.total.volume, - difficulty=hal.total.difficulty, - effort=hal.total.effort, - mi=mi, - tokens=tokens, - ) - except (SyntaxError, UnicodeDecodeError, OSError, ValueError) as e: - return FileAnalysisResult( - path=str(file_path), - complexity=0, - volume=0.0, - difficulty=0.0, - effort=0.0, - mi=0.0, - tokens=0, - error=str(e), - ) +CALCULATOR_VERSION = "2024.1.5" class ComplexityAnalyzer: - """Analyzes cognitive complexity of Python files using radon.""" + """Analyzes cognitive complexity of source files using rust-code-analysis.""" def __init__(self, working_directory: Path | None = None): """Initialize the analyzer. @@ -93,6 +32,14 @@ def __init__(self, working_directory: Path | None = None): working_directory: Directory to analyze. Defaults to current working directory. """ self.working_directory = working_directory or Path.cwd() + self._code_analyzer: CodeAnalyzer | None = None + + @property + def code_analyzer(self) -> CodeAnalyzer: + """Get the code analyzer, creating it on first access.""" + if self._code_analyzer is None: + self._code_analyzer = CodeAnalyzer() + return self._code_analyzer def analyze_complexity(self) -> ComplexityMetrics: """Analyze complexity of Python files in the working directory. @@ -126,7 +73,7 @@ def analyze_complexity_with_baseline(self, baseline_dir: Path) -> tuple[Complexi return current_metrics, ComplexityDelta() def _analyze_directory(self, directory: Path) -> ComplexityMetrics: - """Analyze complexity of Python files in a specific directory. + """Analyze complexity of source files in a specific directory. Files with syntax errors or encoding issues are silently skipped. @@ -136,56 +83,39 @@ def _analyze_directory(self, directory: Path) -> ComplexityMetrics: Returns: ComplexityMetrics with aggregated complexity data. """ - import radon.complexity as cc_lib - from slopometry.core.git_tracker import GitTracker tracker = GitTracker(directory) - python_files = tracker.get_tracked_python_files() + python_files = [f for f in tracker.get_tracked_python_files() if f.exists()] + rust_files = [f for f in tracker.get_tracked_rust_files() if f.exists()] + all_files = python_files + rust_files - encoder = _get_tiktoken_encoder() + results = self.code_analyzer.analyze_files(all_files) files_by_complexity: dict[str, int] = {} all_complexities: list[int] = [] - files_by_token_count: dict[str, int] = {} all_token_counts: list[int] = [] - for file_path in python_files: - if not file_path.exists(): + for result in results: + if result.error: + logger.debug(f"Skipping file with error {result.path}: {result.error}") continue - try: - content = file_path.read_text(encoding="utf-8") - - # Suppress SyntaxWarning from radon parsing third-party code - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=SyntaxWarning) - blocks = cc_lib.cc_visit(content) - file_complexity = sum(block.complexity for block in blocks) - - relative_path = self._get_relative_path(file_path, directory) - files_by_complexity[relative_path] = file_complexity - all_complexities.append(file_complexity) - - token_count = len(encoder.encode(content, disallowed_special=())) - files_by_token_count[relative_path] = token_count - all_token_counts.append(token_count) - - except (SyntaxError, UnicodeDecodeError, OSError) as e: - logger.debug(f"Skipping unparseable file {file_path}: {e}") - continue + relative_path = self._get_relative_path(result.path, directory) + files_by_complexity[relative_path] = result.complexity + all_complexities.append(result.complexity) + files_by_token_count[relative_path] = result.tokens + all_token_counts.append(result.tokens) total_files = len(all_complexities) total_complexity = sum(all_complexities) - total_tokens = sum(all_token_counts) if total_files > 0: average_complexity = total_complexity / total_files max_complexity = max(all_complexities) min_complexity = min(all_complexities) - average_tokens = total_tokens / total_files max_tokens = max(all_token_counts) min_tokens = min(all_token_counts) @@ -193,7 +123,6 @@ def _analyze_directory(self, directory: Path) -> ComplexityMetrics: average_complexity = 0.0 max_complexity = 0 min_complexity = 0 - average_tokens = 0.0 max_tokens = 0 min_tokens = 0 @@ -212,61 +141,6 @@ def _analyze_directory(self, directory: Path) -> ComplexityMetrics: files_by_token_count=files_by_token_count, ) - def _process_radon_output(self, radon_data: dict[str, Any], reference_dir: Path | None = None) -> ComplexityMetrics: - """Process radon JSON output into ComplexityMetrics. - - Files with parse errors (e.g., Python version mismatch) are tracked separately. - - Args: - radon_data: Raw JSON data from radon - reference_dir: Reference directory for path calculation (defaults to working_directory) - - Returns: - Processed ComplexityMetrics - """ - files_by_complexity = {} - all_complexities = [] - files_with_parse_errors: dict[str, str] = {} - - reference_directory = reference_dir or self.working_directory - - for file_path, functions in radon_data.items(): - if not functions: - continue - - if isinstance(functions, dict) and "error" in functions: - relative_path = self._get_relative_path(file_path, reference_directory) - files_with_parse_errors[relative_path] = functions.get("error", "Unknown parse error") - continue - - file_complexity = sum(func.get("complexity", 0) for func in functions) - - relative_path = self._get_relative_path(file_path, reference_directory) - files_by_complexity[relative_path] = file_complexity - all_complexities.append(file_complexity) - - total_files = len(all_complexities) - total_complexity = sum(all_complexities) - - if total_files > 0: - average_complexity = total_complexity / total_files - max_complexity = max(all_complexities) - min_complexity = min(all_complexities) - else: - average_complexity = 0.0 - max_complexity = 0 - min_complexity = 0 - - return ComplexityMetrics( - total_files_analyzed=total_files, - total_complexity=total_complexity, - average_complexity=average_complexity, - max_complexity=max_complexity, - min_complexity=min_complexity, - files_by_complexity=files_by_complexity, - files_with_parse_errors=files_with_parse_errors, - ) - def _calculate_delta( self, baseline_metrics: ComplexityMetrics | ExtendedComplexityMetrics, @@ -414,11 +288,11 @@ def _get_relative_path(self, file_path: str | Path, reference_dir: Path | None = def _analyze_files_parallel( self, files: list[Path], max_workers: int | None = None - ) -> list[FileAnalysisResult | None]: + ) -> list[FileAnalysisResult]: """Analyze files in parallel using ProcessPoolExecutor. Args: - files: List of Python file paths to analyze + files: List of source file paths to analyze max_workers: Maximum number of worker processes (default from settings) Returns: @@ -427,10 +301,10 @@ def _analyze_files_parallel( if max_workers is None: max_workers = min(os.cpu_count() or 4, settings.max_parallel_workers) - results: list[FileAnalysisResult | None] = [] + results: list[FileAnalysisResult] = [] with ProcessPoolExecutor(max_workers=max_workers) as executor: - futures = {executor.submit(_analyze_single_file_extended, fp): fp for fp in files} + futures = {executor.submit(_analyze_single_file, fp): fp for fp in files} for future in as_completed(futures): try: @@ -439,18 +313,27 @@ def _analyze_files_parallel( except Exception as e: file_path = futures[future] logger.warning(f"Failed to analyze {file_path}: {e}") - results.append(None) + results.append(FileAnalysisResult( + path=str(file_path), + complexity=0, + volume=0.0, + difficulty=0.0, + effort=0.0, + mi=0.0, + tokens=0, + error=str(e), + )) return results def analyze_extended_complexity(self, directory: Path | None = None) -> ExtendedComplexityMetrics: - """Analyze with CC, Halstead, and MI metrics. + """Analyze with CC, Halstead, and MI metrics for all supported languages. Args: directory: Directory to analyze. Defaults to working_directory. Returns: - ExtendedComplexityMetrics with all radon metrics. + ExtendedComplexityMetrics with metrics from all supported languages. """ target_dir = directory or self.working_directory @@ -458,13 +341,15 @@ def analyze_extended_complexity(self, directory: Path | None = None) -> Extended tracker = GitTracker(target_dir) python_files = [f for f in tracker.get_tracked_python_files() if f.exists()] + rust_files = [f for f in tracker.get_tracked_rust_files() if f.exists()] + all_files = python_files + rust_files start_total = time.perf_counter() - if len(python_files) >= settings.parallel_file_threshold: - results = self._analyze_files_parallel(python_files) + if len(all_files) >= settings.parallel_file_threshold: + results = self._analyze_files_parallel(all_files) else: - results = [_analyze_single_file_extended(fp) for fp in python_files] + results = self.code_analyzer.analyze_files(all_files) files_by_complexity: dict[str, int] = {} files_by_effort: dict[str, float] = {} @@ -508,8 +393,10 @@ def analyze_extended_complexity(self, directory: Path | None = None) -> Extended all_token_counts.append(result.tokens) elapsed_total = time.perf_counter() - start_total - mode = "parallel" if len(python_files) >= settings.parallel_file_threshold else "sequential" - logger.debug(f"Complexity analysis ({mode}): {len(python_files)} files in {elapsed_total:.2f}s") + mode = "parallel" if len(all_files) >= settings.parallel_file_threshold else "sequential" + logger.debug( + f"Complexity analysis ({mode}): {len(all_files)} files ({len(python_files)} Python + {len(rust_files)} Rust) in {elapsed_total:.2f}s" + ) feature_analyzer = PythonFeatureAnalyzer() diff --git a/src/slopometry/core/git_tracker.py b/src/slopometry/core/git_tracker.py index 8b1a022..86900a4 100644 --- a/src/slopometry/core/git_tracker.py +++ b/src/slopometry/core/git_tracker.py @@ -252,6 +252,69 @@ def _find_python_files_fallback(self) -> list[Path]: return files + def get_tracked_rust_files(self) -> list[Path]: + """Get list of Rust files tracked by git or not ignored (if untracked). + + For non-git directories, falls back to finding all Rust files while + excluding common build directories. + + Returns: + List of Path objects for Rust files + + Raises: + GitOperationError: If inside a git repo but git command fails + """ + try: + cmd = ["git", "ls-files", "--cached", "--others", "--exclude-standard"] + result = subprocess.run( + cmd, + cwd=self.working_dir, + capture_output=True, + text=True, + timeout=30, + ) + + if result.returncode == 0: + files = [] + for line in result.stdout.splitlines(): + if line.endswith(".rs"): + files.append(self.working_dir / line) + return files + + # Check if this is a "not a git repo" error vs actual failure + stderr = result.stderr.strip().lower() + if "not a git repository" in stderr: + # Not a git repo - fall back to finding Rust files directly + return self._find_rust_files_fallback() + + # Actual git failure in a git repo + raise GitOperationError(f"git ls-files failed: {result.stderr.strip()}") + + except subprocess.TimeoutExpired as e: + raise GitOperationError(f"git ls-files timed out: {e}") from e + except FileNotFoundError as e: + raise GitOperationError(f"git not found - is git installed? {e}") from e + except subprocess.SubprocessError as e: + raise GitOperationError(f"git ls-files failed: {e}") from e + + def _find_rust_files_fallback(self) -> list[Path]: + """Find Rust files without git (for non-git directories).""" + ignored_dirs = { + "target", # Cargo build output + ".cargo", # Cargo cache + ".git", + "node_modules", + } + + files = [] + for file_path in self.working_dir.rglob("*.rs"): + parts = file_path.relative_to(self.working_dir).parts + if any(part in ignored_dirs for part in parts): + continue + files.append(file_path) + + return files + def extract_files_from_commit(self, commit_ref: str = "HEAD~1") -> Path | None: """Extract Python files and coverage.xml from a specific commit to a temporary directory. diff --git a/src/slopometry/core/language_config.py b/src/slopometry/core/language_config.py index 934b231..d1809f5 100644 --- a/src/slopometry/core/language_config.py +++ b/src/slopometry/core/language_config.py @@ -81,18 +81,24 @@ def matches_extension(self, file_path: Path | str) -> bool: ), ) -# Future language configs can be added here: -# RUST_CONFIG = LanguageConfig( -# language=ProjectLanguage.RUST, -# extensions=(".rs",), -# git_patterns=("*.rs",), -# ignore_dirs=("target",), -# test_patterns=("*_test.rs", "tests/**/*.rs"), -# ) +RUST_CONFIG = LanguageConfig( + language=ProjectLanguage.RUST, + extensions=(".rs",), + git_patterns=("*.rs",), + ignore_dirs=( + "target", # Cargo build output + ".cargo", # Cargo cache + ), + test_patterns=( + "*_test.rs", + "tests/**/*.rs", + ), +) # Registry mapping ProjectLanguage to its config LANGUAGE_CONFIGS: dict[ProjectLanguage, LanguageConfig] = { ProjectLanguage.PYTHON: PYTHON_CONFIG, + ProjectLanguage.RUST: RUST_CONFIG, } diff --git a/src/slopometry/core/language_detector.py b/src/slopometry/core/language_detector.py index 2598c44..5a0b7fe 100644 --- a/src/slopometry/core/language_detector.py +++ b/src/slopometry/core/language_detector.py @@ -11,12 +11,11 @@ # Map file extensions to supported ProjectLanguage EXTENSION_MAP: dict[str, ProjectLanguage] = { ".py": ProjectLanguage.PYTHON, - # ".rs": ProjectLanguage.RUST, # Future: Add when rust analyzer ready + ".rs": ProjectLanguage.RUST, } # Extensions we recognize but don't support yet (for explicit warnings) KNOWN_UNSUPPORTED_EXTENSIONS: dict[str, str] = { - ".rs": "Rust", ".go": "Go", ".ts": "TypeScript", ".tsx": "TypeScript", diff --git a/src/slopometry/core/models.py b/src/slopometry/core/models.py index 5ab01af..8c0e757 100644 --- a/src/slopometry/core/models.py +++ b/src/slopometry/core/models.py @@ -199,6 +199,7 @@ class ProjectLanguage(str, Enum): """Supported languages for complexity analysis.""" PYTHON = "python" + RUST = "rust" class ProjectSource(str, Enum): diff --git a/src/slopometry/core/tokenizer.py b/src/slopometry/core/tokenizer.py new file mode 100644 index 0000000..f30a711 --- /dev/null +++ b/src/slopometry/core/tokenizer.py @@ -0,0 +1,60 @@ +"""Token counting utilities using tiktoken.""" + +import logging +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +_encoder: Any = None + + +def get_encoder() -> Any: + """Get tiktoken encoder, caching for reuse. + + Returns: + tiktoken Encoder for token counting + """ + global _encoder + if _encoder is not None: + return _encoder + + import tiktoken + + try: + _encoder = tiktoken.get_encoding("o200k_base") + except Exception as e: + logger.debug(f"Falling back to cl100k_base encoding: {e}") + _encoder = tiktoken.get_encoding("cl100k_base") + + return _encoder + + +def count_tokens(content: str) -> int: + """Count tokens in text content. + + Args: + content: Text content to tokenize. + + Returns: + Number of tokens. + """ + encoder = get_encoder() + return len(encoder.encode(content, disallowed_special=())) + + +def count_file_tokens(file_path: Path) -> int: + """Count tokens in a file. + + Args: + file_path: Path to the file to tokenize. + + Returns: + Number of tokens, or 0 if file cannot be read. + """ + try: + content = file_path.read_text(encoding="utf-8") + return count_tokens(content) + except Exception as e: + logger.warning("Failed to read file for token counting %s: %s", file_path, e) + return 0 diff --git a/tests/test_cli.py b/tests/test_cli.py index 75d0871..f7ecc29 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,8 +3,9 @@ from unittest.mock import patch import pytest +from click.testing import CliRunner -from slopometry.cli import check_slopometry_in_path, warn_if_not_in_path +from slopometry.cli import check_slopometry_in_path, cli, get_version, warn_if_not_in_path def test_check_slopometry_in_path__returns_true_when_executable_found() -> None: @@ -30,3 +31,17 @@ def test_warn_if_not_in_path__silent_when_in_path(capsys: pytest.CaptureFixture[ warn_if_not_in_path() captured = capsys.readouterr() assert captured.out == "" + + +def test_get_version__returns_version_string() -> None: + version = get_version() + assert isinstance(version, str) + assert len(version) > 0 + + +def test_cli_version__outputs_version() -> None: + runner = CliRunner() + result = runner.invoke(cli, ["--version"]) + assert result.exit_code == 0 + assert "slopometry" in result.output + assert "version" in result.output diff --git a/tests/test_code_analyzer.py b/tests/test_code_analyzer.py new file mode 100644 index 0000000..46bb7c6 --- /dev/null +++ b/tests/test_code_analyzer.py @@ -0,0 +1,149 @@ +"""Tests for unified code analyzer using rust-code-analysis.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from slopometry.core.code_analyzer import ( + CodeAnalysisError, + CodeAnalysisNotInstalledError, + CodeAnalyzer, + SUPPORTED_EXTENSIONS, + _analyze_single_file, + _safe_float, +) +from slopometry.core.models import FileAnalysisResult + + +class TestSafeFloat: + """Tests for _safe_float helper function.""" + + def test_safe_float__returns_value_for_normal_float(self) -> None: + """Should return the value for normal floats.""" + assert _safe_float(3.14) == 3.14 + + def test_safe_float__returns_zero_for_none(self) -> None: + """Should return 0.0 for None.""" + assert _safe_float(None) == 0.0 + + def test_safe_float__returns_zero_for_nan(self) -> None: + """Should return 0.0 for NaN.""" + assert _safe_float(float("nan")) == 0.0 + + def test_safe_float__returns_zero_for_inf(self) -> None: + """Should return 0.0 for infinity.""" + assert _safe_float(float("inf")) == 0.0 + assert _safe_float(float("-inf")) == 0.0 + + +class TestSupportedExtensions: + """Tests for supported extensions constant.""" + + def test_supported_extensions__includes_python(self) -> None: + """Should include Python extension.""" + assert ".py" in SUPPORTED_EXTENSIONS + + def test_supported_extensions__includes_rust(self) -> None: + """Should include Rust extension.""" + assert ".rs" in SUPPORTED_EXTENSIONS + + def test_supported_extensions__includes_javascript(self) -> None: + """Should include JavaScript extensions.""" + assert ".js" in SUPPORTED_EXTENSIONS + assert ".jsx" in SUPPORTED_EXTENSIONS + + def test_supported_extensions__includes_typescript(self) -> None: + """Should include TypeScript extensions.""" + assert ".ts" in SUPPORTED_EXTENSIONS + assert ".tsx" in SUPPORTED_EXTENSIONS + + +class TestCodeAnalyzer: + """Tests for CodeAnalyzer class.""" + + def test_init__raises_when_rca_not_installed(self) -> None: + """Should raise CodeAnalysisNotInstalledError when rust_code_analysis not installed.""" + with patch.dict("sys.modules", {"rust_code_analysis": None}): + with patch( + "slopometry.core.code_analyzer._check_rca_installed", + side_effect=CodeAnalysisNotInstalledError("Not installed"), + ): + with pytest.raises(CodeAnalysisNotInstalledError): + CodeAnalyzer() + + def test_is_supported__returns_true_for_python(self) -> None: + """Should return True for Python files.""" + analyzer = CodeAnalyzer() + assert analyzer.is_supported(Path("test.py")) + + def test_is_supported__returns_true_for_rust(self) -> None: + """Should return True for Rust files.""" + analyzer = CodeAnalyzer() + assert analyzer.is_supported(Path("test.rs")) + + def test_is_supported__returns_false_for_unsupported(self) -> None: + """Should return False for unsupported extensions.""" + analyzer = CodeAnalyzer() + assert not analyzer.is_supported(Path("test.txt")) + assert not analyzer.is_supported(Path("test.md")) + + def test_analyze_file__returns_result_for_valid_python(self, tmp_path: Path) -> None: + """Should return FileAnalysisResult for valid Python file.""" + test_file = tmp_path / "test.py" + test_file.write_text("def foo(): pass") + + analyzer = CodeAnalyzer() + result = analyzer.analyze_file(test_file) + + assert result.path == str(test_file) + assert result.error is None + assert isinstance(result.complexity, int) + assert isinstance(result.tokens, int) + + def test_analyze_file__returns_error_for_missing_file(self, tmp_path: Path) -> None: + """Should return result with error for missing file.""" + missing_file = tmp_path / "missing.py" + + analyzer = CodeAnalyzer() + result = analyzer.analyze_file(missing_file) + + assert result.path == str(missing_file) + assert result.error is not None + + def test_analyze_files__returns_results_for_multiple_files(self, tmp_path: Path) -> None: + """Should return list of results for multiple files.""" + file1 = tmp_path / "a.py" + file2 = tmp_path / "b.py" + file1.write_text("x = 1") + file2.write_text("y = 2") + + analyzer = CodeAnalyzer() + results = analyzer.analyze_files([file1, file2]) + + assert len(results) == 2 + assert all(isinstance(r, FileAnalysisResult) for r in results) + + +class TestAnalyzeSingleFile: + """Tests for module-level _analyze_single_file function.""" + + def test_analyze_single_file__returns_result(self, tmp_path: Path) -> None: + """Should return FileAnalysisResult for valid file.""" + test_file = tmp_path / "test.py" + test_file.write_text("def bar(): return 42") + + result = _analyze_single_file(test_file) + + assert result.path == str(test_file) + assert result.error is None + + def test_analyze_single_file__handles_error(self, tmp_path: Path) -> None: + """Should return result with error for invalid file.""" + missing_file = tmp_path / "missing.py" + + result = _analyze_single_file(missing_file) + + assert result.path == str(missing_file) + assert result.error is not None + assert result.complexity == 0 diff --git a/tests/test_complexity_analyzer.py b/tests/test_complexity_analyzer.py index 430a5a0..9148057 100644 --- a/tests/test_complexity_analyzer.py +++ b/tests/test_complexity_analyzer.py @@ -3,6 +3,7 @@ import pytest from slopometry.core.complexity_analyzer import ComplexityAnalyzer +from slopometry.core.models import FileAnalysisResult @pytest.fixture @@ -14,27 +15,38 @@ def test_analyze_directory_aggregation(mock_path): """Test standard complexity analysis aggregation.""" analyzer = ComplexityAnalyzer(working_directory=mock_path) - with ( - patch("slopometry.core.git_tracker.GitTracker") as MockTracker, - patch("radon.complexity.cc_visit") as mock_cc_visit, - ): - # Mock connection to GitTracker + mock_code_analyzer = MagicMock() + analyzer._code_analyzer = mock_code_analyzer + + with patch("slopometry.core.git_tracker.GitTracker") as MockTracker: mock_tracker_instance = MockTracker.return_value file1 = mock_path / "a.py" file2 = mock_path / "b.py" file1.touch() file2.touch() mock_tracker_instance.get_tracked_python_files.return_value = [file1, file2] - - # Mock radon results - # File 1: Two blocks, complexity 5 and 3 -> Total 8 - block1 = MagicMock(complexity=5) - block2 = MagicMock(complexity=3) - - # File 2: One block, complexity 2 -> Total 2 - block3 = MagicMock(complexity=2) - - mock_cc_visit.side_effect = [[block1, block2], [block3]] + mock_tracker_instance.get_tracked_rust_files.return_value = [] + + mock_code_analyzer.analyze_files.return_value = [ + FileAnalysisResult( + path=str(file1), + complexity=8, + volume=100.0, + difficulty=5.0, + effort=500.0, + mi=80.0, + tokens=100, + ), + FileAnalysisResult( + path=str(file2), + complexity=2, + volume=50.0, + difficulty=2.0, + effort=100.0, + mi=90.0, + tokens=50, + ), + ] metrics = analyzer._analyze_directory(mock_path) @@ -49,32 +61,42 @@ def test_analyze_directory_syntax_error(mock_path): """Test handling of syntax errors during analysis.""" analyzer = ComplexityAnalyzer(working_directory=mock_path) - with ( - patch("slopometry.core.git_tracker.GitTracker") as MockTracker, - patch("radon.complexity.cc_visit") as mock_cc_visit, - ): + mock_code_analyzer = MagicMock() + analyzer._code_analyzer = mock_code_analyzer + + with patch("slopometry.core.git_tracker.GitTracker") as MockTracker: mock_tracker_instance = MockTracker.return_value file1 = mock_path / "good.py" file2 = mock_path / "bad.py" file1.touch() file2.touch() mock_tracker_instance.get_tracked_python_files.return_value = [file1, file2] - - # Good file returns complexity 5 - block1 = MagicMock(complexity=5) - - # Bad file raises SyntaxError - def side_effect(content): - if content == "GOOD": - return [block1] - raise SyntaxError("Invalid syntax") - - mock_cc_visit.side_effect = [[block1], SyntaxError("Fail")] + mock_tracker_instance.get_tracked_rust_files.return_value = [] + + mock_code_analyzer.analyze_files.return_value = [ + FileAnalysisResult( + path=str(file1), + complexity=5, + volume=100.0, + difficulty=5.0, + effort=500.0, + mi=80.0, + tokens=100, + ), + FileAnalysisResult( + path=str(file2), + complexity=0, + volume=0.0, + difficulty=0.0, + effort=0.0, + mi=0.0, + tokens=0, + error="SyntaxError: Invalid syntax", + ), + ] metrics = analyzer._analyze_directory(mock_path) - # Should only count the valid file - # Note: current implementation skips errors in aggregation assert metrics.total_files_analyzed == 1 assert metrics.total_complexity == 5 @@ -83,69 +105,84 @@ def test_analyze_extended_complexity(mock_path): """Test extended complexity analysis (CC + Halstead + MI).""" analyzer = ComplexityAnalyzer(working_directory=mock_path) + mock_code_analyzer = MagicMock() + analyzer._code_analyzer = mock_code_analyzer + with ( patch("slopometry.core.git_tracker.GitTracker") as MockTracker, - patch("radon.complexity.cc_visit") as mock_cc, - patch("radon.metrics.h_visit") as mock_hal, - patch("radon.metrics.mi_visit") as mock_mi, patch("slopometry.core.complexity_analyzer.PythonFeatureAnalyzer") as MockFeatureAnalyzer, ): - # Mock Tracker mock_tracker_instance = MockTracker.return_value file1 = mock_path / "f.py" file1.touch() mock_tracker_instance.get_tracked_python_files.return_value = [file1] + mock_tracker_instance.get_tracked_rust_files.return_value = [] + + mock_code_analyzer.analyze_files.return_value = [ + FileAnalysisResult( + path=str(file1), + complexity=10, + volume=100.0, + difficulty=5.0, + effort=500.0, + mi=80.0, + tokens=100, + ), + ] - # Mock CC (Complexity 10) - mock_cc.return_value = [MagicMock(complexity=10)] - - # Mock Halstead - mock_h_data = MagicMock() - mock_h_data.total.volume = 100.0 - mock_h_data.total.difficulty = 5.0 - mock_h_data.total.effort = 500.0 - mock_hal.return_value = mock_h_data - - # Mock MI - mock_mi.return_value = 80.0 - - # Mock Feature Analyzer mock_features = MockFeatureAnalyzer.return_value mock_feature_stats = MagicMock() mock_feature_stats.deprecations_count = 3 mock_feature_stats.docstrings_count = 5 mock_feature_stats.functions_count = 5 mock_feature_stats.classes_count = 0 - - # Mock coverage stats for division mock_feature_stats.args_count = 10 mock_feature_stats.returns_count = 10 mock_feature_stats.annotated_args_count = 5 mock_feature_stats.annotated_returns_count = 5 - # (5+5)/(10+10) = 50% - - # Mock type reference tracking mock_feature_stats.total_type_references = 20 - mock_feature_stats.any_type_count = 2 # 10% Any usage - mock_feature_stats.str_type_count = 5 # 25% str usage - + mock_feature_stats.any_type_count = 2 + mock_feature_stats.str_type_count = 5 + mock_feature_stats.orphan_comment_count = 0 + mock_feature_stats.orphan_comment_files = [] + mock_feature_stats.untracked_todo_count = 0 + mock_feature_stats.untracked_todo_files = [] + mock_feature_stats.inline_import_count = 0 + mock_feature_stats.inline_import_files = [] + mock_feature_stats.dict_get_with_default_count = 0 + mock_feature_stats.dict_get_with_default_files = [] + mock_feature_stats.hasattr_getattr_count = 0 + mock_feature_stats.hasattr_getattr_files = [] + mock_feature_stats.nonempty_init_count = 0 + mock_feature_stats.nonempty_init_files = [] + mock_feature_stats.test_skip_count = 0 + mock_feature_stats.test_skip_files = [] + mock_feature_stats.swallowed_exception_count = 0 + mock_feature_stats.swallowed_exception_files = [] + mock_feature_stats.type_ignore_count = 0 + mock_feature_stats.type_ignore_files = [] + mock_feature_stats.dynamic_execution_count = 0 + mock_feature_stats.dynamic_execution_files = [] + mock_feature_stats.single_method_class_count = 0 + mock_feature_stats.single_method_class_files = [] + mock_feature_stats.deep_inheritance_count = 0 + mock_feature_stats.deep_inheritance_files = [] + mock_feature_stats.passthrough_wrapper_count = 0 + mock_feature_stats.passthrough_wrapper_files = [] + mock_feature_stats.total_loc = 100 + mock_feature_stats.code_loc = 80 mock_features.analyze_directory.return_value = mock_feature_stats metrics = analyzer.analyze_extended_complexity(mock_path) - # Checks assert metrics.total_complexity == 10 assert metrics.average_volume == 100.0 assert metrics.average_effort == 500.0 assert metrics.average_mi == 80.0 assert metrics.deprecation_count == 3 assert metrics.type_hint_coverage == 50.0 - - # New type percentage checks - assert metrics.any_type_percentage == 10.0 # 2/20 * 100 - assert metrics.str_type_percentage == 25.0 # 5/20 * 100 - - # Per-file metrics should be populated + assert metrics.any_type_percentage == 10.0 + assert metrics.str_type_percentage == 25.0 assert "f.py" in metrics.files_by_effort assert metrics.files_by_effort["f.py"] == 500.0 assert "f.py" in metrics.files_by_mi diff --git a/tests/test_language_guard.py b/tests/test_language_guard.py index b0ca379..0921ab1 100644 --- a/tests/test_language_guard.py +++ b/tests/test_language_guard.py @@ -30,7 +30,7 @@ def test_detect_languages__detects_python_from_git_tracked_files(self, tmp_path: assert len(unsupported) == 0 def test_detect_languages__reports_unsupported_languages(self, tmp_path: Path) -> None: - """Should report unsupported languages like Rust, Go, TypeScript.""" + """Should report unsupported languages like Go, TypeScript (but Rust is now supported).""" # Create a git repo with mixed files subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True) (tmp_path / "main.rs").write_text("fn main() {}") @@ -41,8 +41,7 @@ def test_detect_languages__reports_unsupported_languages(self, tmp_path: Path) - detector = LanguageDetector(tmp_path) supported, unsupported = detector.detect_languages() - assert len(supported) == 0 # No Python - assert "Rust" in unsupported + assert ProjectLanguage.RUST in supported # Rust is now supported assert "Go" in unsupported assert "TypeScript" in unsupported @@ -71,13 +70,15 @@ def test_detect_languages__mixed_supported_and_unsupported(self, tmp_path: Path) subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True) (tmp_path / "main.py").write_text("print('hello')") (tmp_path / "lib.rs").write_text("pub fn foo() {}") + (tmp_path / "app.go").write_text("package main") # Go is still unsupported subprocess.run(["git", "add", "."], cwd=tmp_path, capture_output=True) detector = LanguageDetector(tmp_path) supported, unsupported = detector.detect_languages() assert ProjectLanguage.PYTHON in supported - assert "Rust" in unsupported + assert ProjectLanguage.RUST in supported # Rust is now supported + assert "Go" in unsupported class TestCheckLanguageSupport: @@ -99,6 +100,7 @@ def test_check_language_support__not_allowed_when_python_missing(self, tmp_path: """Should return allowed=False when required Python is not detected.""" subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True) (tmp_path / "main.rs").write_text("fn main() {}") + (tmp_path / "app.go").write_text("package main") # Go is still unsupported subprocess.run(["git", "add", "."], cwd=tmp_path, capture_output=True) result = check_language_support(tmp_path, ProjectLanguage.PYTHON) @@ -106,7 +108,8 @@ def test_check_language_support__not_allowed_when_python_missing(self, tmp_path: assert result.allowed is False assert result.required_language == ProjectLanguage.PYTHON assert ProjectLanguage.PYTHON not in result.detected_supported - assert "Rust" in result.detected_unsupported + assert ProjectLanguage.RUST in result.detected_supported # Rust is now supported + assert "Go" in result.detected_unsupported class TestLanguageGuardResult: @@ -169,8 +172,10 @@ def test_extension_map__contains_python(self) -> None: assert EXTENSION_MAP[".py"] == ProjectLanguage.PYTHON def test_known_unsupported__contains_common_languages(self) -> None: - """Common languages should be in unsupported map.""" - assert ".rs" in KNOWN_UNSUPPORTED_EXTENSIONS + """Common unsupported languages should be in unsupported map (Rust is now supported).""" + assert ".rs" not in KNOWN_UNSUPPORTED_EXTENSIONS # Rust is now supported + assert ".rs" in EXTENSION_MAP # Rust should be in supported map + assert EXTENSION_MAP[".rs"] == ProjectLanguage.RUST assert ".go" in KNOWN_UNSUPPORTED_EXTENSIONS assert ".ts" in KNOWN_UNSUPPORTED_EXTENSIONS assert ".js" in KNOWN_UNSUPPORTED_EXTENSIONS diff --git a/tests/test_tokenizer.py b/tests/test_tokenizer.py new file mode 100644 index 0000000..2ae89ef --- /dev/null +++ b/tests/test_tokenizer.py @@ -0,0 +1,77 @@ +"""Tests for tokenizer module.""" + +from pathlib import Path +from unittest.mock import patch + +from slopometry.core.tokenizer import count_file_tokens, count_tokens, get_encoder + + +class TestGetEncoder: + """Tests for get_encoder function.""" + + def test_get_encoder__returns_encoder(self) -> None: + """Should return a tiktoken encoder.""" + encoder = get_encoder() + assert encoder is not None + # Verify it can encode text (functional test rather than hasattr check) + tokens = encoder.encode("test") + assert isinstance(tokens, list) + + def test_get_encoder__caches_encoder(self) -> None: + """Should return the same encoder instance on subsequent calls.""" + encoder1 = get_encoder() + encoder2 = get_encoder() + assert encoder1 is encoder2 + + +class TestCountTokens: + """Tests for count_tokens function.""" + + def test_count_tokens__counts_simple_text(self) -> None: + """Should count tokens in simple text.""" + result = count_tokens("hello world") + assert result > 0 + + def test_count_tokens__empty_string_returns_zero(self) -> None: + """Should return 0 for empty string.""" + result = count_tokens("") + assert result == 0 + + def test_count_tokens__handles_code(self) -> None: + """Should count tokens in code.""" + code = """ +def hello(): + print("world") +""" + result = count_tokens(code) + assert result > 0 + + +class TestCountFileTokens: + """Tests for count_file_tokens function.""" + + def test_count_file_tokens__reads_and_counts(self, tmp_path: Path) -> None: + """Should read file and count tokens.""" + test_file = tmp_path / "test.py" + test_file.write_text("def foo(): pass") + + result = count_file_tokens(test_file) + assert result > 0 + + def test_count_file_tokens__missing_file_returns_zero(self, tmp_path: Path) -> None: + """Should return 0 for missing file.""" + missing_file = tmp_path / "missing.py" + + result = count_file_tokens(missing_file) + assert result == 0 + + def test_count_file_tokens__unreadable_file_returns_zero( + self, tmp_path: Path + ) -> None: + """Should return 0 when file cannot be read.""" + test_file = tmp_path / "test.py" + test_file.write_text("content") + + with patch.object(Path, "read_text", side_effect=PermissionError("denied")): + result = count_file_tokens(test_file) + assert result == 0 diff --git a/uv.lock b/uv.lock index 6bc70c3..31bb2e0 100644 --- a/uv.lock +++ b/uv.lock @@ -1299,18 +1299,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7d/5e/db903ce9cf82c48d6b91bf6d63ae4c8d0d17958939a4e04ba6b9f38b8643/lupa-2.6-cp314-cp314t-win_amd64.whl", hash = "sha256:fc1498d1a4fc028bc521c26d0fad4ca00ed63b952e32fb95949bda76a04bad52", size = 1913818, upload-time = "2025-10-24T07:19:36.039Z" }, ] -[[package]] -name = "mando" -version = "0.7.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/35/24/cd70d5ae6d35962be752feccb7dca80b5e0c2d450e995b16abd6275f3296/mando-0.7.1.tar.gz", hash = "sha256:18baa999b4b613faefb00eac4efadcf14f510b59b924b66e08289aa1de8c3500", size = 37868, upload-time = "2022-02-24T08:12:27.316Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/f0/834e479e47e499b6478e807fb57b31cc2db696c4db30557bb6f5aea4a90b/mando-0.7.1-py2.py3-none-any.whl", hash = "sha256:26ef1d70928b6057ee3ca12583d73c63e05c49de8972d620c278a7b206581a8a", size = 28149, upload-time = "2022-02-24T08:12:25.24Z" }, -] - [[package]] name = "markdown-it-py" version = "4.0.0" @@ -2461,19 +2449,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] -[[package]] -name = "radon" -version = "6.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama" }, - { name = "mando" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/6d/98e61600febf6bd929cf04154537c39dc577ce414bafbfc24a286c4fa76d/radon-6.0.1.tar.gz", hash = "sha256:d1ac0053943a893878940fedc8b19ace70386fc9c9bf0a09229a44125ebf45b5", size = 1874992, upload-time = "2023-03-26T06:24:38.868Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/93/f7/d00d9b4a0313a6be3a3e0818e6375e15da6d7076f4ae47d1324e7ca986a1/radon-6.0.1-py2.py3-none-any.whl", hash = "sha256:632cc032364a6f8bb1010a2f6a12d0f14bc7e5ede76585ef29dc0cecf4cd8859", size = 52784, upload-time = "2023-03-26T06:24:33.949Z" }, -] - [[package]] name = "redis" version = "7.1.0" @@ -2705,6 +2680,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/26/09/7a9520315decd2334afa65ed258fed438f070e31f05a2e43dd480a5e5911/ruff-0.14.9-py3-none-win_arm64.whl", hash = "sha256:8e821c366517a074046d92f0e9213ed1c13dbc5b37a7fc20b07f79b64d62cc84", size = 13744730, upload-time = "2025-12-11T21:39:29.659Z" }, ] +[[package]] +name = "rust-code-analysis" +version = "2026.1.31" +source = { registry = "https://github.com/Droidcraft/rust-code-analysis/releases/expanded_assets/python-2026.1.31" } +sdist = { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31.tar.gz" } +wheels = [ + { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31-cp313-cp313-macosx_10_12_x86_64.whl" }, + { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31-cp313-cp313-macosx_11_0_arm64.whl" }, + { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31-cp313-cp313t-macosx_10_12_x86_64.whl" }, + { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31-cp313-cp313t-macosx_11_0_arm64.whl" }, + { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31-cp314-cp314-macosx_10_12_x86_64.whl" }, + { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31-cp314-cp314-macosx_11_0_arm64.whl" }, + { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31-cp314-cp314t-macosx_10_12_x86_64.whl" }, + { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31-cp314-cp314t-macosx_11_0_arm64.whl" }, + { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" }, + { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" }, + { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" }, + { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" }, + { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" }, + { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" }, + { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" }, + { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" }, +] + [[package]] name = "s3transfer" version = "0.16.0" @@ -2750,7 +2749,7 @@ wheels = [ [[package]] name = "slopometry" -version = "20260121.post2" +version = "2026.1.25.2" source = { editable = "." } dependencies = [ { name = "click" }, @@ -2762,8 +2761,8 @@ dependencies = [ { name = "pydantic" }, { name = "pydantic-ai" }, { name = "pydantic-settings" }, - { name = "radon" }, { name = "rich" }, + { name = "rust-code-analysis" }, { name = "sqlite-utils" }, { name = "tiktoken" }, { name = "toml" }, @@ -2796,9 +2795,9 @@ requires-dist = [ { name = "pyrefly", marker = "extra == 'dev'", specifier = ">=0.46.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.5" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" }, - { name = "radon", specifier = ">=6.0.1" }, { name = "rich", specifier = ">=13.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.0.244" }, + { name = "rust-code-analysis", specifier = ">=2026.1.31" }, { name = "sqlite-utils", specifier = ">=3.0" }, { name = "tiktoken", specifier = ">=0.7.0" }, { name = "toml", specifier = ">=0.10.2" }, From 64f4343a7f48e3673ea25c25781e4cf3900d75a7 Mon Sep 17 00:00:00 2001 From: TensorTemplar Date: Sat, 31 Jan 2026 18:35:22 +0200 Subject: [PATCH 2/3] add stubs for prefly --- .coverage | Bin 53248 -> 53248 bytes coverage.xml | 205 ++++++++++---------- pyproject.toml | 2 +- src/slopometry/core/complexity_analyzer.py | 27 ++- src/slopometry/stubs/rust_code_analysis.pyi | 48 +++++ tests/test_code_analyzer.py | 5 +- tests/test_tokenizer.py | 4 +- uv.lock | 16 +- 8 files changed, 175 insertions(+), 132 deletions(-) create mode 100644 src/slopometry/stubs/rust_code_analysis.pyi diff --git a/.coverage b/.coverage index a5c32ea42cf2af9c95503336b2bd680baaae5372..7488cb5bf78cd238d113d92f7f30f444545a0120 100644 GIT binary patch delta 94 zcmZozz}&Ead4pHK|5f$_+y1({v#>I9HuACmV$I9HuACmVhb0`5f@-!;BZ*~ecx~9KlZW=2++Xr rPvn3uU&33)2Bs_7fB!DqQC|J3`ug18|AAn8?(W~)_&3k&Pj>(SL^>yZ diff --git a/coverage.xml b/coverage.xml index c48dde9..b33cba3 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,5 +1,5 @@ - + @@ -16,7 +16,7 @@ - + @@ -285,7 +285,7 @@ - + @@ -297,219 +297,218 @@ - - - - - + + + + + - + - + - - - - + + + + - - - - + + + + - - - + + + - - + + - + - + - + - + - + - - - + + + - + - + - + - + - + - - - - - + + + + + - + - + - - - + + + - - + + - + - - - - - - + + + + + + - + - - - + + + - + - - + + - - + + + - + + - - - + + + - - - - - - - + + + + + - - + + - - + + - + - + - - - - + + + + - + - + - + - + - + - - - + + + - + - + - - + + - + - + - - + - + @@ -718,7 +717,7 @@ - + diff --git a/pyproject.toml b/pyproject.toml index c33f7a5..2e645d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -127,7 +127,7 @@ precision = 2 directory = "htmlcov" [tool.pyrefly] -search_path = ["src", "tests"] +search_path = ["src", "tests", "src/slopometry/stubs"] [tool.uv] find-links = ["https://github.com/Droidcraft/rust-code-analysis/releases/expanded_assets/python-2026.1.31"] diff --git a/src/slopometry/core/complexity_analyzer.py b/src/slopometry/core/complexity_analyzer.py index 18a20b7..c5762b0 100644 --- a/src/slopometry/core/complexity_analyzer.py +++ b/src/slopometry/core/complexity_analyzer.py @@ -15,7 +15,6 @@ ) from slopometry.core.python_feature_analyzer import PythonFeatureAnalyzer, _count_loc from slopometry.core.settings import settings -from slopometry.core.tokenizer import count_file_tokens logger = logging.getLogger(__name__) @@ -286,9 +285,7 @@ def _get_relative_path(self, file_path: str | Path, reference_dir: Path | None = except (ValueError, OSError): return str(file_path) - def _analyze_files_parallel( - self, files: list[Path], max_workers: int | None = None - ) -> list[FileAnalysisResult]: + def _analyze_files_parallel(self, files: list[Path], max_workers: int | None = None) -> list[FileAnalysisResult]: """Analyze files in parallel using ProcessPoolExecutor. Args: @@ -313,16 +310,18 @@ def _analyze_files_parallel( except Exception as e: file_path = futures[future] logger.warning(f"Failed to analyze {file_path}: {e}") - results.append(FileAnalysisResult( - path=str(file_path), - complexity=0, - volume=0.0, - difficulty=0.0, - effort=0.0, - mi=0.0, - tokens=0, - error=str(e), - )) + results.append( + FileAnalysisResult( + path=str(file_path), + complexity=0, + volume=0.0, + difficulty=0.0, + effort=0.0, + mi=0.0, + tokens=0, + error=str(e), + ) + ) return results diff --git a/src/slopometry/stubs/rust_code_analysis.pyi b/src/slopometry/stubs/rust_code_analysis.pyi new file mode 100644 index 0000000..49b5165 --- /dev/null +++ b/src/slopometry/stubs/rust_code_analysis.pyi @@ -0,0 +1,48 @@ +"""Type stubs for rust-code-analysis Python bindings.""" + +from typing import Iterator + +class CyclomaticMetrics: + sum: float + average: float + max: float + +class HalsteadMetrics: + volume: float + difficulty: float + effort: float + bugs: float + length: int + vocabulary: int + n1: int + n2: int + N1: int + N2: int + +class MIMetrics: + mi_original: float + mi_sei: float + mi_visual_studio: float + +class Metrics: + cyclomatic: CyclomaticMetrics + halstead: HalsteadMetrics + mi: MIMetrics + sloc: int + ploc: int + lloc: int + cloc: int + blank: int + +class FunctionInfo: + name: str + start_line: int + end_line: int + metrics: Metrics + +class AnalysisResult: + metrics: Metrics + def get_functions(self) -> Iterator[FunctionInfo]: ... + +def analyze_file(path: str) -> AnalysisResult: ... +def analyze_code(code: str, language: str) -> AnalysisResult: ... diff --git a/tests/test_code_analyzer.py b/tests/test_code_analyzer.py index 46bb7c6..a7241e9 100644 --- a/tests/test_code_analyzer.py +++ b/tests/test_code_analyzer.py @@ -1,15 +1,14 @@ """Tests for unified code analyzer using rust-code-analysis.""" from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest from slopometry.core.code_analyzer import ( - CodeAnalysisError, + SUPPORTED_EXTENSIONS, CodeAnalysisNotInstalledError, CodeAnalyzer, - SUPPORTED_EXTENSIONS, _analyze_single_file, _safe_float, ) diff --git a/tests/test_tokenizer.py b/tests/test_tokenizer.py index 2ae89ef..7f3facd 100644 --- a/tests/test_tokenizer.py +++ b/tests/test_tokenizer.py @@ -65,9 +65,7 @@ def test_count_file_tokens__missing_file_returns_zero(self, tmp_path: Path) -> N result = count_file_tokens(missing_file) assert result == 0 - def test_count_file_tokens__unreadable_file_returns_zero( - self, tmp_path: Path - ) -> None: + def test_count_file_tokens__unreadable_file_returns_zero(self, tmp_path: Path) -> None: """Should return 0 when file cannot be read.""" test_file = tmp_path / "test.py" test_file.write_text("content") diff --git a/uv.lock b/uv.lock index 31bb2e0..8749b06 100644 --- a/uv.lock +++ b/uv.lock @@ -2686,22 +2686,22 @@ version = "2026.1.31" source = { registry = "https://github.com/Droidcraft/rust-code-analysis/releases/expanded_assets/python-2026.1.31" } sdist = { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31.tar.gz" } wheels = [ + { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31-cp313-cp313-manylinux_2_34_aarch64.whl" }, + { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31-cp313-cp313-manylinux_2_34_x86_64.whl" }, { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31-cp313-cp313-macosx_10_12_x86_64.whl" }, { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31-cp313-cp313-macosx_11_0_arm64.whl" }, + { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31-cp313-cp313t-manylinux_2_34_aarch64.whl" }, + { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31-cp313-cp313t-manylinux_2_34_x86_64.whl" }, { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31-cp313-cp313t-macosx_10_12_x86_64.whl" }, { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31-cp313-cp313t-macosx_11_0_arm64.whl" }, + { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31-cp314-cp314-manylinux_2_34_aarch64.whl" }, + { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31-cp314-cp314-manylinux_2_34_x86_64.whl" }, { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31-cp314-cp314-macosx_10_12_x86_64.whl" }, { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31-cp314-cp314-macosx_11_0_arm64.whl" }, + { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31-cp314-cp314t-manylinux_2_34_aarch64.whl" }, + { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31-cp314-cp314t-manylinux_2_34_x86_64.whl" }, { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31-cp314-cp314t-macosx_10_12_x86_64.whl" }, { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31-cp314-cp314t-macosx_11_0_arm64.whl" }, - { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" }, - { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" }, - { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" }, - { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" }, - { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" }, - { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" }, - { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" }, - { url = "https://github.com/Droidcraft/rust-code-analysis/releases/download/python-2026.1.31/rust_code_analysis-2026.1.31-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" }, ] [[package]] From f77565e608fffd5f26adb7b6efd684cda789b798 Mon Sep 17 00:00:00 2001 From: TensorTemplar Date: Sat, 31 Jan 2026 18:39:56 +0200 Subject: [PATCH 3/3] fix lint for stub --- src/slopometry/stubs/rust_code_analysis.pyi | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/slopometry/stubs/rust_code_analysis.pyi b/src/slopometry/stubs/rust_code_analysis.pyi index 49b5165..fb5db93 100644 --- a/src/slopometry/stubs/rust_code_analysis.pyi +++ b/src/slopometry/stubs/rust_code_analysis.pyi @@ -1,6 +1,6 @@ """Type stubs for rust-code-analysis Python bindings.""" -from typing import Iterator +from collections.abc import Iterator class CyclomaticMetrics: sum: float