diff --git a/.github/workflows/auto-release-on-merge.yml b/.github/workflows/auto-release-on-merge.yml index 4a62fdb..785eb99 100644 --- a/.github/workflows/auto-release-on-merge.yml +++ b/.github/workflows/auto-release-on-merge.yml @@ -14,6 +14,7 @@ jobs: runs-on: ubuntu-latest outputs: release_tag: ${{ steps.get_version.outputs.version_tag }} + release_created: ${{ steps.check_create_release.outputs.release_created }} steps: - name: Checkout Code uses: actions/checkout@v4 @@ -27,49 +28,25 @@ jobs: echo "Found version: $VERSION" echo "version_tag=v$VERSION" >> "$GITHUB_OUTPUT" - - name: Generate Release Notes - env: - TAG: ${{ steps.get_version.outputs.version_tag }} - run: | - cat > release_notes.md <<'EOF' - # PureCode $TAG - - ## Highlights - - Add a few concise bullets about the most impactful changes. - - ## Fixes - - Note bug fixes or quality improvements worth calling out. - - ## Compatibility - - Breaking changes: none. - - Minimum supported Rust: stable. - - ## Install - ```bash - curl -LsSf https://raw.githubusercontent.com/isupervillain/purecode/main/install.sh | sh - ``` - - ```powershell - powershell -ExecutionPolicy ByPass -c "irm https://raw.githubusercontent.com/isupervillain/purecode/main/install.ps1 | iex" - ``` - EOF - - name: Check and Create Release + id: check_create_release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ steps.get_version.outputs.version_tag }} SHA: ${{ github.event.pull_request.merge_commit_sha }} run: | if gh release view "$TAG" > /dev/null 2>&1; then - echo "Error: Release $TAG already exists." - exit 1 + echo "Release $TAG already exists. Skipping creation." + echo "release_created=false" >> "$GITHUB_OUTPUT" else echo "Creating release $TAG for commit $SHA" - gh release create "$TAG" --target "$SHA" --notes-file release_notes.md + gh release create "$TAG" --target "$SHA" --generate-notes + echo "release_created=true" >> "$GITHUB_OUTPUT" fi build_and_upload: needs: prepare_release + if: needs.prepare_release.outputs.release_created == 'true' name: Build and Upload Assets runs-on: ${{ matrix.os }} defaults: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 425eb6d..7da5af0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,15 @@ jobs: uses: dtolnay/rust-toolchain@stable with: toolchain: stable + components: clippy, rustfmt + + - name: Check Formatting + if: matrix.os == 'ubuntu-latest' + run: cargo fmt --check + + - name: Run Clippy + if: matrix.os == 'ubuntu-latest' + run: cargo clippy -- -D warnings - name: Build Release run: cargo build --release diff --git a/Cargo.lock b/Cargo.lock index 084c167..d801e5f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -107,18 +107,58 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "indexmap" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + [[package]] name = "once_cell_polyfill" version = "1.70.2" @@ -136,10 +176,15 @@ dependencies = [ [[package]] name = "purecode" -version = "0.1.0" +version = "0.2.0" dependencies = [ "clap", "colored", + "glob", + "serde", + "serde_json", + "toml", + "walkdir", ] [[package]] @@ -151,6 +196,73 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "strsim" version = "0.11.1" @@ -168,6 +280,47 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "unicode-ident" version = "1.0.22" @@ -180,6 +333,25 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -267,3 +439,12 @@ name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] diff --git a/Cargo.toml b/Cargo.toml index 73d9999..9d41a48 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,13 @@ [package] name = "purecode" -version = "0.1.0" +version = "0.2.0" edition = "2021" [dependencies] -clap = { version = "4.4", features = ["derive"] } +clap = { version = "4.4", features = ["derive", "env"] } colored = "3.0.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +toml = "0.8" +walkdir = "2.4" +glob = "0.3" diff --git a/README.md b/README.md index 4d1631d..e1a243f 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,28 @@ # PureCode -`purecode` is a production-grade Rust CLI tool that analyzes **git diffs** to provide meaningful insights into your code changes. Unlike standard line counters, it distinguishes between "pure code" and "noise" (comments, docstrings, and blank lines), helping you understand the real impact of a pull request or commit. +A fast, language-aware code analysis tool that distinguishes "pure code" from "noise" (comments, whitespace, boilerplates). It can analyze git diffs (for PR reviews) or scan file directories (for codebase stats). ## Features -- **Language-Aware Analysis**: Automatically detects languages and applies specific rules for identifying comments and code. -- **Pure Code Metrics**: Calculates `TOTAL` lines changed and `PURE` lines changed (excluding noise). -- **Rich Stats**: Tracks added/removed words, estimated tokens, and breaks down comments vs docstrings. -- **Flexible Input**: - - Run directly on git repositories (comparing branches/commits). - - Pipe unified diffs via stdin (ideal for pre-commit hooks and CI). -- **Thresholds**: Gate CI pipelines by enforcing maximum noise ratios or minimum pure code requirements. -- **Fast**: Built in Rust for high performance. -- **Multiple Formats**: Output Human-readable (colorful), Plain text, or JSON. +- **Language Aware**: Distinguishes comments, docstrings, and pure code for over 20 languages. +- **Diff Analysis**: Analyzes git diffs to show the "net pure code" contribution of a change. +- **Snapshot Analysis**: Scans directories to generate codebase statistics. +- **Complexity Metrics**: Calculates a review complexity score based on churn and code type. +- **Unified Output**: Supports Human-readable, Plain text, and JSON formats. +- **CI Friendly**: Strict threshold checking, exit codes, and machine-readable summaries. -## Supported Languages +## Architecture -`purecode` supports detecting and classifying code for: -- Python (distinguishes Docstrings vs Comments) -- C-style languages (C, C++, Java, C#, JS, TS, Go, PHP, Swift, Kotlin, Scala) -- Shell / PowerShell -- Ruby -- HTML, CSS, Vue (basic support) +PureCode operates in a pipeline: -## Quickstart +1. **Parser**: Reads a git diff (Diff Mode) or file contents (Snapshot Mode). +2. **Classifier**: A stateful engine that processes content line-by-line. It detects the language based on file extension and applies language-specific rules (e.g., Python triple-quotes, C-style block comments) to classify each line as `Pure`, `Comment`, `Docstring`, or `Blank`. +3. **Stats Aggregator**: Accumulates metrics per file and per language. +4. **Reporter**: Outputs the data in the requested format (Human, JSON, Plain). + +## Installation + +### Quickstart Install with a single command (macOS / Linux): @@ -37,71 +36,74 @@ For Windows (PowerShell): powershell -ExecutionPolicy ByPass -c "irm https://raw.githubusercontent.com/isupervillain/purecode/main/install.ps1 | iex" ``` -Run in a git repository: +### From Source ```bash -# Compare local changes against main -purecode - -# OR pipe a diff manually -git diff --cached --unified=0 --no-color | purecode --stdin +cargo install --path . ``` -## How It Works - -1. **Diff Parsing**: `purecode` takes a unified diff (standard git output). It ignores metadata and focuses on lines starting with `+` or `-`. -2. **Language Detection**: It identifies the file type for each hunk (e.g., `.py`, `.rs`, `.js`). -3. **Classification**: - * **Pure Code**: Logic, variable definitions, function calls. - * **Noise**: Comments (`//`, `#`), Docstrings (`"""`, `/**`), and Blank lines. -4. **Aggregation**: It sums up these metrics to show you the "Net Pure Code" added or removed. - -*Note*: For accurate results, always use `--unified=0` when piping diffs. This ensures context lines aren't counted as "noise" or "code". - ## Usage -### Basic Usage +### Diff Mode (Default) -Compare `origin/main` (default base) with `HEAD` (default head): +Analyzes the changes between two git references. ```bash -purecode -``` +# Analyze changes between main and HEAD +purecode diff --base origin/main --head HEAD -### Specific Commits/Branches +# Shortcut (uses default origin/main -> HEAD) +purecode -```bash -purecode --base v1.0 --head v2.0 +# Read diff from stdin +git diff origin/main | purecode diff --stdin ``` -### Output Formats +### Files Mode (Snapshot) + +Analyzes files in the current directory or specified paths. ```bash -# Default (Colorful with emojis) -purecode --format human +# Analyze all files in current directory +purecode files -# Plain (CI friendly, no colors) -purecode --format plain +# Analyze specific directories +purecode files src/ lib/ -# JSON (Machine readable) -purecode --format json +# Exclude node_modules (respected by default, but customizable) +purecode files --exclude "**/node_modules/**" ``` -### Thresholds & CI Gates +### Options -Fail the command if the PR is "too noisy" or decreases code volume: +- `--format `: Output format. +- `--per-file`: Show detailed statistics per file. +- `--max-noise-ratio <0.0-1.0>`: Fail if the noise ratio exceeds this value. +- `--min-pure-lines `: Fail if net pure lines count is less than N. +- `--fail-on-decrease`: Fail if net pure code contribution is negative. +- `--warn-only`: Print validation failures but exit with 0 (useful for non-blocking CI). +- `--ci`: Enable CI mode (no colors, deterministic output, summary lines). -```bash -# Fail if more than 50% of changes are comments/blanks -purecode --max-noise-ratio 0.5 +## Configuration + +You can configure defaults via a `.purecode.toml` file in your project root: -# Fail if net pure lines < 10 (ensure significant contribution) -purecode --min-pure-lines 10 +```toml +[purecode] +base = "origin/main" +format = "human" +max_noise_ratio = 0.6 +min_pure_lines = 5 +fail_on_decrease = true +warn_only = false +ci = false -# Fail if net pure code is negative -purecode --fail-on-decrease +include = ["src/**"] +exclude = ["**/*.lock", "dist/**", "target/**", "node_modules/**"] ``` +CLI flags always override configuration values. + ## Integration ### Pre-commit Hook @@ -111,7 +113,7 @@ Add to `.pre-commit-config.yaml`: ```yaml repos: - repo: https://github.com/isupervillain/purecode - rev: v0.1.0 + rev: v0.2.0 hooks: - id: purecode args: ["--stdin", "--format", "human"] @@ -136,10 +138,46 @@ steps: purecode --base origin/${{ github.base_ref }} --head HEAD --format human --max-noise-ratio 0.6 ``` +## Output Formats + +### JSON + +Use `--format json` for a fully structured output suitable for automated processing: + +```json +{ + "summary": { + "total_added": 120, + "pure_added": 100, + ... + }, + "language_stats": { ... }, + "complexity_score": 145.2, + "token_estimate": 2340, + "mode": "diff" +} +``` + +### CI Mode + +Use `--ci` to get machine-readable summary lines at the end of output: + +```bash +PURECODE_SUMMARY noise_ratio=0.15 pure_added=100 pure_removed=5 files_changed=8 complexity=145.2 +``` + +On failure: + +```bash +PURECODE_FAIL reason=noise_ratio_exceeded noise_ratio=0.62 max_noise_ratio=0.50 +``` + ## Contributing -We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details. +1. Clone the repository: `git clone https://github.com/isupervillain/purecode-priv` +2. Run tests: `cargo test` +3. Submit a PR. ## License -This project is open source. +MIT diff --git a/src/classifier.rs b/src/classifier.rs index 61ea3e2..285e995 100644 --- a/src/classifier.rs +++ b/src/classifier.rs @@ -1,3 +1,5 @@ +use crate::language::Language; + #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum LineType { Pure, @@ -7,8 +9,6 @@ pub enum LineType { } pub trait Classifier { - /// Classifies the line as Pure, Comment, Docstring, or Blank. - /// This method is stateful for multi-line comments. fn classify(&mut self, line: &str) -> LineType; } @@ -24,6 +24,12 @@ impl Classifier for DefaultClassifier { } } +impl Default for PythonClassifier { + fn default() -> Self { + Self::new() + } +} + pub struct PythonClassifier { in_triple_double: bool, in_triple_single: bool, @@ -38,12 +44,6 @@ impl PythonClassifier { } } -impl Default for PythonClassifier { - fn default() -> Self { - Self::new() - } -} - impl Classifier for PythonClassifier { fn classify(&mut self, line: &str) -> LineType { let trimmed = line.trim(); @@ -51,7 +51,6 @@ impl Classifier for PythonClassifier { return LineType::Blank; } - // If we are currently inside a triple-quote block if self.in_triple_double { if trimmed.contains("\"\"\"") { self.in_triple_double = false; @@ -65,19 +64,15 @@ impl Classifier for PythonClassifier { return LineType::Docstring; } - // Check for comments if trimmed.starts_with('#') { return LineType::Comment; } - // Check for start of docstrings if trimmed.starts_with("\"\"\"") { let count = line.matches("\"\"\"").count(); if count >= 2 { - // Open and close on same line -> one-line docstring return LineType::Docstring; } else { - // Open, but not close -> enter state self.in_triple_double = true; return LineType::Docstring; } @@ -121,14 +116,8 @@ impl Classifier for CStyleClassifier { } if self.in_block { - if let Some(end_idx) = trimmed.find("*/") { + if trimmed.contains("*/") { self.in_block = false; - // If there is code after the block ends, treat as pure. - if trimmed[end_idx + 2..].trim().is_empty() { - return LineType::Comment; - } else { - return LineType::Pure; - } } return LineType::Comment; } @@ -137,90 +126,20 @@ impl Classifier for CStyleClassifier { return LineType::Comment; } - // Check for Javadoc style continuations if trimmed.starts_with('*') { return LineType::Comment; } if let Some(start_idx) = trimmed.find("/*") { if let Some(end_idx) = trimmed.find("*/") { - // Inline block comment. If there's code outside the comment, count as pure. - let before = trimmed[..start_idx].trim(); - let after = trimmed[end_idx + 2..].trim(); - if before.is_empty() && after.is_empty() { + if end_idx > start_idx { return LineType::Comment; } - return LineType::Pure; } - - // Block starts, doesn't end on this line. self.in_block = true; - // If there is code before the block starts, treat as pure; otherwise comment. - if trimmed[..start_idx].trim().is_empty() { - return LineType::Comment; - } else { - return LineType::Pure; - } - } - - LineType::Pure - } -} - -pub struct HtmlClassifier { - in_comment: bool, -} - -impl HtmlClassifier { - pub fn new() -> Self { - Self { in_comment: false } - } -} - -impl Default for HtmlClassifier { - fn default() -> Self { - Self::new() - } -} - -impl Classifier for HtmlClassifier { - fn classify(&mut self, line: &str) -> LineType { - let trimmed = line.trim(); - if trimmed.is_empty() { - return LineType::Blank; - } - - if self.in_comment { - if let Some(end_idx) = trimmed.find("-->") { - self.in_comment = false; - if trimmed[end_idx + 3..].trim().is_empty() { - return LineType::Comment; - } else { - return LineType::Pure; - } - } return LineType::Comment; } - if let Some(start_idx) = trimmed.find("") { - let end_idx = start_idx + 4 + end_rel; - let before = trimmed[..start_idx].trim(); - let after = trimmed[end_idx + 3..].trim(); - if before.is_empty() && after.is_empty() { - return LineType::Comment; - } - return LineType::Pure; - } else { - self.in_comment = true; - if trimmed[..start_idx].trim().is_empty() { - return LineType::Comment; - } else { - return LineType::Pure; - } - } - } - LineType::Pure } } @@ -283,15 +202,96 @@ impl Classifier for RubyClassifier { } } -pub fn get_classifier(lang: &str) -> Box { +// Updated HTML/Vue Classifier to handle multi-line comments +pub struct HtmlClassifier { + in_comment: bool, +} + +impl HtmlClassifier { + pub fn new() -> Self { + Self { in_comment: false } + } +} + +impl Default for HtmlClassifier { + fn default() -> Self { + Self::new() + } +} + +impl Classifier for HtmlClassifier { + fn classify(&mut self, line: &str) -> LineType { + let trimmed = line.trim(); + if trimmed.is_empty() { + return LineType::Blank; + } + + if self.in_comment { + if let Some(idx) = trimmed.find("-->") { + // Check if there is code after comment end + // For simplified classification, if a line has code mixed with comment end, we count as pure if it's not just comment. + // But requirements say: "Classify lines containing both code and comments as LineType::Pure" + // So if "-->
", it's Pure. + // If "-->", it's Comment. + let after = &trimmed[idx + 3..]; + if !after.trim().is_empty() { + self.in_comment = false; + return LineType::Pure; + } + self.in_comment = false; + return LineType::Comment; + } + return LineType::Comment; + } + + // Check for start of comment + if let Some(start_idx) = trimmed.find("") { + if end_idx > start_idx { + // Full comment on one line. + // Check if there is code before or after. + let before = &trimmed[..start_idx]; + let after = &trimmed[end_idx + 3..]; + if !before.trim().is_empty() || !after.trim().is_empty() { + return LineType::Pure; + } + return LineType::Comment; + } + } + // Starts but doesn't end + let before = &trimmed[..start_idx]; + if !before.trim().is_empty() { + self.in_comment = true; + return LineType::Pure; + } + self.in_comment = true; + return LineType::Comment; + } + + LineType::Pure + } +} + +pub fn get_classifier(lang: Language) -> Box { match lang { - "Python" => Box::new(PythonClassifier::new()), - "TypeScript" | "JavaScript" | "C" | "C++" | "C#" | "Java" | "Go" | "PHP" | "Swift" - | "Kotlin" | "Scala" | "CSS" => Box::new(CStyleClassifier::new()), - "HTML" | "Vue" => Box::new(HtmlClassifier::new()), - "Shell" | "PowerShell" => Box::new(ShellClassifier), // PowerShell uses # for comments too - "Ruby" => Box::new(RubyClassifier::new()), - _ => Box::new(DefaultClassifier), + Language::Python => Box::new(PythonClassifier::new()), + Language::TypeScript + | Language::JavaScript + | Language::C + | Language::Cpp + | Language::Csharp + | Language::Java + | Language::Go + | Language::Php + | Language::Swift + | Language::Kotlin + | Language::Scala + | Language::Css => Box::new(CStyleClassifier::new()), + Language::Shell | Language::PowerShell => Box::new(ShellClassifier), + Language::Ruby => Box::new(RubyClassifier::new()), + Language::Html | Language::Vue => Box::new(HtmlClassifier::new()), + Language::Other => Box::new(DefaultClassifier), } } @@ -305,66 +305,22 @@ mod tests { assert_eq!(c.classify("x = 1"), LineType::Pure); assert_eq!(c.classify("# comment"), LineType::Comment); assert_eq!(c.classify(" "), LineType::Blank); - - // Multiline docstring - assert_eq!(c.classify("\"\"\""), LineType::Docstring); // Start block - assert_eq!(c.classify("docs"), LineType::Docstring); // Inside - assert_eq!(c.classify("\"\"\""), LineType::Docstring); // End block - assert_eq!(c.classify("x = 2"), LineType::Pure); - - // One-liner - assert_eq!(c.classify("\"\"\" one line docs \"\"\""), LineType::Docstring); - assert_eq!(c.classify("y = 2"), LineType::Pure); - } - - #[test] - fn test_cstyle_classifier() { - let mut c = CStyleClassifier::new(); - assert_eq!(c.classify("int x = 1;"), LineType::Pure); - assert_eq!(c.classify("// comment"), LineType::Comment); - assert_eq!(c.classify(" "), LineType::Blank); - - // Multiline - assert_eq!(c.classify("/*"), LineType::Comment); - assert_eq!(c.classify(" * inside"), LineType::Comment); - assert_eq!(c.classify("*/"), LineType::Comment); - assert_eq!(c.classify("x = 2;"), LineType::Pure); - - // One-liner - assert_eq!(c.classify("/* comment */"), LineType::Comment); - assert_eq!(c.classify("code(); /* comment */"), LineType::Pure); - assert_eq!(c.classify("code(); /* comment */ more();"), LineType::Pure); - - // Inline block start that continues on next line counts as pure for the code part. - assert_eq!(c.classify("do_work(); /* start"), LineType::Pure); - assert_eq!(c.classify(" * inside block"), LineType::Comment); - assert_eq!(c.classify(" end */"), LineType::Comment); - assert_eq!(c.classify("done();"), LineType::Pure); - } - - #[test] - fn test_ruby_classifier() { - let mut c = RubyClassifier::new(); - assert_eq!(c.classify("x = 1"), LineType::Pure); - assert_eq!(c.classify("# comment"), LineType::Comment); - assert_eq!(c.classify(""), LineType::Blank); - - assert_eq!(c.classify("=begin"), LineType::Comment); - assert_eq!(c.classify("docs"), LineType::Comment); - assert_eq!(c.classify("=end"), LineType::Comment); - assert_eq!(c.classify("y = 2"), LineType::Pure); } #[test] fn test_html_classifier() { let mut c = HtmlClassifier::new(); - assert_eq!(c.classify("
hi
"), LineType::Pure); - assert_eq!(c.classify(""), LineType::Comment); - assert_eq!(c.classify("text more"), LineType::Pure); - - // Multiline HTML comments - assert_eq!(c.classify(" trailing"), LineType::Pure); + assert_eq!(c.classify("
"), LineType::Pure); + assert_eq!(c.classify(""), LineType::Comment); + assert_eq!(c.classify("
"), LineType::Pure); + + assert_eq!(c.classify(""), LineType::Comment); + + // Mixed + let mut c2 = HtmlClassifier::new(); + assert_eq!(c2.classify("
"), LineType::Pure); } } diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..e6ec81f --- /dev/null +++ b/src/config.rs @@ -0,0 +1,72 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::Path; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + #[serde(default = "default_base")] + pub base: String, + #[serde(default = "default_format")] + pub format: String, + pub max_noise_ratio: Option, + pub min_pure_lines: Option, + #[serde(default)] + pub fail_on_decrease: bool, + #[serde(default)] + pub warn_only: bool, + #[serde(default)] + pub ci: bool, + #[serde(default = "default_include")] + pub include: Vec, + #[serde(default = "default_exclude")] + pub exclude: Vec, +} + +fn default_base() -> String { + "origin/main".to_string() +} +fn default_format() -> String { + "human".to_string() +} +fn default_include() -> Vec { + vec!["**/*".to_string()] +} +fn default_exclude() -> Vec { + vec![ + "**/*.lock".to_string(), + "dist/**".to_string(), + "target/**".to_string(), + "node_modules/**".to_string(), + ".git/**".to_string(), + ] +} + +impl Default for Config { + fn default() -> Self { + Self { + base: default_base(), + format: default_format(), + max_noise_ratio: None, + min_pure_lines: None, + fail_on_decrease: false, + warn_only: false, + ci: false, + include: default_include(), + exclude: default_exclude(), + } + } +} + +pub fn load_config() -> Config { + let path = Path::new(".purecode.toml"); + if path.exists() { + match fs::read_to_string(path) { + Ok(content) => match toml::from_str(&content) { + Ok(config) => return config, + Err(e) => eprintln!("Warning: Failed to parse .purecode.toml: {}", e), + }, + Err(e) => eprintln!("Warning: Failed to read .purecode.toml: {}", e), + } + } + Config::default() +} diff --git a/src/files.rs b/src/files.rs new file mode 100644 index 0000000..85b271e --- /dev/null +++ b/src/files.rs @@ -0,0 +1,134 @@ +use crate::classifier::{get_classifier, LineType}; +use crate::language::Language; +use crate::stats::{FileStats, LangStats}; +use glob::Pattern; +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::Path; +use walkdir::WalkDir; + +pub fn analyze_files( + include: &[String], + exclude: &[String], + reader: Option>, // For stdin support +) -> Result, std::io::Error> { + let mut stats = Vec::new(); + + // Process stdin if provided (assuming list of files) + if let Some(r) = reader { + for line in r.lines() { + let path_str = line?; + let path = Path::new(&path_str); + if path.exists() { + if let Ok(fs) = process_file(path) { + stats.push(fs); + } + } else { + eprintln!("Warning: File not found: {}", path_str); + } + } + return Ok(stats); + } + + let exclude_patterns: Vec = exclude + .iter() + .filter_map(|p| Pattern::new(p).ok()) + .collect(); + + let include_patterns: Vec = include + .iter() + .filter_map(|p| Pattern::new(p).ok()) + .collect(); + + for entry in WalkDir::new(".").into_iter().flatten() { + let path = entry.path(); + if path.is_dir() { + continue; + } + + // Convert path to relative string for matching + let path_str = path.to_string_lossy(); + let clean_path = if let Some(stripped) = path_str.strip_prefix("./") { + stripped + } else { + &path_str + }; + + // Check excludes + if exclude_patterns.iter().any(|p| p.matches(clean_path)) { + continue; + } + + // Check includes (at least one must match if we are strict, or default include is "**/*") + if !include_patterns.iter().any(|p| p.matches(clean_path)) { + continue; + } + + if let Ok(fs) = process_file(path) { + stats.push(fs); + } + } + + Ok(stats) +} + +fn process_file(path: &Path) -> Result { + let language = Language::from_path(path); + + // Use a separate check + if is_binary(path)? { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Binary file", + )); + } + + let file = File::open(path)?; + let reader = BufReader::new(file); + + let mut classifier = get_classifier(language); + let mut lang_stats = LangStats::default(); + + for line_result in reader.lines() { + match line_result { + Ok(line) => { + lang_stats.total_added += 1; // Snapshot mode: everything is added + match classifier.classify(&line) { + LineType::Pure => { + lang_stats.pure_added += 1; + lang_stats.code_words_added += line.split_whitespace().count() as i64; + } + LineType::Comment => lang_stats.comment_lines_added += 1, + LineType::Docstring => lang_stats.docstring_lines_added += 1, + LineType::Blank => lang_stats.blank_lines_added += 1, + } + } + Err(_) => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Read error", + )) + } + } + } + + Ok(FileStats { + path: path.to_string_lossy().to_string(), + language: language.to_string(), + lang_stats, + }) +} + +fn is_binary(path: &Path) -> Result { + let mut file = File::open(path)?; + let mut buffer = [0; 1024]; + use std::io::Read; + let n = file.read(&mut buffer)?; + if n == 0 { + return Ok(false); + } // Empty file is not binary + if buffer[..n].contains(&0) { + return Ok(true); + } + Ok(false) +} diff --git a/src/language.rs b/src/language.rs new file mode 100644 index 0000000..4ed4382 --- /dev/null +++ b/src/language.rs @@ -0,0 +1,85 @@ +use std::fmt; +use std::path::Path; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Language { + Python, + JavaScript, + TypeScript, + Html, + Css, + C, + Cpp, + Csharp, + Java, + Go, + Php, + Ruby, + Swift, + Kotlin, + Scala, + Shell, + PowerShell, + Vue, + Other, +} + +impl Language { + pub fn from_path(path: &Path) -> Self { + match path.extension().and_then(|e| e.to_str()) { + Some("py") => Language::Python, + Some("js") | Some("jsx") | Some("mjs") => Language::JavaScript, + Some("ts") | Some("tsx") => Language::TypeScript, + Some("html") | Some("htm") => Language::Html, + Some("css") | Some("scss") => Language::Css, + Some("c") | Some("h") => Language::C, + Some("cpp") | Some("hpp") | Some("cc") | Some("cxx") | Some("hh") => Language::Cpp, + Some("cs") => Language::Csharp, + Some("java") => Language::Java, + Some("go") => Language::Go, + Some("php") => Language::Php, + Some("rb") => Language::Ruby, + Some("swift") => Language::Swift, + Some("kt") | Some("kts") => Language::Kotlin, + Some("scala") | Some("sc") => Language::Scala, + Some("sh") | Some("bash") | Some("zsh") => Language::Shell, + Some("ps1") | Some("psm1") => Language::PowerShell, + Some("vue") => Language::Vue, + _ => { + // Check filename for special cases + match path.file_name().and_then(|n| n.to_str()) { + Some("Dockerfile") => Language::Other, // Or maybe shell-like? keeping Other for now + Some("Makefile") => Language::Other, + _ => Language::Other, + } + } + } + } +} + +impl fmt::Display for Language { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Language::Python => "Python", + Language::JavaScript => "JavaScript", + Language::TypeScript => "TypeScript", + Language::Html => "HTML", + Language::Css => "CSS", + Language::C => "C", + Language::Cpp => "C++", + Language::Csharp => "C#", + Language::Java => "Java", + Language::Go => "Go", + Language::Php => "PHP", + Language::Ruby => "Ruby", + Language::Swift => "Swift", + Language::Kotlin => "Kotlin", + Language::Scala => "Scala", + Language::Shell => "Shell", + Language::PowerShell => "PowerShell", + Language::Vue => "Vue", + Language::Other => "Other", + }; + write!(f, "{}", s) + } +} diff --git a/src/lib.rs b/src/lib.rs index 4397d09..4dd432d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,38 +1,15 @@ pub mod classifier; +pub mod config; pub mod diff; +pub mod files; +pub mod language; pub mod parser; pub mod report; pub mod stats; -/// Detects the programming language based on the file extension. -/// -/// Returns "Other" if the extension is unknown. -pub fn detect_language(path: &str) -> String { - let extension = std::path::Path::new(path) - .extension() - .and_then(|ext| ext.to_str()) - .unwrap_or(""); +#[cfg(test)] +mod tests; - match extension { - "py" => "Python", - "ts" | "tsx" => "TypeScript", - "js" | "jsx" => "JavaScript", - "c" | "h" => "C", - "cpp" | "cc" | "hpp" | "hh" => "C++", - "cs" => "C#", - "java" => "Java", - "go" => "Go", - "swift" => "Swift", - "kt" | "kts" => "Kotlin", - "php" => "PHP", - "scala" => "Scala", - "rb" => "Ruby", - "sh" | "bash" | "zsh" => "Shell", - "ps1" => "PowerShell", - "css" | "scss" => "CSS", - "html" | "htm" => "HTML", - "vue" => "Vue", - _ => "Other", - } - .to_string() +pub fn detect_language(path: &str) -> String { + language::Language::from_path(std::path::Path::new(path)).to_string() } diff --git a/src/main.rs b/src/main.rs index b6027bd..cecab76 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,41 +1,147 @@ -use clap::{Parser, ValueEnum}; -use purecode::{diff, parser, report, stats::LangStats}; -use std::collections::HashMap; +use clap::{Parser, Subcommand, ValueEnum}; +use purecode::{ + config, diff, files, parser, report, + stats::{FileStats, LangStats, ThresholdError}, +}; +use std::io::BufReader; use std::process::exit; #[derive(Parser, Debug)] #[command(name = "purecode")] #[command(author = "PureCode Author")] -#[command(version = "0.1.0")] -#[command(about = "Analyzes git diffs to count pure code vs noise", long_about = None)] +#[command(version = "0.2.0")] +#[command(about = "Analyzes code to count pure code vs noise", long_about = None)] +#[command(args_conflicts_with_subcommands = true)] struct Cli { + #[command(subcommand)] + command: Option, + + // Fallback flags for root command (diff mode) /// Base ref for git diff - #[arg(long, default_value = "origin/main")] - base: String, + #[arg(long)] + base: Option, /// Head ref for git diff - #[arg(long, default_value = "HEAD")] - head: String, + #[arg(long)] + head: Option, /// Read unified diff from stdin instead of running git #[arg(long)] stdin: bool, /// Output format - #[arg(long, value_enum, default_value_t = Format::Human)] - format: Format, + #[arg(long, value_enum)] + format: Option, + + /// Show per-file statistics + #[arg(long)] + per_file: bool, /// Fail if noise ratio (comments/blanks) is greater than this value (0.0 - 1.0) #[arg(long)] max_noise_ratio: Option, - /// Fail if the number of net pure lines is less than this value + /// Fail if the net pure lines is less than this value #[arg(long)] min_pure_lines: Option, /// Fail if the net pure code change is negative #[arg(long)] fail_on_decrease: bool, + + /// Only warn on threshold failures + #[arg(long)] + warn_only: bool, + + /// CI mode (no colors, summary output) + #[arg(long)] + ci: bool, +} + +#[derive(Subcommand, Debug)] +enum Commands { + /// Analyze git diffs + Diff { + /// Base ref for git diff + #[arg(long, default_value = "origin/main")] + base: String, + + /// Head ref for git diff + #[arg(long, default_value = "HEAD")] + head: String, + + /// Read unified diff from stdin + #[arg(long)] + stdin: bool, + + /// Output format + #[arg(long, value_enum)] + format: Option, + + /// Show per-file statistics + #[arg(long)] + per_file: bool, + + /// Fail if noise ratio (comments/blanks) is greater than this value (0.0 - 1.0) + #[arg(long)] + max_noise_ratio: Option, + + /// Fail if the net pure lines is less than this value + #[arg(long)] + min_pure_lines: Option, + + /// Fail if the net pure code change is negative + #[arg(long)] + fail_on_decrease: bool, + + /// Only warn on threshold failures + #[arg(long)] + warn_only: bool, + + /// CI mode + #[arg(long)] + ci: bool, + }, + /// Analyze files/directories (Snapshot mode) + Files { + /// Paths to include (defaults to all) + #[arg(default_value = ".")] + paths: Vec, + + /// Read file list from stdin + #[arg(long)] + stdin: bool, + + /// Output format + #[arg(long, value_enum)] + format: Option, + + /// Show per-file statistics + #[arg(long)] + per_file: bool, + + /// Fail if noise ratio (comments/blanks) is greater than this value (0.0 - 1.0) + #[arg(long)] + max_noise_ratio: Option, + + /// Fail if the net pure lines is less than this value + #[arg(long)] + min_pure_lines: Option, + + /// Fail if the net pure code change is negative + #[arg(long)] + fail_on_decrease: bool, + + /// Only warn on threshold failures + #[arg(long)] + warn_only: bool, + + /// CI mode + #[arg(long)] + ci: bool, + }, + /// History analysis (Scaffolding) + History, } #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)] @@ -55,84 +161,268 @@ impl From for report::OutputFormat { } } +fn resolve_format(cli_format: Option, config_format: &str) -> Format { + if let Some(f) = cli_format { + f + } else { + match config_format { + "json" => Format::Json, + "plain" => Format::Plain, + _ => Format::Human, + } + } +} + fn main() { - let args = Cli::parse(); + let cli = Cli::parse(); + let config = config::load_config(); - let reader: Box = if args.stdin { - diff::get_stdin_diff() - } else { - match diff::get_git_diff(&args.base, &args.head) { - Ok(r) => r, - Err(e) => { - eprintln!("Error running git diff: {}", e); + // Determine mode and arguments + let (stats, mode, active_config) = match cli.command { + Some(Commands::History) => { + println!("History analysis not implemented yet."); + exit(0); + } + Some(Commands::Files { + paths, + stdin, + format, + per_file, + max_noise_ratio, + min_pure_lines, + fail_on_decrease, + warn_only, + ci, + }) => { + let final_format = resolve_format(format, &config.format); + + let _files_to_scan = if paths == vec!["."] { + vec![".".to_string()] + } else { + paths + }; + + let include = if config.include.is_empty() { + vec!["**/*".to_string()] + } else { + config.include.clone() + }; + let exclude = config.exclude.clone(); + + let reader: Option> = if stdin { + Some(Box::new(BufReader::new(std::io::stdin()))) + } else { + None + }; + + let stats = match files::analyze_files(&include, &exclude, reader) { + Ok(s) => s, + Err(e) => { + eprintln!("Error analyzing files: {}", e); + exit(1); + } + }; + + ( + stats, + "snapshot", + FilesConfig { + format: final_format, + per_file, + max_noise_ratio: max_noise_ratio.or(config.max_noise_ratio), + min_pure_lines: min_pure_lines.or(config.min_pure_lines), + fail_on_decrease: fail_on_decrease || config.fail_on_decrease, + warn_only: warn_only || config.warn_only, + ci: ci || config.ci, + }, + ) + } + Some(Commands::Diff { + base, + head, + stdin, + format, + per_file, + max_noise_ratio, + min_pure_lines, + fail_on_decrease, + warn_only, + ci, + }) => { + let final_format = resolve_format(format, &config.format); + + let reader: Box = if stdin { + diff::get_stdin_diff() + } else { + match diff::get_git_diff(&base, &head) { + Ok(r) => r, + Err(e) => { + eprintln!("Error running git diff: {}", e); + exit(1); + } + } + }; + + let mut file_stats = Vec::new(); + if let Err(e) = parser::parse_diff(reader, &mut file_stats) { + eprintln!("Error parsing diff: {}", e); + exit(1); + } + + ( + file_stats, + "diff", + FilesConfig { + format: final_format, + per_file, + max_noise_ratio: max_noise_ratio.or(config.max_noise_ratio), + min_pure_lines: min_pure_lines.or(config.min_pure_lines), + fail_on_decrease: fail_on_decrease || config.fail_on_decrease, + warn_only: warn_only || config.warn_only, + ci: ci || config.ci, + }, + ) + } + None => { + // Root command fallback + let base = cli.base.unwrap_or(config.base); + let head = cli.head.unwrap_or("HEAD".to_string()); + let format = resolve_format(cli.format, &config.format); + + let reader: Box = if cli.stdin { + diff::get_stdin_diff() + } else { + match diff::get_git_diff(&base, &head) { + Ok(r) => r, + Err(e) => { + eprintln!("Error running git diff: {}", e); + exit(1); + } + } + }; + + let mut file_stats = Vec::new(); + if let Err(e) = parser::parse_diff(reader, &mut file_stats) { + eprintln!("Error parsing diff: {}", e); exit(1); } + + ( + file_stats, + "diff", + FilesConfig { + format, + per_file: cli.per_file, + max_noise_ratio: cli.max_noise_ratio.or(config.max_noise_ratio), + min_pure_lines: cli.min_pure_lines.or(config.min_pure_lines), + fail_on_decrease: cli.fail_on_decrease || config.fail_on_decrease, + warn_only: cli.warn_only || config.warn_only, + ci: cli.ci || config.ci, + }, + ) } }; - let mut stats = HashMap::new(); - if let Err(e) = parser::parse_diff(reader, &mut stats) { - eprintln!("Error parsing diff: {}", e); - exit(1); - } + report::print_report( + &stats, + active_config.format.into(), + active_config.per_file, + mode, + active_config.ci, + ); - report::print_report(&stats, args.format.into()); + if let Err(e) = check_thresholds(&stats, &active_config) { + // Print fail summary for CI + if active_config.ci { + println!( + "PURECODE_FAIL reason={} {}", + error_reason(&e), + error_details(&e) + ); + } - if let Err(e) = check_thresholds(&stats, &args) { eprintln!("{}", e); - exit(1); + if !active_config.warn_only { + exit(2); + } + } else { + // Success summary is printed in print_report if CI mode } } -fn check_thresholds(stats: &HashMap, args: &Cli) -> Result<(), String> { - // Aggregate overall stats +struct FilesConfig { + format: Format, + per_file: bool, + max_noise_ratio: Option, + min_pure_lines: Option, + fail_on_decrease: bool, + warn_only: bool, + ci: bool, +} + +fn check_thresholds(file_stats: &[FileStats], args: &FilesConfig) -> Result<(), ThresholdError> { let mut overall = LangStats::default(); - for s in stats.values() { - overall.total_added += s.total_added; - overall.total_removed += s.total_removed; - overall.pure_added += s.pure_added; - overall.pure_removed += s.pure_removed; + for s in file_stats { + overall.total_added += s.lang_stats.total_added; + overall.total_removed += s.lang_stats.total_removed; + overall.pure_added += s.lang_stats.pure_added; + overall.pure_removed += s.lang_stats.pure_removed; + overall.comment_lines_added += s.lang_stats.comment_lines_added; + overall.docstring_lines_added += s.lang_stats.docstring_lines_added; + overall.blank_lines_added += s.lang_stats.blank_lines_added; } - // Check Max Noise Ratio if let Some(max_ratio) = args.max_noise_ratio { let total_changes = overall.total_added + overall.total_removed; + if total_changes > 0 { let pure_changes = overall.pure_added + overall.pure_removed; let pure_ratio = pure_changes as f64 / total_changes as f64; let noise_ratio = 1.0 - pure_ratio; if noise_ratio > max_ratio { - return Err(format!( - "Failure: Noise ratio {:.2} exceeds limit {:.2}", - noise_ratio, max_ratio - )); + return Err(ThresholdError::NoiseRatioExceeded { + actual: noise_ratio, + max: max_ratio, + }); } } } - // Check Min Pure Lines (Total touched) - // Treat "pure lines" as the amount of pure code touched (added or removed), - // so cleanup PRs that delete code still count toward the threshold. if let Some(min_lines) = args.min_pure_lines { - let pure_touched = overall.pure_added + overall.pure_removed; - if pure_touched < min_lines { - return Err(format!( - "Failure: Pure lines touched {} is less than minimum {}", - pure_touched, min_lines - )); + if overall.net_pure() < min_lines { + return Err(ThresholdError::MinPureLines { + actual: overall.net_pure(), + min: min_lines, + }); } } - // Check Fail on Decrease - if args.fail_on_decrease { - if overall.net_pure() < 0 { - return Err(format!( - "Failure: Net pure code decreased ({})", - overall.net_pure() - )); - } + if args.fail_on_decrease && overall.net_pure() < 0 { + return Err(ThresholdError::PureLinesDecreased { + actual: overall.net_pure(), + }); } Ok(()) } + +fn error_reason(e: &ThresholdError) -> &'static str { + match e { + ThresholdError::NoiseRatioExceeded { .. } => "noise_ratio_exceeded", + ThresholdError::MinPureLines { .. } => "min_pure_lines_not_met", + ThresholdError::PureLinesDecreased { .. } => "pure_lines_decreased", + } +} + +fn error_details(e: &ThresholdError) -> String { + match e { + ThresholdError::NoiseRatioExceeded { actual, max } => { + format!("noise_ratio={:.2} max_noise_ratio={:.2}", actual, max) + } + ThresholdError::MinPureLines { actual, min } => { + format!("net_pure_lines={} min_pure_lines={}", actual, min) + } + ThresholdError::PureLinesDecreased { actual } => format!("net_pure_lines={}", actual), + } +} diff --git a/src/parser.rs b/src/parser.rs index 72f7443..56d4452 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -1,56 +1,104 @@ use crate::classifier::{get_classifier, LineType}; -use crate::detect_language; -use crate::stats::LangStats; -use std::collections::HashMap; +use crate::language::Language; +use crate::stats::{FileStats, LangStats}; +use std::path::Path; /// Parses a unified diff from the reader and updates statistics. -/// -/// This function reads the diff line by line, detects file changes and languages, -/// uses classifiers to determine if lines are pure code or noise, and aggregates -/// the results into the provided `stats` map. pub fn parse_diff( reader: R, - stats: &mut HashMap, + stats: &mut Vec, ) -> Result<(), std::io::Error> { - let mut current_lang = String::new(); - let mut classifier = get_classifier("Other"); + let mut current_file_stats: Option = None; + let mut classifier = get_classifier(Language::Other); + let mut is_binary_diff = false; + let mut context_warning_printed = false; for line_result in reader.lines() { let line = line_result?; + // Detect binary files diff + if line.starts_with("Binary files") && line.contains("differ") { + // "Binary files a/foo and b/foo differ" + // We should skip this file. + // If we already started tracking it (unlikely if this is the first line about it), clear it. + current_file_stats = None; + is_binary_diff = true; + continue; + } + + if line.starts_with("--- ") { + // Save previous + if let Some(file_stats) = current_file_stats.take() { + if !is_binary_diff + && (file_stats.lang_stats.total_added > 0 + || file_stats.lang_stats.total_removed > 0) + { + stats.push(file_stats); + } + } + is_binary_diff = false; + + let path_part = line.trim_start_matches("--- ").trim(); + if path_part == "/dev/null" { + current_file_stats = None; + continue; + } + + let clean_path = if let Some(stripped) = path_part.strip_prefix("a/") { + stripped + } else { + path_part + }; + + let language = Language::from_path(Path::new(clean_path)); + classifier = get_classifier(language); + current_file_stats = Some(FileStats { + path: clean_path.to_string(), + language: language.to_string(), + lang_stats: LangStats::default(), + }); + continue; + } + if line.starts_with("+++ ") { let path_part = line.trim_start_matches("+++ ").trim(); - - if path_part == "/dev/null" || path_part.ends_with("/dev/null") { + if path_part == "/dev/null" { continue; } - // Strip prefix a/ or b/ let clean_path = if let Some(stripped) = path_part.strip_prefix("b/") { stripped - } else if let Some(stripped) = path_part.strip_prefix("a/") { - stripped } else { path_part }; - current_lang = detect_language(clean_path); - classifier = get_classifier(¤t_lang); + if let Some(fs) = &mut current_file_stats { + if fs.path != clean_path { + let language = Language::from_path(Path::new(clean_path)); + classifier = get_classifier(language); + fs.path = clean_path.to_string(); + fs.language = language.to_string(); + } + } else { + let language = Language::from_path(Path::new(clean_path)); + classifier = get_classifier(language); + current_file_stats = Some(FileStats { + path: clean_path.to_string(), + language: language.to_string(), + lang_stats: LangStats::default(), + }); + } continue; } - if line.starts_with("--- ") { - let path_part = line.trim_start_matches("--- ").trim(); - if path_part != "/dev/null" && !path_part.ends_with("/dev/null") { - let clean_path = if let Some(stripped) = path_part.strip_prefix("a/") { - stripped - } else if let Some(stripped) = path_part.strip_prefix("b/") { - stripped - } else { - path_part - }; - current_lang = detect_language(clean_path); - classifier = get_classifier(¤t_lang); + // Hunk header + if line.starts_with("@@") { + // Reset classifier state for new hunk because hunks are disjoint + // and carrying state (like in_comment) across hunks is dangerous. + // We re-initialize the classifier for the current language. + if let Some(fs) = ¤t_file_stats { + let lang = Language::from_path(Path::new(&fs.path)); + classifier = get_classifier(lang); } continue; } @@ -60,46 +108,64 @@ pub fn parse_diff( || line.starts_with("index ") || line.starts_with("new file mode") || line.starts_with("deleted file mode") - || line.starts_with("@@") { continue; } - if current_lang.is_empty() { - // Probably haven't seen file header yet or parsing error, skip + if is_binary_diff { continue; } + let file_stats = match &mut current_file_stats { + Some(fs) => fs, + None => continue, + }; + if line.starts_with('+') && !line.starts_with("+++") { let content = &line[1..]; - let stat = stats.entry(current_lang.clone()).or_default(); + let stat = &mut file_stats.lang_stats; stat.total_added += 1; match classifier.classify(content) { LineType::Pure => { stat.pure_added += 1; stat.code_words_added += count_words(content) as i64; - }, + } LineType::Comment => stat.comment_lines_added += 1, LineType::Docstring => stat.docstring_lines_added += 1, LineType::Blank => stat.blank_lines_added += 1, } } else if line.starts_with('-') && !line.starts_with("---") { let content = &line[1..]; - let stat = stats.entry(current_lang.clone()).or_default(); + let stat = &mut file_stats.lang_stats; stat.total_removed += 1; match classifier.classify(content) { LineType::Pure => { stat.pure_removed += 1; stat.code_words_removed += count_words(content) as i64; - }, + } LineType::Comment => stat.comment_lines_removed += 1, LineType::Docstring => stat.docstring_lines_removed += 1, LineType::Blank => stat.blank_lines_removed += 1, } + } else if line.starts_with(' ') { + // Context line + if !context_warning_printed { + eprintln!("Warning: Context line detected. Please use 'git diff --unified=0' for accurate results."); + context_warning_printed = true; + } + } + } + + if let Some(file_stats) = current_file_stats.take() { + if !is_binary_diff + && (file_stats.lang_stats.total_added > 0 || file_stats.lang_stats.total_removed > 0) + { + stats.push(file_stats); } } + Ok(()) } @@ -125,30 +191,19 @@ index 123..456 100644 +def bar(): + pass "; - let mut stats = HashMap::new(); + let mut stats = Vec::new(); let reader = Cursor::new(diff_input); parse_diff(reader, &mut stats).unwrap(); - let py_stats = stats.get("Python").unwrap(); - // Removed: "def foo():", "# comment" - // Added: "def bar():", " pass" - assert_eq!(py_stats.total_removed, 2); - assert_eq!(py_stats.total_added, 2); - - // Pure Removed: "def foo():" (1) - // Pure Added: "def bar():", " pass" (2) - assert_eq!(py_stats.pure_removed, 1); - assert_eq!(py_stats.pure_added, 2); - - // Words - // "def foo():" -> 2 words - // "def bar():" -> 2 words - // " pass" -> 1 word - assert_eq!(py_stats.code_words_removed, 2); - assert_eq!(py_stats.code_words_added, 3); - - // Comments - assert_eq!(py_stats.comment_lines_removed, 1); - assert_eq!(py_stats.comment_lines_added, 0); + assert_eq!(stats.len(), 1); + let file_stats = &stats[0]; + assert_eq!(file_stats.path, "test.py"); + assert_eq!(file_stats.language, "Python"); + + let lang_stats = &file_stats.lang_stats; + assert_eq!(lang_stats.total_removed, 2); + assert_eq!(lang_stats.total_added, 2); + assert_eq!(lang_stats.pure_removed, 1); + assert_eq!(lang_stats.pure_added, 2); } } diff --git a/src/report.rs b/src/report.rs index 45dc154..82ff40d 100644 --- a/src/report.rs +++ b/src/report.rs @@ -1,5 +1,5 @@ -use crate::stats::LangStats; -use colored::Colorize; +use crate::stats::{calculate_complexity, estimate_tokens, AnalysisResult, FileStats, LangStats}; +use colored::*; use std::collections::HashMap; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -9,226 +9,209 @@ pub enum OutputFormat { Json, } -pub fn print_report(stats: &HashMap, format: OutputFormat) { - match format { - OutputFormat::Human => print_human(stats, true, true), - OutputFormat::Plain => print_human(stats, false, false), - OutputFormat::Json => print_json(stats), +pub fn print_report( + stats: &[FileStats], + format: OutputFormat, + per_file: bool, + mode: &str, + ci: bool, +) { + let mut overall = LangStats::default(); + let mut lang_map: HashMap = HashMap::new(); + + for file in stats { + // Aggregate overall + overall.total_added += file.lang_stats.total_added; + overall.total_removed += file.lang_stats.total_removed; + overall.pure_added += file.lang_stats.pure_added; + overall.pure_removed += file.lang_stats.pure_removed; + overall.comment_lines_added += file.lang_stats.comment_lines_added; + overall.comment_lines_removed += file.lang_stats.comment_lines_removed; + overall.docstring_lines_added += file.lang_stats.docstring_lines_added; + overall.docstring_lines_removed += file.lang_stats.docstring_lines_removed; + overall.blank_lines_added += file.lang_stats.blank_lines_added; + overall.blank_lines_removed += file.lang_stats.blank_lines_removed; + overall.code_words_added += file.lang_stats.code_words_added; + overall.code_words_removed += file.lang_stats.code_words_removed; + + // Aggregate per language + let entry = lang_map.entry(file.language.clone()).or_default(); + entry.total_added += file.lang_stats.total_added; + entry.total_removed += file.lang_stats.total_removed; + entry.pure_added += file.lang_stats.pure_added; + entry.pure_removed += file.lang_stats.pure_removed; + entry.comment_lines_added += file.lang_stats.comment_lines_added; + entry.comment_lines_removed += file.lang_stats.comment_lines_removed; + entry.docstring_lines_added += file.lang_stats.docstring_lines_added; + entry.docstring_lines_removed += file.lang_stats.docstring_lines_removed; + entry.blank_lines_added += file.lang_stats.blank_lines_added; + entry.blank_lines_removed += file.lang_stats.blank_lines_removed; + entry.code_words_added += file.lang_stats.code_words_added; + entry.code_words_removed += file.lang_stats.code_words_removed; } -} -fn print_human(stats: &HashMap, use_emojis: bool, use_color: bool) { - // Aggregate overall stats - let mut overall = LangStats::default(); + let complexity = calculate_complexity(&overall); + let token_estimate = estimate_tokens(overall.code_words_added); - let mut languages: Vec<_> = stats.keys().collect(); - languages.sort(); - - for lang in &languages { - if let Some(s) = stats.get(*lang) { - overall.total_added += s.total_added; - overall.total_removed += s.total_removed; - overall.pure_added += s.pure_added; - overall.pure_removed += s.pure_removed; - overall.comment_lines_added += s.comment_lines_added; - overall.comment_lines_removed += s.comment_lines_removed; - overall.docstring_lines_added += s.docstring_lines_added; - overall.docstring_lines_removed += s.docstring_lines_removed; - overall.blank_lines_added += s.blank_lines_added; - overall.blank_lines_removed += s.blank_lines_removed; - overall.code_words_added += s.code_words_added; - overall.code_words_removed += s.code_words_removed; + match format { + OutputFormat::Json => { + let result = AnalysisResult { + summary: overall, + language_stats: lang_map, + file_stats: if per_file { Some(stats.to_vec()) } else { None }, + complexity_score: complexity, + token_estimate, + mode: mode.to_string(), + }; + if let Ok(json) = serde_json::to_string_pretty(&result) { + println!("{}", json); + } } - } + OutputFormat::Human | OutputFormat::Plain => { + let use_color = !ci && format == OutputFormat::Human; - let net_total = overall.net_total(); - let net_pure = overall.net_pure(); - - // Ratios - let total_changes = overall.total_added + overall.total_removed; - let pure_changes = overall.pure_added + overall.pure_removed; + if use_color { + print_human_report( + stats, + &overall, + &lang_map, + per_file, + complexity, + token_estimate, + ); + } else { + print_plain_report( + stats, + &overall, + &lang_map, + per_file, + complexity, + token_estimate, + ); + } + } + } - let pure_ratio = if total_changes > 0 { - (pure_changes as f64 / total_changes as f64) * 100.0 - } else { - 0.0 - }; - let noise_ratio = 100.0 - pure_ratio; - - let e_pure = if use_emojis { "โœ… " } else { "" }; - let e_noise = if use_emojis { "๐Ÿงน " } else { "" }; - let e_summary = if use_emojis { "๐Ÿ“Š " } else { "" }; - let e_sparkles = if use_emojis { "โœจ " } else { "" }; - let e_comments = if use_emojis { "๐Ÿ“ " } else { "" }; - let e_docs = if use_emojis { "๐Ÿ“š " } else { "" }; - - if use_color { - println!("{}", format!("{}=== PureCode Summary ===", e_summary).bold()); - } else { - println!("{}=== PureCode Summary ===", e_summary); + if ci { + // Print summary line + let total_changes = overall.total_added + overall.total_removed; + let pure_changes = overall.pure_added + overall.pure_removed; + let noise_ratio = if total_changes > 0 { + 1.0 - (pure_changes as f64 / total_changes as f64) + } else { + 0.0 + }; + + println!("PURECODE_SUMMARY noise_ratio={:.2} pure_added={} pure_removed={} files_changed={} complexity={:.2}", + noise_ratio, + overall.pure_added, + overall.pure_removed, + stats.len(), + complexity + ); } +} +fn print_human_report( + files: &[FileStats], + overall: &LangStats, + lang_map: &HashMap, + per_file: bool, + complexity: f64, + tokens: u64, +) { + println!("{}", "PureCode Analysis Report".bold().underline()); + println!("Total Files: {}", files.len()); + println!("Net Pure Lines: {}", overall.net_pure().to_string().cyan()); println!( - "TOTAL lines : +{:<4} -{:<4} (net {})", - overall.total_added, overall.total_removed, net_total + "Review Complexity: {:.1} ({})", + complexity, + complexity_bucket(complexity) ); - println!( - "PURE lines : +{:<4} -{:<4} (net {})", - overall.pure_added, overall.pure_removed, net_pure - ); - println!( - "NOISE lines : +{:<4} -{:<4} (comments/docstrings/blanks)", - overall.total_added - overall.pure_added, - overall.total_removed - overall.pure_removed - ); - println!(); + println!("Estimated Tokens (Added): {}", tokens); - if use_color { - println!( - "{}{}", - e_sparkles, - format!("Pure ratio : {:.0}% of changes are pure code", pure_ratio).green() - ); - let noise_msg = format!("Noise : {:.0}% comments & formatting", noise_ratio); - if noise_ratio > 50.0 { - println!("{}{}", e_noise, noise_msg.yellow()); - } else { - println!("{}{}", e_noise, noise_msg.dimmed()); - } - } else { - println!( - "{}Pure ratio : {:.0}% of changes are pure code", - e_sparkles, pure_ratio - ); + println!("\n{}", "Language Breakdown:".bold()); + let mut sorted_langs: Vec<_> = lang_map.iter().collect(); + sorted_langs.sort_by_key(|(k, _)| *k); + + for (lang, stat) in sorted_langs { println!( - "{}Noise : {:.0}% comments & formatting", - e_noise, noise_ratio + " {:<12} | Pure: {:>4} | Added: {:>4} | Removed: {:>4} | Noise: {:>4}", + lang.blue(), + stat.net_pure(), + stat.pure_added.to_string().green(), + stat.pure_removed.to_string().red(), + (stat.noise_added() + stat.noise_removed()) ); } - println!(); - - if use_color { - println!("{}", "=== Per language ===".bold()); - } else { - println!("=== Per language ==="); - } - for lang in languages { - if let Some(s) = stats.get(lang) { - if s.total_added == 0 && s.total_removed == 0 { - continue; - } - - if use_color { - println!("{}", lang.cyan().bold()); - } else { - println!("{}", lang); - } - - println!( - " TOTAL : +{:<4} -{:<4} (net {})", - s.total_added, - s.total_removed, - s.net_total() - ); + if per_file { + println!("\n{}", "File Details:".bold()); + for file in files { println!( - " PURE : +{:<4} -{:<4} (net {}) {}", - s.pure_added, - s.pure_removed, - s.net_pure(), - e_pure + " {:<30} [{}] | Pure: {:>3}", + file.path, + file.language.yellow(), + file.lang_stats.net_pure() ); - - if s.comment_lines_added > 0 || s.comment_lines_removed > 0 { - println!( - " Comments : +{:<4} -{:<4} {}", - s.comment_lines_added, s.comment_lines_removed, e_comments - ); - } - if s.docstring_lines_added > 0 || s.docstring_lines_removed > 0 { - println!( - " Docstrings : +{:<4} -{:<4} {}", - s.docstring_lines_added, s.docstring_lines_removed, e_docs - ); - } - if s.blank_lines_added > 0 || s.blank_lines_removed > 0 { - println!( - " Blanks : +{:<4} -{:<4}", - s.blank_lines_added, s.blank_lines_removed - ); - } - - let net_words = s.code_words_added - s.code_words_removed; - let est_tokens_net = s.estimated_tokens_added() - s.estimated_tokens_removed(); - - println!( - " Words : +{:<4} -{:<4} (net {} words, est. ~{} tokens)", - s.code_words_added, s.code_words_removed, net_words, est_tokens_net - ); - println!(); } } + println!(); } -fn print_json(stats: &HashMap) { - // Simple manual JSON construction to avoid adding serde/serde_json dependencies for now. - // If complex, we should add serde. +fn print_plain_report( + files: &[FileStats], + overall: &LangStats, + lang_map: &HashMap, + per_file: bool, + complexity: f64, + tokens: u64, +) { + println!("PureCode Analysis Report"); + println!("Total Files: {}", files.len()); + println!("Net Pure Lines: {}", overall.net_pure()); + println!( + "Review Complexity: {:.1} ({})", + complexity, + complexity_bucket(complexity) + ); + println!("Estimated Tokens (Added): {}", tokens); - // Overall aggregation - let mut overall = LangStats::default(); - for s in stats.values() { - overall.total_added += s.total_added; - overall.total_removed += s.total_removed; - overall.pure_added += s.pure_added; - overall.pure_removed += s.pure_removed; - overall.comment_lines_added += s.comment_lines_added; - overall.comment_lines_removed += s.comment_lines_removed; - overall.docstring_lines_added += s.docstring_lines_added; - overall.docstring_lines_removed += s.docstring_lines_removed; - overall.blank_lines_added += s.blank_lines_added; - overall.blank_lines_removed += s.blank_lines_removed; - overall.code_words_added += s.code_words_added; - overall.code_words_removed += s.code_words_removed; + println!("\nLanguage Breakdown:"); + let mut sorted_langs: Vec<_> = lang_map.iter().collect(); + sorted_langs.sort_by_key(|(k, _)| *k); + + for (lang, stat) in sorted_langs { + println!( + " {:<12} | Pure: {:>4} | Added: {:>4} | Removed: {:>4} | Noise: {:>4}", + lang, + stat.net_pure(), + stat.pure_added, + stat.pure_removed, + (stat.noise_added() + stat.noise_removed()) + ); } - println!("{{"); - println!(" \"overall\": {{"); - print_json_stats_fields(&overall, 4); - println!(" }},"); - println!(" \"languages\": {{"); - - let mut languages: Vec<_> = stats.keys().collect(); - languages.sort(); - - for (i, lang) in languages.iter().enumerate() { - let s = stats.get(*lang).unwrap(); - println!(" \"{}\": {{", lang.to_lowercase()); - print_json_stats_fields(s, 6); - if i < languages.len() - 1 { - println!(" }},"); - } else { - println!(" }}"); + if per_file { + println!("\nFile Details:"); + for file in files { + println!( + " {:<30} [{}] | Pure: {:>3}", + file.path, + file.language, + file.lang_stats.net_pure() + ); } } - - println!(" }}"); - println!("}}"); + println!(); } -fn print_json_stats_fields(s: &LangStats, indent: usize) { - let pad = " ".repeat(indent); - println!("{}\"total_added\": {},", pad, s.total_added); - println!("{}\"total_removed\": {},", pad, s.total_removed); - println!("{}\"pure_added\": {},", pad, s.pure_added); - println!("{}\"pure_removed\": {},", pad, s.pure_removed); - println!("{}\"comment_added\": {},", pad, s.comment_lines_added); - println!("{}\"comment_removed\": {},", pad, s.comment_lines_removed); - println!("{}\"docstring_added\": {},", pad, s.docstring_lines_added); - println!("{}\"docstring_removed\": {},", pad, s.docstring_lines_removed); - println!("{}\"blank_added\": {},", pad, s.blank_lines_added); - println!("{}\"blank_removed\": {},", pad, s.blank_lines_removed); - println!("{}\"words_added\": {},", pad, s.code_words_added); - println!("{}\"words_removed\": {},", pad, s.code_words_removed); - println!("{}\"tokens_added_est\": {},", pad, s.estimated_tokens_added()); - println!("{}\"tokens_removed_est\": {}", pad, s.estimated_tokens_removed()); +fn complexity_bucket(score: f64) -> &'static str { + if score < 50.0 { + "light" + } else if score < 200.0 { + "medium" + } else { + "heavy" + } } diff --git a/src/stats.rs b/src/stats.rs index c226287..8a15439 100644 --- a/src/stats.rs +++ b/src/stats.rs @@ -1,38 +1,85 @@ -#[derive(Debug, Default, Clone)] +use serde::Serialize; +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize, Default)] +pub struct AnalysisResult { + pub summary: LangStats, + pub language_stats: HashMap, // Keyed by Language::to_string() + #[serde(skip_serializing_if = "Option::is_none")] + pub file_stats: Option>, + pub complexity_score: f64, + pub token_estimate: u64, + pub mode: String, // "diff" or "snapshot" +} + +#[derive(Debug, Clone, Serialize)] +pub struct FileStats { + pub path: String, + pub language: String, // String for serialization, but internal logic uses Language + pub lang_stats: LangStats, +} + +#[derive(Debug, Default, Clone, Copy, Serialize)] pub struct LangStats { pub total_added: i64, pub total_removed: i64, - pub pure_added: i64, pub pure_removed: i64, - pub comment_lines_added: i64, pub comment_lines_removed: i64, - pub docstring_lines_added: i64, pub docstring_lines_removed: i64, - pub blank_lines_added: i64, pub blank_lines_removed: i64, - pub code_words_added: i64, pub code_words_removed: i64, } impl LangStats { - pub fn net_total(&self) -> i64 { - self.total_added - self.total_removed - } - pub fn net_pure(&self) -> i64 { self.pure_added - self.pure_removed } - pub fn estimated_tokens_added(&self) -> i64 { - (self.code_words_added as f64 * 1.3).round() as i64 + pub fn noise_added(&self) -> i64 { + self.comment_lines_added + self.docstring_lines_added + self.blank_lines_added + } + + pub fn noise_removed(&self) -> i64 { + self.comment_lines_removed + self.docstring_lines_removed + self.blank_lines_removed } +} + +pub fn calculate_complexity(stats: &LangStats) -> f64 { + // complexity = pure_added * 1.0 + pure_removed * 0.5 + noise_added * 0.1 + noise_removed * 0.05 + (stats.pure_added as f64 * 1.0) + + (stats.pure_removed as f64 * 0.5) + + (stats.noise_added() as f64 * 0.1) + + (stats.noise_removed() as f64 * 0.05) +} + +pub fn estimate_tokens(word_count: i64) -> u64 { + (word_count as f64 * 1.3).round() as u64 +} + +#[derive(Debug)] +pub enum ThresholdError { + NoiseRatioExceeded { actual: f64, max: f64 }, + MinPureLines { actual: i64, min: i64 }, + PureLinesDecreased { actual: i64 }, +} - pub fn estimated_tokens_removed(&self) -> i64 { - (self.code_words_removed as f64 * 1.3).round() as i64 +impl std::fmt::Display for ThresholdError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ThresholdError::NoiseRatioExceeded { actual, max } => { + write!(f, "Noise ratio {:.2} exceeds limit {:.2}", actual, max) + } + ThresholdError::MinPureLines { actual, min } => { + write!(f, "Net pure lines {} is less than minimum {}", actual, min) + } + ThresholdError::PureLinesDecreased { actual } => { + write!(f, "Net pure code decreased ({})", actual) + } + } } } diff --git a/src/tests.rs b/src/tests.rs new file mode 100644 index 0000000..ad002b7 --- /dev/null +++ b/src/tests.rs @@ -0,0 +1,40 @@ +#[cfg(test)] +mod tests { + use crate::classifier::{get_classifier, LineType}; + use crate::language::Language; + use std::fs; + + #[test] + fn test_python_fixture() { + let content = fs::read_to_string("src/tests/fixtures/python.py").unwrap(); + let mut classifier = get_classifier(Language::Python); + let lines: Vec<&str> = content.lines().collect(); + + assert_eq!(classifier.classify(lines[0]), LineType::Pure); // def hello(): + assert_eq!(classifier.classify(lines[1]), LineType::Pure); // print + assert_eq!(classifier.classify(lines[2]), LineType::Blank); // empty + assert_eq!(classifier.classify(lines[3]), LineType::Comment); // # comment + assert_eq!(classifier.classify(lines[4]), LineType::Docstring); // """ + assert_eq!(classifier.classify(lines[5]), LineType::Docstring); // This is a + assert_eq!(classifier.classify(lines[6]), LineType::Docstring); // multiline docstring + assert_eq!(classifier.classify(lines[7]), LineType::Docstring); // """ + assert_eq!(classifier.classify(lines[8]), LineType::Pure); // x = 1 + } + + #[test] + fn test_html_fixture() { + let content = fs::read_to_string("src/tests/fixtures/test.html").unwrap(); + let mut classifier = get_classifier(Language::Html); + let lines: Vec<&str> = content.lines().collect(); + + assert_eq!(classifier.classify(lines[0]), LineType::Comment); // + assert_eq!(classifier.classify(lines[1]), LineType::Pure); //
+ assert_eq!(classifier.classify(lines[2]), LineType::Pure); //

+ assert_eq!(classifier.classify(lines[3]), LineType::Pure); //

+ assert_eq!(classifier.classify(lines[4]), LineType::Pure); //

+ assert_eq!(classifier.classify(lines[5]), LineType::Comment); // + } +} diff --git a/src/tests/fixtures/python.py b/src/tests/fixtures/python.py new file mode 100644 index 0000000..19f3e5d --- /dev/null +++ b/src/tests/fixtures/python.py @@ -0,0 +1,9 @@ +def hello(): + print("Hello") + +# This is a comment +""" +This is a +multiline docstring +""" +x = 1 diff --git a/src/tests/fixtures/test.html b/src/tests/fixtures/test.html new file mode 100644 index 0000000..59e24dd --- /dev/null +++ b/src/tests/fixtures/test.html @@ -0,0 +1,9 @@ + +
+

Hello

+

Text

+
+