From ccc769e671d18751058b998d9b997e0b7b9e6185 Mon Sep 17 00:00:00 2001 From: Joshua Clayton Date: Thu, 10 Dec 2020 15:59:20 -0500 Subject: [PATCH] Initial Release What? ===== This is an initial release of the CLI tool, `complexity`. --- .github/workflows/ci.yml | 19 ++++ .github/workflows/daily-audit.yml | 12 +++ .github/workflows/release.yml | 149 ++++++++++++++++++++++++++++++ .gitignore | 1 + CODE_OF_CONDUCT.md | 6 ++ Cargo.lock | 145 +++++++++++++++++++++++++++++ Cargo.toml | 11 +++ LICENSE | 7 ++ README.md | 35 +++++++ src/complexity_score.rs | 82 ++++++++++++++++ src/files_filter.rs | 45 +++++++++ src/lib.rs | 7 ++ src/main.rs | 49 +++++----- src/parsed_file.rs | 52 +++++++++++ src/parser.rs | 53 +++++++++++ 15 files changed, 647 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/daily-audit.yml create mode 100644 .github/workflows/release.yml create mode 100644 CODE_OF_CONDUCT.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 src/complexity_score.rs create mode 100644 src/files_filter.rs create mode 100644 src/lib.rs create mode 100644 src/parsed_file.rs create mode 100644 src/parser.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c3654a3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Build + run: cargo build --verbose + - name: Run tests + run: cargo test --all --verbose + - name: Verify formatting + run: cargo fmt -- --check diff --git a/.github/workflows/daily-audit.yml b/.github/workflows/daily-audit.yml new file mode 100644 index 0000000..8fd8458 --- /dev/null +++ b/.github/workflows/daily-audit.yml @@ -0,0 +1,12 @@ +name: Security audit +on: + schedule: + - cron: '0 0 * * *' +jobs: + audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions-rs/audit-check@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7cb7cf4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,149 @@ +name: release +on: + push: + tags: + - '[0-9]+.[0-9]+.[0-9]+' + - '[0-9]+.[0-9]+.[0-9]+.alpha' + - '[0-9]+.[0-9]+.[0-9]+.beta' + - '[0-9]+.[0-9]+.[0-9]+.rc' +jobs: + create-release: + name: create-release + runs-on: ubuntu-latest + # env: + # Set to force version number, e.g., when no tag exists. + # COMPLEXITY_VERSION: TEST-0.0.0 + steps: + - name: Create artifacts directory + run: mkdir artifacts + + - name: Get the release version from the tag + if: env.COMPLEXITY_VERSION == '' + run: | + # Apparently, this is the right way to get a tag name. Really? + # + # See: https://github.community/t5/GitHub-Actions/How-to-get-just-the-tag-name/m-p/32167/highlight/true#M1027 + echo "::set-env name=COMPLEXITY_VERSION::${GITHUB_REF#refs/tags/}" + echo "version is: ${{ env.COMPLEXITY_VERSION }}" + + - name: Create GitHub release + id: release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ env.COMPLEXITY_VERSION }} + release_name: ${{ env.COMPLEXITY_VERSION }} + + - name: Save release upload URL to artifact + run: echo "${{ steps.release.outputs.upload_url }}" > artifacts/release-upload-url + + - name: Save version number to artifact + run: echo "${{ env.COMPLEXITY_VERSION }}" > artifacts/release-version + + - name: Upload artifacts + uses: actions/upload-artifact@v1 + with: + name: artifacts + path: artifacts + + build-release: + name: build-release + needs: ['create-release'] + runs-on: ${{ matrix.os }} + env: + # For some builds, we use cross to test on 32-bit and big-endian + # systems. + CARGO: cargo + # When CARGO is set to CROSS, this is set to `--target matrix.target`. + TARGET_FLAGS: + # When CARGO is set to CROSS, TARGET_DIR includes matrix.target. + TARGET_DIR: ./target + # Emit backtraces on panics. + RUST_BACKTRACE: 1 + strategy: + matrix: + build: [linux, macos] + include: + - build: linux + os: ubuntu-18.04 + rust: stable + target: x86_64-unknown-linux-musl + - build: macos + os: macos-latest + rust: stable + target: x86_64-apple-darwin + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + with: + fetch-depth: 1 + + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: ${{ matrix.rust }} + profile: minimal + override: true + target: ${{ matrix.target }} + + - name: Use Cross + run: | + # FIXME: to work around bugs in latest cross release, install master. + # See: https://github.com/rust-embedded/cross/issues/357 + cargo install --git https://github.com/rust-embedded/cross + echo "::set-env name=CARGO::cross" + echo "::set-env name=TARGET_FLAGS::--target ${{ matrix.target }}" + echo "::set-env name=TARGET_DIR::./target/${{ matrix.target }}" + + - name: Show command used for Cargo + run: | + echo "cargo command is: ${{ env.CARGO }}" + echo "target flag is: ${{ env.TARGET_FLAGS }}" + echo "target dir is: ${{ env.TARGET_DIR }}" + + - name: Get release download URL + uses: actions/download-artifact@v1 + with: + name: artifacts + path: artifacts + + - name: Set release upload URL and release version + shell: bash + run: | + release_upload_url="$(cat artifacts/release-upload-url)" + echo "::set-env name=RELEASE_UPLOAD_URL::$release_upload_url" + echo "release upload url: $RELEASE_UPLOAD_URL" + release_version="$(cat artifacts/release-version)" + echo "::set-env name=RELEASE_VERSION::$release_version" + echo "release version: $RELEASE_VERSION" + + - name: Build release binary + run: ${{ env.CARGO }} build --verbose --release --bin complexity ${{ env.TARGET_FLAGS }} + + - name: Strip release binary (Linux and macOS) + if: matrix.build == 'linux' || matrix.build == 'macos' + run: strip "target/${{ matrix.target }}/release/complexity" + + - name: Build archive + shell: bash + run: | + staging="complexity-${{ env.RELEASE_VERSION }}-${{ matrix.target }}" + mkdir -p "$staging" + + cp {README.md,LICENSE} "$staging/" + + cp "target/${{ matrix.target }}/release/complexity" "$staging/" + tar czf "$staging.tar.gz" "$staging" + echo "::set-env name=ASSET::$staging.tar.gz" + + - name: Upload release archive + uses: actions/upload-release-asset@v1.0.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ env.RELEASE_UPLOAD_URL }} + asset_path: ${{ env.ASSET }} + asset_name: ${{ env.ASSET }} + asset_content_type: application/octet-stream diff --git a/.gitignore b/.gitignore index ea8c4bf..5c919d3 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +tmp diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..65ff724 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,6 @@ +# Code of Conduct + +By participating in this project, you agree to abide by the +[thoughtbot code of conduct][1]. + +[1]: https://thoughtbot.com/open-source-code-of-conduct diff --git a/Cargo.lock b/Cargo.lock index 73e9d34..023eec0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9,12 +9,45 @@ dependencies = [ "memchr", ] +[[package]] +name = "approx" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f2a05fd1bd10b2527e20a2cd32d8873d115b8b39fe219ee25f42a8aca6ba278" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arrayvec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" + [[package]] name = "autocfg" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" +[[package]] +name = "bitflags" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" + +[[package]] +name = "bitvec" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7ba35e9565969edb811639dbebfe34edc0368e472c5018474c8eb2543397f81" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "bstr" version = "0.2.14" @@ -24,6 +57,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "cc" +version = "1.0.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c0496836a84f8d0495758516b8621a622beb77c0fed418570e50764093ced48" + [[package]] name = "cfg-if" version = "0.1.10" @@ -36,11 +75,23 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "cmake" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb6210b637171dfba4cda12e579ac6dc73f5165ad56133e5d72ef3131f320855" +dependencies = [ + "cc", +] + [[package]] name = "complexity" version = "0.1.0" dependencies = [ + "approx", "ignore", + "mimalloc", + "nom", ] [[package]] @@ -60,6 +111,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "funty" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ba62103ce691c2fd80fbae2213dfdda9ce60804973ac6b6e97de818ea7f52c8" + [[package]] name = "globset" version = "0.4.6" @@ -97,6 +154,28 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +[[package]] +name = "lexical-core" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db65c6da02e61f55dae90a0ae427b2a5f6b3e8db09f58d10efab23af92592616" +dependencies = [ + "arrayvec", + "bitflags", + "cfg-if 0.1.10", + "ryu", + "static_assertions", +] + +[[package]] +name = "libmimalloc-sys" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151ff13433c4d403cb15d0e6fbda14b24d65bd1a5b33f7d52ec983cc00752d" +dependencies = [ + "cmake", +] + [[package]] name = "log" version = "0.4.11" @@ -112,6 +191,42 @@ version = "2.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee1c47aaa256ecabcaea351eae4a9b01ef39ed810004e298d2511ed284b1525" +[[package]] +name = "mimalloc" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5d2c9cb18f9cdc6d88f4aca6d3d8ea89c4c8202d6facfc7e56efdee97b80fa" +dependencies = [ + "libmimalloc-sys", +] + +[[package]] +name = "nom" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88034cfd6b4a0d54dd14f4a507eceee36c0b70e5a02236c4e4df571102be17f0" +dependencies = [ + "bitvec", + "lexical-core", + "memchr", + "version_check", +] + +[[package]] +name = "num-traits" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" +dependencies = [ + "autocfg", +] + +[[package]] +name = "radium" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "941ba9d78d8e2f7ce474c015eea4d9c6d25b6a3327f9832ee29a4de27f91bbb8" + [[package]] name = "regex" version = "1.4.2" @@ -130,6 +245,12 @@ version = "0.6.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b181ba2dcf07aaccad5448e8ead58db5b742cf85dfe035e2227f137a539a189" +[[package]] +name = "ryu" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" + [[package]] name = "same-file" version = "1.0.6" @@ -139,6 +260,18 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "tap" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36474e732d1affd3a6ed582781b3683df3d0563714c59c39591e8ff707cf078e" + [[package]] name = "thread_local" version = "1.0.1" @@ -148,6 +281,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "version_check" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a972e5669d67ba988ce3dc826706fb0a8b01471c088cb0b6110b805cc36aed" + [[package]] name = "walkdir" version = "2.3.1" @@ -189,3 +328,9 @@ name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "wyz" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85e60b0d1b5f99db2556934e21937020776a5d31520bf169e851ac44e6420214" diff --git a/Cargo.toml b/Cargo.toml index 1d1eaa9..088259a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,8 +3,19 @@ name = "complexity" version = "0.1.0" authors = ["Joshua Clayton "] edition = "2018" +license = "MIT" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] ignore = "0.4" +nom = "6" +mimalloc = { version = "*", default-features = false } + +[dev-dependencies] +approx = "0.4" + +[profile.release] +lto = "fat" +codegen-units = 1 +panic = "abort" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..39c98a2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,7 @@ +Copyright (c) 2020 Josh Clayton and thoughtbot, inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2077b79 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# `complexity` + +Calculate an approximation of code complexity per file in a language-agnostic way. + +## Motivation + +If you're new to a codebase, it's helpful to understand at a glance what files +may be particularly complex. With that guidance, developers can more quickly +read through the code to understand hotspots. + +At thoughtbot, we work in codebases of all shapes and languages, including +Ruby, Elixir, Python, Scala, TypeScript/JavaScript, Go, Elm, Swift, and Java. +This CLI tool aims to highlight complexity across any of these codebases by +assigning simple heuristics to increases in indentation. + +This concept has been discussed in [this paper]; `complexity` does not intend +to mimic approaches in this paper directly, although the motivations discussed +in the paper – especially avoiding calculating cyclomatic complexity ([McCabe]) +given requirements of AST parsing and analysis due to time and language +requirements – are of considerable overlap. + +[this paper]: https://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.68.3558&rep=rep1&type=pdf +[McCabe]: https://en.wikipedia.org/wiki/Cyclomatic_complexity + +## Usage + +Let's grab the 20 most complex files: + +```sh +complexity | sort -n --reverse | head -n 20 +``` + +## License + +Copyright 2020 Josh Clayton and thoughtbot, inc. See the [LICENSE](LICENSE). diff --git a/src/complexity_score.rs b/src/complexity_score.rs new file mode 100644 index 0000000..ed561b1 --- /dev/null +++ b/src/complexity_score.rs @@ -0,0 +1,82 @@ +pub struct ScoreConfig { + pub base_value: f32, + pub multiplier: f32, + pub minimum_line_length_of_file: usize, +} + +impl Default for ScoreConfig { + fn default() -> Self { + Self { + base_value: 1.0, + multiplier: 0.5, + minimum_line_length_of_file: 2, + } + } +} + +pub fn score(config: ScoreConfig, input: &[usize]) -> f32 { + let line_length = input.len(); + + if line_length < config.minimum_line_length_of_file { + return 0.0; + } + + let mut acc = 0.0; + let mut previous_line_whitespace = (None, config.base_value); + + for current_whitespace in input { + match previous_line_whitespace { + (None, score) => { + acc += score; + } + (Some(previous_whitespace), previous_score) => { + if previous_whitespace < *current_whitespace { + previous_line_whitespace.1 = config.base_value; + acc += previous_line_whitespace.1; + } else if previous_whitespace == *current_whitespace { + let new_score = previous_score * config.multiplier; + previous_line_whitespace.1 = new_score; + acc += previous_line_whitespace.1; + } + } + } + + previous_line_whitespace.0 = Some(*current_whitespace); + } + + acc.powf(2.0) / line_length as f32 +} + +#[cfg(test)] +mod tests { + use super::*; + use approx::*; + + #[test] + fn simple_case() { + assert!(abs_diff_eq!( + score(ScoreConfig::default(), &vec![0, 2, 0]), + 1.33333, + epsilon = 0.0001 + )); + } + + #[test] + fn complex_case() { + let lines = vec![ + 0, // base score + 2, // + base score + 2, // + base score * multiplier + 2, // + (base score * multiplier ^ 2) + 4, // + base_score + 4, // + base_score * multiplier + 2, // + 0 + 0, // + 0 + ]; + assert!(abs_diff_eq!( + score(ScoreConfig::default(), &lines), + 2.2578, + epsilon = 0.0001 + )); + } +} diff --git a/src/files_filter.rs b/src/files_filter.rs new file mode 100644 index 0000000..5c64895 --- /dev/null +++ b/src/files_filter.rs @@ -0,0 +1,45 @@ +use ignore::DirEntry; +use std::ffi::OsStr; + +pub struct FilesFilter<'a> { + ignored_extensions: Vec<&'a OsStr>, + ignored_paths: Vec<&'a str>, +} + +impl<'a> Default for FilesFilter<'a> { + fn default() -> Self { + Self { + ignored_extensions: vec![ + OsStr::new("json"), + OsStr::new("lock"), + OsStr::new("toml"), + OsStr::new("yml"), + OsStr::new("yaml"), + OsStr::new("md"), + OsStr::new("markdown"), + OsStr::new("xml"), + OsStr::new("svg"), + ], + ignored_paths: vec!["vendor"], + } + } +} + +impl<'a> FilesFilter<'a> { + pub fn matches(&self, entry: &DirEntry) -> bool { + self.approved_extension(entry.path()) && self.approved_path(entry.path()) + } + + fn approved_extension(&self, path: &std::path::Path) -> bool { + match path.extension() { + Some(ext) => !self.ignored_extensions.contains(&ext), + None => true, + } + } + + fn approved_path(&self, path: &std::path::Path) -> bool { + self.ignored_paths + .iter() + .all(|ignored| !path.to_str().unwrap_or("").contains(ignored)) + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..92f1970 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,7 @@ +mod complexity_score; +mod files_filter; +mod parsed_file; +mod parser; + +pub use files_filter::*; +pub use parsed_file::*; diff --git a/src/main.rs b/src/main.rs index 1a5eb34..4f83ebc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,34 +1,31 @@ -use ignore::Walk; -use std::fs::File; -use std::io::{BufReader, Read}; -use std::path::PathBuf; +#[global_allocator] +static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; -#[derive(Debug)] -struct ParsedFile { - path: PathBuf, - body: String, -} +use complexity::*; +use ignore::{DirEntry, WalkBuilder, WalkState}; fn main() { - println!("hello world"); + let mut builder = WalkBuilder::new("./"); + builder.filter_entry(|e| FilesFilter::default().matches(e)); + + builder.build_parallel().run(|| { + Box::new(|result| { + render_result(result); - Walk::new("./") - .filter_map(|result| { - result - .ok() - .and_then(|entry| get_file_contents(entry.path().to_path_buf()).ok()) + WalkState::Continue }) - .for_each(|v| println!("{:?}", v)); + }); } -fn get_file_contents(path: PathBuf) -> std::io::Result { - let file = File::open(&path)?; - let mut buf_reader = BufReader::new(file); - let mut contents = String::new(); - buf_reader.read_to_string(&mut contents)?; - - Ok(ParsedFile { - path, - body: contents, - }) +fn render_result(result: Result) { + if let Some(parsed_file) = result + .ok() + .and_then(|entry| ParsedFile::new(entry.path().to_path_buf()).ok()) + { + println!( + "{:>8} {}", + format!("{:.2}", parsed_file.complexity_score), + parsed_file.path.display() + ); + } } diff --git a/src/parsed_file.rs b/src/parsed_file.rs new file mode 100644 index 0000000..f149a34 --- /dev/null +++ b/src/parsed_file.rs @@ -0,0 +1,52 @@ +use crate::{complexity_score, parser}; +use std::convert::From; +use std::fs::File; +use std::io::{BufReader, Read}; +use std::path::PathBuf; + +#[derive(Debug)] +pub struct ParsedFile { + pub path: PathBuf, + stats: Vec, + pub complexity_score: f32, +} + +pub enum ParsedFileError { + IoError(std::io::Error), + IncompleteParse, + FailedParse, +} + +impl From for ParsedFileError { + fn from(err: std::io::Error) -> Self { + ParsedFileError::IoError(err) + } +} + +impl ParsedFile { + pub fn new(path: PathBuf) -> Result { + let contents = get_file_contents(&path)?; + let stats = match parser::parse_file(&contents) { + Ok(("", stats)) => Ok(stats), + Ok(_) => Err(ParsedFileError::IncompleteParse), + Err(_) => Err(ParsedFileError::FailedParse), + }?; + let complexity_score = + complexity_score::score(complexity_score::ScoreConfig::default(), &stats); + + Ok(ParsedFile { + path, + stats, + complexity_score, + }) + } +} + +fn get_file_contents(path: &PathBuf) -> std::io::Result { + let file = File::open(path)?; + let mut buf_reader = BufReader::new(file); + let mut contents = String::new(); + buf_reader.read_to_string(&mut contents)?; + + Ok(contents) +} diff --git a/src/parser.rs b/src/parser.rs new file mode 100644 index 0000000..e3fd298 --- /dev/null +++ b/src/parser.rs @@ -0,0 +1,53 @@ +use nom::{ + bytes::complete::{tag, take_till}, + character::complete::space0, + combinator::map, + multi::{many0, many1, separated_list1}, + sequence::pair, + sequence::terminated, + IResult, +}; + +pub fn parse_file(input: &str) -> IResult<&str, Vec> { + let (input, _) = many0(tag("\n"))(input)?; + map( + terminated( + separated_list1(many1(tag("\n")), parse_line), + many0(tag("\n")), + ), + |vs| { + vs.into_iter() + .filter_map(|(ws, text_present)| if text_present { Some(ws) } else { None }) + .collect::>() + }, + )(input) +} + +fn parse_line(input: &str) -> IResult<&str, (usize, bool)> { + pair( + map(space0, |ws: &str| ws.len()), + map(take_till(|c| c == '\n'), |v: &str| v.len() > 0), + )(input) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn counts_whitespace_correctly() { + assert_eq!(parse_line(" def full_name").unwrap().1, (4, true)); + assert_eq!(parse_line("class Person; end").unwrap().1, (0, true)); + assert_eq!(parse_line("").unwrap().1, (0, false)); + } + + #[test] + fn parses_a_file() { + assert_eq!( + parse_file("\n\nclass Person;end\n\nclass Dog;end\n\n") + .unwrap() + .1, + vec![0, 0] + ) + } +}