diff --git a/.cargo/config.toml b/.cargo/config.toml index 6248c98..3c31adf 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,4 +1,5 @@ [build] +jobs = 8 rustdocflags = ["--cfg", "docsrs"] rustflags = [] diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 2c2a299..f13fa06 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -4,7 +4,7 @@ on: push: branches: [main] pull_request: - branches: [main] + branches: ['*'] env: CARGO_TERM_COLOR: always @@ -42,3 +42,8 @@ jobs: with: command: test args: --all-features + + - name: Output test coverage + run: | + cargo install cargo-tarpaulin + cargo tarpaulin --fail-under 75 diff --git a/.gitignore b/.gitignore index b844f3e..dcc7a36 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ target debug.log release -node_modules \ No newline at end of file +node_modules +tarpaulin-report.html +build_rs_cov.profraw diff --git a/.vscode/settings.json b/.vscode/settings.json index 274e1cd..f587f50 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,7 @@ { - "rust-analyzer.linkedProjects": [".\\crates\\fta-wasm\\Cargo.toml"] -} + "rust-analyzer.linkedProjects": ["./crates/fta-wasm/Cargo.toml"], + "[rust]": { + "editor.defaultFormatter": "rust-lang.rust-analyzer", + "editor.formatOnSave": true + }, +} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 379d033..216a88a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,10 +14,11 @@ dependencies = [ [[package]] name = "ahash" -version = "0.7.6" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47" +checksum = "2c99f64d1e06488f620f932677e24bc6e2897582980441ae90a671415bd7ec2f" dependencies = [ + "cfg-if", "getrandom", "once_cell", "version_check", @@ -58,15 +59,15 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41ed9a86bf92ae6580e0a31281f65a1b1d867c0cc68d5346e2ae128dddfa6a7d" +checksum = "3a30da5c5f2d5e72842e00bcb57657162cdabef0931f40e2deb9b4140440cecd" [[package]] name = "anstyle-parse" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e765fd216e48e067936442276d1d57399e37bce53c264d6fefbe298080cb57ee" +checksum = "938874ff5980b03a87c5524b3ae5b59cf99b1d6bc836848df7bc5ada9643c333" dependencies = [ "utf8parse", ] @@ -92,15 +93,15 @@ dependencies = [ [[package]] name = "ast_node" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c704e2f6ee1a98223f5a7629a6ef0f3decb3b552ed282889dc957edff98ce1e6" +checksum = "c09c69dffe06d222d072c878c3afe86eee2179806f20503faec97250268b4c24" dependencies = [ "pmutil", "proc-macro2", "quote", "swc_macros_common", - "syn 1.0.109", + "syn 2.0.22", ] [[package]] @@ -122,9 +123,9 @@ checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" [[package]] name = "better_scoped_tls" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73e8ecdec39e98aa3b19e8cd0b8ed8f77ccb86a6b0b2dc7cd86d105438a2123" +checksum = "794edcc9b3fb07bb4aecaa11f093fd45663b4feadb782d68303a2268bc2701de" dependencies = [ "scoped-tls", ] @@ -137,9 +138,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.3.1" +version = "2.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6776fc96284a0bb647b615056fc496d1fe1644a7ab01829818a6d91cae888b84" +checksum = "630be753d4e58660abd17930c71b647fe46c27ea6b63cc59e1e3851406972e42" [[package]] name = "bstr" @@ -171,9 +172,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "clap" -version = "4.3.3" +version = "4.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca8f255e4b8027970e78db75e78831229c9815fdbfa67eb1a1b777a62e24b4a0" +checksum = "384e169cc618c613d5e3ca6404dda77a8685a63e08660dcc64abaf7da7cb0c7a" dependencies = [ "clap_builder", "clap_derive", @@ -182,13 +183,12 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.3.3" +version = "4.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acd4f3c17c83b0ba34ffbc4f8bbd74f079413f747f84a6f89292f138057e36ab" +checksum = "ef137bbe35aab78bdb468ccfba75a5f4d8321ae011d34063770780545176af2d" dependencies = [ "anstream", "anstyle", - "bitflags 1.3.2", "clap_lex", "strsim", ] @@ -202,7 +202,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.18", + "syn 2.0.22", ] [[package]] @@ -217,6 +217,43 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" +[[package]] +name = "comfy-table" +version = "7.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ab77dbd8adecaf3f0db40581631b995f312a8a5ae3aa9993188bb8f23d83a5b" +dependencies = [ + "crossterm", + "strum", + "strum_macros", + "unicode-width", +] + +[[package]] +name = "crossterm" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84cda67535339806297f1b331d6dd6320470d2a0fe65381e79ee9e156dd3d13" +dependencies = [ + "bitflags 1.3.2", + "crossterm_winapi", + "libc", + "mio", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "either" version = "1.8.1" @@ -283,14 +320,14 @@ dependencies = [ [[package]] name = "from_variant" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d449976075322384507443937df2f1d5577afbf4282f12a5a66ef29fa3e6307" +checksum = "03ec5dc38ee19078d84a692b1c41181ff9f94331c76cee66ff0208c770b5e54f" dependencies = [ "pmutil", "proc-macro2", "swc_macros_common", - "syn 1.0.109", + "syn 2.0.22", ] [[package]] @@ -298,6 +335,7 @@ name = "fta" version = "0.1.11" dependencies = [ "clap", + "comfy-table", "env_logger", "globset", "ignore", @@ -420,26 +458,25 @@ dependencies = [ [[package]] name = "is-macro" -version = "0.2.2" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7d079e129b77477a49c5c4f1cfe9ce6c2c909ef52520693e8e811a714c7b20" +checksum = "f4467ed1321b310c2625c5aa6c1b1ffc5de4d9e42668cf697a08fb033ee8265e" dependencies = [ "Inflector", "pmutil", "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.22", ] [[package]] name = "is-terminal" -version = "0.4.7" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adcf93614601c8129ddf72e2d5633df827ba6551541c6d8c59520a371475be1f" +checksum = "24fddda5af7e54bf7da53067d6e802dbcc381d0a8eef629df528e3ebf68755cb" dependencies = [ "hermit-abi 0.3.1", - "io-lifetimes", - "rustix", + "rustix 0.38.1", "windows-sys", ] @@ -530,9 +567,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.146" +version = "0.2.147" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f92be4933c13fd498862a9e02a3055f8a8d9c039ce33db97306fd5a6caa7f29b" +checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" [[package]] name = "linux-raw-sys" @@ -540,6 +577,12 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" +[[package]] +name = "linux-raw-sys" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09fc20d2ca12cb9f044c93e3bd6d32d523e6e2ec3db4f7b2939cd99026ecd3f0" + [[package]] name = "lock_api" version = "0.4.10" @@ -552,9 +595,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.18" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "518ef76f2f87365916b142844c16d8fefd85039bc5699050210a7778ee1cd1de" +checksum = "b06a4cde4c0f271a446782e3eff8de789548ce57dbc8eca9292c27f4a42004b4" [[package]] name = "memchr" @@ -562,6 +605,18 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" +[[package]] +name = "mio" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + [[package]] name = "new_debug_unreachable" version = "1.0.4" @@ -655,19 +710,19 @@ dependencies = [ [[package]] name = "pin-project-lite" -version = "0.2.9" +version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116" +checksum = "4c40d25201921e5ff0c862a505c6557ea88568a4e3ace775ab55e93f2f4f9d57" [[package]] name = "pmutil" -version = "0.5.3" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3894e5d549cccbe44afecf72922f277f603cd4bb0219c8342631ef18fffbe004" +checksum = "52a40bc70c2c58040d2d8b167ba9a5ff59fc9dab7ad44771cfde3dcfde7a09c6" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.22", ] [[package]] @@ -684,9 +739,9 @@ checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" [[package]] name = "proc-macro2" -version = "1.0.60" +version = "1.0.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec2b086b7a862cf4de201096214fa870344cf922b2b30c167badb3af3195406" +checksum = "7b368fba921b0dce7e60f5e04ec15e565b3303972b42bcfde1d0713b881959eb" dependencies = [ "unicode-ident", ] @@ -702,9 +757,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.28" +version = "1.0.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ab9c7eadfd8df19006f1cf1a4aed13540ed5cbc047010ece5826e10825488" +checksum = "573015e8ab27661678357f27dc26460738fd2b6c86e46f386fde94cb5d913105" dependencies = [ "proc-macro2", ] @@ -773,18 +828,37 @@ checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustix" -version = "0.37.19" +version = "0.37.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acf8729d8542766f1b2cf77eb034d52f40d375bb8b615d0b147089946e16613d" +checksum = "62f25693a73057a1b4cb56179dd3c7ea21a7c6c5ee7d85781f5749b46f34b79c" dependencies = [ "bitflags 1.3.2", "errno", "io-lifetimes", "libc", - "linux-raw-sys", + "linux-raw-sys 0.3.8", "windows-sys", ] +[[package]] +name = "rustix" +version = "0.38.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbc6396159432b5c8490d4e301d8c705f61860b8b6c863bf79942ce5401968f3" +dependencies = [ + "bitflags 2.3.3", + "errno", + "libc", + "linux-raw-sys 0.4.3", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f3208ce4d8448b3f3e7d168a73f5e0c43a61e32930de3bceeccedb388b6bf06" + [[package]] name = "ryu" version = "1.0.13" @@ -829,20 +903,50 @@ checksum = "d9735b638ccc51c28bf6914d90a2e9725b377144fc612c49a611fddd1b631d68" dependencies = [ "proc-macro2", "quote", - "syn 2.0.18", + "syn 2.0.22", ] [[package]] name = "serde_json" -version = "1.0.96" +version = "1.0.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "057d394a50403bcac12672b2b18fb387ab6d289d957dab67dd201875391e52f1" +checksum = "46266871c240a00b8f503b877622fe33430b3c7d963bdc0f2adc511e54a1eae3" dependencies = [ "itoa", "ryu", "serde", ] +[[package]] +name = "signal-hook" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732768f1176d21d09e076c23a93123d40bba92d50c4058da34d45c8de8e682b9" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +dependencies = [ + "libc", +] + [[package]] name = "siphasher" version = "0.3.10" @@ -919,15 +1023,15 @@ dependencies = [ [[package]] name = "string_enum" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0090512bdfee4b56d82480d66c0fd8a6f53f0fe0f97e075e949b252acdd482e0" +checksum = "8fa4d4f81d7c05b9161f8de839975d3326328b8ba2831164b465524cc2f55252" dependencies = [ "pmutil", "proc-macro2", "quote", "swc_macros_common", - "syn 1.0.109", + "syn 2.0.22", ] [[package]] @@ -936,6 +1040,25 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "strum" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f" + +[[package]] +name = "strum_macros" +version = "0.24.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e385be0d24f186b4ce2f9982191e7101bb737312ad61c1f2f984f34bcf85d59" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 1.0.109", +] + [[package]] name = "swc_atoms" version = "0.5.6" @@ -952,9 +1075,9 @@ dependencies = [ [[package]] name = "swc_common" -version = "0.31.12" +version = "0.31.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19c774005489d2907fb67909cf42af926e72edee1366512777c605ba2ef19c94" +checksum = "c6414bd4e553f5638961d39b07075ffd37a3d63176829592f4a5900260d94ca1" dependencies = [ "ahash", "ast_node", @@ -979,11 +1102,11 @@ dependencies = [ [[package]] name = "swc_ecma_ast" -version = "0.104.5" +version = "0.106.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5cf9dd351d0c285dcd36535267953a18995d4dda0cbe34ac9d1df61aa415b26" +checksum = "ebf4d6804b1da4146c4c0359d129e3dd43568d321f69d7953d9abbca4ded76ba" dependencies = [ - "bitflags 2.3.1", + "bitflags 2.3.3", "is-macro", "num-bigint", "scoped-tls", @@ -995,9 +1118,9 @@ dependencies = [ [[package]] name = "swc_ecma_parser" -version = "0.134.12" +version = "0.136.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0a3fcfe3d83dd445cbd9321882e47b467594433d9a21c4d6c37a27f534bb89e" +checksum = "45d40421c607d7a48334f78a9b24a5cbde1f36250f9986746ec082208d68b39f" dependencies = [ "either", "lexical", @@ -1015,9 +1138,9 @@ dependencies = [ [[package]] name = "swc_ecma_visit" -version = "0.90.5" +version = "0.92.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce3ac941ae1d6c7e683aa375fc71fbf58df58b441f614d757fbb10554936ca2" +checksum = "0f61da6cac0ec3b7e62d367cfbd9e38e078a4601271891ad94f0dac5ff69f839" dependencies = [ "num-bigint", "swc_atoms", @@ -1029,33 +1152,33 @@ dependencies = [ [[package]] name = "swc_eq_ignore_macros" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c20468634668c2bbab581947bb8c75c97158d5a6959f4ba33df20983b20b4f6" +checksum = "05a95d367e228d52484c53336991fdcf47b6b553ef835d9159db4ba40efb0ee8" dependencies = [ "pmutil", "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.22", ] [[package]] name = "swc_macros_common" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e582c3e3c2269238524923781df5be49e011dbe29cf7683a2215d600a562ea6" +checksum = "7a273205ccb09b51fabe88c49f3b34c5a4631c4c00a16ae20e03111d6a42e832" dependencies = [ "pmutil", "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.22", ] [[package]] name = "swc_visit" -version = "0.5.6" +version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f412dd4fbc58f509a04e64f5c8038333142fc139e8232f01b883db0094b3b51" +checksum = "e87c337fbb2d191bf371173dea6a957f01899adb8f189c6c31b122a6cfc98fc3" dependencies = [ "either", "swc_visit_macros", @@ -1063,16 +1186,16 @@ dependencies = [ [[package]] name = "swc_visit_macros" -version = "0.5.7" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cfc226380ba54a5feed2c12f3ccd33f1ae8e959160290e5d2d9b4e918b6472a" +checksum = "0f322730fb82f3930a450ac24de8c98523af7d34ab8cb2f46bcb405839891a99" dependencies = [ "Inflector", "pmutil", "proc-macro2", "quote", "swc_macros_common", - "syn 1.0.109", + "syn 2.0.22", ] [[package]] @@ -1088,9 +1211,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.18" +version = "2.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32d41677bcbe24c20c52e7c70b0d8db04134c5d1066bf98662e2871ad200ea3e" +checksum = "2efbeae7acf4eabd6bcdcbd11c92f45231ddda7539edc7806bd1a04a03b24616" dependencies = [ "proc-macro2", "quote", @@ -1107,7 +1230,7 @@ dependencies = [ "cfg-if", "fastrand", "redox_syscall", - "rustix", + "rustix 0.37.21", "windows-sys", ] @@ -1159,13 +1282,13 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.24" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f57e3ca2a01450b1a921183a9c9cbfda207fd822cef4ccb00a65402cbba7a74" +checksum = "5f4f31f56159e98206da9efd823404b79b6ef3143b4a7ab76e67b1751b25a4ab" dependencies = [ "proc-macro2", "quote", - "syn 2.0.18", + "syn 2.0.22", ] [[package]] @@ -1267,9 +1390,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" -version = "0.2.86" +version = "0.2.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bba0e8cb82ba49ff4e229459ff22a191bbe9a1cb3a341610c9c33efc27ddf73" +checksum = "7706a72ab36d8cb1f80ffbf0e071533974a60d0a308d01a5d0375bf60499a342" dependencies = [ "cfg-if", "wasm-bindgen-macro", @@ -1277,24 +1400,24 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.86" +version = "0.2.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19b04bc93f9d6bdee709f6bd2118f57dd6679cf1176a1af464fca3ab0d66d8fb" +checksum = "5ef2b6d3c510e9625e5fe6f509ab07d66a760f0885d858736483c32ed7809abd" dependencies = [ "bumpalo", "log", "once_cell", "proc-macro2", "quote", - "syn 2.0.18", + "syn 2.0.22", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.86" +version = "0.2.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14d6b024f1a526bb0234f52840389927257beb670610081360e5a03c5df9c258" +checksum = "dee495e55982a3bd48105a7b947fd2a9b4a8ae3010041b9e0faab3f9cd028f1d" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1302,22 +1425,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.86" +version = "0.2.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e128beba882dd1eb6200e1dc92ae6c5dbaa4311aa7bb211ca035779e5efc39f8" +checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.18", + "syn 2.0.22", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.86" +version = "0.2.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed9d5b4305409d1fc9482fee2d7f9bcbf24b3972bf59817ef757e23982242a93" +checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1" [[package]] name = "winapi" @@ -1361,9 +1484,9 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.48.0" +version = "0.48.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b1eb6f0cd7c80c79759c929114ef071b87354ce476d9d94271031c0497adfd5" +checksum = "05d4b17490f70499f20b9e791dcf6a299785ce8af4d709018206dc5b4953e95f" dependencies = [ "windows_aarch64_gnullvm", "windows_aarch64_msvc", diff --git a/MAINTENANCE.md b/MAINTENANCE.md index e1b3334..0309baf 100644 --- a/MAINTENANCE.md +++ b/MAINTENANCE.md @@ -52,3 +52,14 @@ This should be published manually. From the `crates/fta-wasm` directory: It's complex: the NPM package relies on the Rust crate binaries, which currently only get built in CI & uploaded to the GitHub release. A potential improvement on this publishing workflow is to keep hold of the binaries in CI and use them as input to an NPM package publish job. The NPM package version would also need bumping in this scenario. + +## Code Coverage + +Install and run `tarpaulin`: + +``` +cargo install cargo-tarpaulin +cargo tarpaulin +``` + +Note that `tarpaulin` is not installed as a build dependency, hence should be intsalled manually to generate coverage. diff --git a/crates/fta-wasm/src/lib.rs b/crates/fta-wasm/src/lib.rs index 4e95f38..fd5d209 100644 --- a/crates/fta-wasm/src/lib.rs +++ b/crates/fta-wasm/src/lib.rs @@ -1,4 +1,5 @@ -use fta::{analyze_file, parse_module::parse_module}; +use fta::analyze_file; +use fta::parse; use serde_json::{json, to_string, Value}; use std::collections::HashMap; use wasm_bindgen::prelude::*; @@ -10,7 +11,7 @@ mod lib_tests; pub fn analyze_file_wasm(source_code: &str, use_tsx: bool) -> String { let json_string; - match parse_module(source_code, use_tsx) { + match parse::parse_module(source_code, use_tsx) { (Ok(module), line_count) => { let (cyclo, halstead_metrics, fta_score) = analyze_file(&module, line_count); let mut analyzed: HashMap<&str, Value> = HashMap::new(); diff --git a/crates/fta-wasm/src/lib_tests.rs b/crates/fta-wasm/src/lib_tests.rs index f2d6038..2520399 100644 --- a/crates/fta-wasm/src/lib_tests.rs +++ b/crates/fta-wasm/src/lib_tests.rs @@ -1,5 +1,5 @@ #[cfg(test)] -mod lib_tests { +mod tests { use crate::analyze_file_wasm; use serde_json::{from_str, Value}; diff --git a/crates/fta/Cargo.toml b/crates/fta/Cargo.toml index 9f0a1a4..c275565 100644 --- a/crates/fta/Cargo.toml +++ b/crates/fta/Cargo.toml @@ -13,6 +13,7 @@ readme = "../../README.md" [dependencies] clap = { version = "4.2.7", features = ["derive"] } +comfy-table = "7.0.0" env_logger = "0.9" globset = "0.4" ignore = "0.4" @@ -20,7 +21,7 @@ log = "0.4" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" swc_common = "0.31.12" -swc_ecma_ast = "0.104.5" -swc_ecma_parser = "0.134.12" -swc_ecma_visit = "0.90.5" +swc_ecma_ast = "0.106.0" +swc_ecma_parser = "0.136.0" +swc_ecma_visit = "0.92.0" tempfile = "3.2.0" \ No newline at end of file diff --git a/crates/fta/src/complexity_tests.rs b/crates/fta/src/complexity_tests.rs deleted file mode 100644 index 7e6dfcd..0000000 --- a/crates/fta/src/complexity_tests.rs +++ /dev/null @@ -1,163 +0,0 @@ -#[cfg(test)] -use crate::complexity::cyclomatic_complexity; -use crate::parse_module::parse_module; - -use swc_ecma_ast::Module; - -fn parse(src: &str) -> Module { - match parse_module(src, false) { - (Ok(module), _line_count) => module, - (Err(_err), _) => { - panic!("failed"); - } - } -} - -#[test] -fn test_empty_module() { - let ts_code = r#" - /* Empty TypeScript code */ - "#; - let module = parse(ts_code); - assert_eq!(cyclomatic_complexity(module), 1); -} - -#[test] -fn test_single_if() { - let ts_code = r#" - if (x > 0) { - console.log("x is positive"); - } - "#; - let module = parse(ts_code); - assert_eq!(cyclomatic_complexity(module), 2); -} - -#[test] -fn test_if_else() { - let ts_code = r#" - if (x > 0) { - console.log("x is positive"); - } else { - console.log("x is not positive"); - } - "#; - let module = parse(ts_code); - assert_eq!(cyclomatic_complexity(module), 2); -} - -#[test] -fn test_nested_ifs() { - let ts_code = r#" - if (x > 0) { - if (x < 10) { - console.log("x is between 0 and 10"); - } - } else { - console.log("x is not positive"); - } - "#; - let module = parse(ts_code); - assert_eq!(cyclomatic_complexity(module), 3); -} - -#[test] -fn test_switch_case() { - let ts_code = r#" - switch (x) { - case 0: - console.log("x is 0"); - break; - case 1: - console.log("x is 1"); - break; - default: - console.log("x is not 0 or 1"); - } - "#; - let module = parse(ts_code); - assert_eq!(cyclomatic_complexity(module), 4); -} - -#[test] -fn test_for_loop() { - let ts_code = r#" - for (let i = 0; i < 10; i++) { - console.log(i); - } - "#; - let module = parse(ts_code); - assert_eq!(cyclomatic_complexity(module), 2); -} - -#[test] -fn test_while_loop() { - let ts_code = r#" - let i = 0; - while (i < 10) { - console.log(i); - i++; - } - "#; - let module = parse(ts_code); - assert_eq!(cyclomatic_complexity(module), 2); -} - -#[test] -fn test_do_while_loop() { - let ts_code = r#" - let i = 0; - do { - console.log(i); - i++; - } while (i < 10); - "#; - let module = parse(ts_code); - assert_eq!(cyclomatic_complexity(module), 2); -} - -#[test] -fn test_for_in_loop() { - let ts_code = r#" - let obj = { a: 1, b: 2, c: 3 }; - for (let key in obj) { - console.log(key, obj[key]); - } - "#; - let module = parse(ts_code); - assert_eq!(cyclomatic_complexity(module), 2); -} - -#[test] -fn test_for_of_loop() { - let ts_code = r#" - let arr = [1, 2, 3]; - for (let item of arr) { - console.log(item); - } - "#; - let module = parse(ts_code); - assert_eq!(cyclomatic_complexity(module), 2); -} - -#[test] -fn test_try_catch() { - let ts_code = r#" - try { - throw new Error("An error occurred"); - } catch (e) { - console.log(e.message); - } - "#; - let module = parse(ts_code); - assert_eq!(cyclomatic_complexity(module), 2); -} - -#[test] -fn test_conditional_expression() { - let ts_code = r#" - let result = x > 0 ? "positive" : "non-positive"; - "#; - let module = parse(ts_code); - assert_eq!(cyclomatic_complexity(module), 2); -} diff --git a/crates/fta/src/config.rs b/crates/fta/src/config/mod.rs similarity index 96% rename from crates/fta/src/config.rs rename to crates/fta/src/config/mod.rs index bcd8409..9870929 100644 --- a/crates/fta/src/config.rs +++ b/crates/fta/src/config/mod.rs @@ -3,7 +3,8 @@ use std::fs::File; use std::io::Read; use std::path::Path; -#[allow(dead_code)] +mod tests; + pub fn read_config(config_path: &str) -> FtaConfig { let default_config = FtaConfig { extensions: Some(vec![ diff --git a/crates/fta/src/config/tests.rs b/crates/fta/src/config/tests.rs new file mode 100644 index 0000000..4df1559 --- /dev/null +++ b/crates/fta/src/config/tests.rs @@ -0,0 +1,141 @@ +#[cfg(test)] +mod tests { + use crate::config::read_config; + use std::io::Write; + use tempfile::NamedTempFile; + + fn create_temp_file(content: &str) -> NamedTempFile { + let mut temp_file = NamedTempFile::new().unwrap(); + write!(temp_file, "{}", content).unwrap(); + temp_file + } + + #[test] + fn test_read_config_with_valid_json() { + let valid_json = r#" + { + "extensions": [".go"], + "exclude_filenames": [".tmp.go"], + "exclude_directories": ["/test"], + "output_limit": 2500, + "score_cap": 500 + } + "#; + + let temp_file = create_temp_file(valid_json); + let path = temp_file.path().to_str().unwrap(); + + let config = read_config(path); + + assert_eq!( + config.extensions, + Some(vec![ + ".js".to_string(), + ".jsx".to_string(), + ".ts".to_string(), + ".tsx".to_string(), + ".go".to_string() + ]) + ); + assert_eq!( + config.exclude_filenames, + Some(vec![ + ".d.ts".to_string(), + ".min.js".to_string(), + ".bundle.js".to_string(), + ".tmp.go".to_string() + ]) + ); + assert_eq!( + config.exclude_directories, + Some(vec![ + "/dist".to_string(), + "/bin".to_string(), + "/build".to_string(), + "/test".to_string() + ]) + ); + assert_eq!(config.output_limit, Some(2500)); + assert_eq!(config.score_cap, Some(500)); + } + + #[test] + fn test_read_config_with_partial_json() { + let partial_json = r#" + { + "extensions": [".go"], + "exclude_filenames": [".tmp.go"] + } + "#; + + let temp_file = create_temp_file(partial_json); + let path = temp_file.path().to_str().unwrap(); + + let config = read_config(path); + + assert_eq!( + config.extensions, + Some(vec![ + ".js".to_string(), + ".jsx".to_string(), + ".ts".to_string(), + ".tsx".to_string(), + ".go".to_string() + ]) + ); + assert_eq!( + config.exclude_filenames, + Some(vec![ + ".d.ts".to_string(), + ".min.js".to_string(), + ".bundle.js".to_string(), + ".tmp.go".to_string() + ]) + ); + assert_eq!( + config.exclude_directories, + Some(vec![ + "/dist".to_string(), + "/bin".to_string(), + "/build".to_string() + ]) + ); + assert_eq!(config.output_limit, Some(5000)); + assert_eq!(config.score_cap, Some(1000)); + } + + #[test] + fn test_read_config_with_nonexistent_file() { + let nonexistent_path = "nonexistent_file.json"; + + let config = read_config(nonexistent_path); + + assert_eq!( + config.extensions, + Some(vec![ + ".js".to_string(), + ".jsx".to_string(), + ".ts".to_string(), + ".tsx".to_string() + ]) + ); + assert_eq!( + config.exclude_filenames, + Some(vec![ + ".d.ts".to_string(), + ".min.js".to_string(), + ".bundle.js".to_string() + ]) + ); + assert_eq!( + config.exclude_directories, + Some(vec![ + "/dist".to_string(), + "/bin".to_string(), + "/build".to_string() + ]) + ); + assert_eq!(config.output_limit, Some(5000)); + assert_eq!(config.score_cap, Some(1000)); + } +} diff --git a/crates/fta/src/config_tests.rs b/crates/fta/src/config_tests.rs deleted file mode 100644 index 944fd16..0000000 --- a/crates/fta/src/config_tests.rs +++ /dev/null @@ -1,140 +0,0 @@ -#[cfg(test)] -use crate::config::read_config; -use std::io::Write; -use tempfile::NamedTempFile; - -#[allow(dead_code)] -fn create_temp_file(content: &str) -> NamedTempFile { - let mut temp_file = NamedTempFile::new().unwrap(); - write!(temp_file, "{}", content).unwrap(); - temp_file -} - -#[test] -fn test_read_config_with_valid_json() { - let valid_json = r#" - { - "extensions": [".go"], - "exclude_filenames": [".tmp.go"], - "exclude_directories": ["/test"], - "output_limit": 2500, - "score_cap": 500 - } - "#; - - let temp_file = create_temp_file(valid_json); - let path = temp_file.path().to_str().unwrap(); - - let config = read_config(path); - - assert_eq!( - config.extensions, - Some(vec![ - ".js".to_string(), - ".jsx".to_string(), - ".ts".to_string(), - ".tsx".to_string(), - ".go".to_string() - ]) - ); - assert_eq!( - config.exclude_filenames, - Some(vec![ - ".d.ts".to_string(), - ".min.js".to_string(), - ".bundle.js".to_string(), - ".tmp.go".to_string() - ]) - ); - assert_eq!( - config.exclude_directories, - Some(vec![ - "/dist".to_string(), - "/bin".to_string(), - "/build".to_string(), - "/test".to_string() - ]) - ); - assert_eq!(config.output_limit, Some(2500)); - assert_eq!(config.score_cap, Some(500)); -} - -#[test] -fn test_read_config_with_partial_json() { - let partial_json = r#" - { - "extensions": [".go"], - "exclude_filenames": [".tmp.go"] - } - "#; - - let temp_file = create_temp_file(partial_json); - let path = temp_file.path().to_str().unwrap(); - - let config = read_config(path); - - assert_eq!( - config.extensions, - Some(vec![ - ".js".to_string(), - ".jsx".to_string(), - ".ts".to_string(), - ".tsx".to_string(), - ".go".to_string() - ]) - ); - assert_eq!( - config.exclude_filenames, - Some(vec![ - ".d.ts".to_string(), - ".min.js".to_string(), - ".bundle.js".to_string(), - ".tmp.go".to_string() - ]) - ); - assert_eq!( - config.exclude_directories, - Some(vec![ - "/dist".to_string(), - "/bin".to_string(), - "/build".to_string() - ]) - ); - assert_eq!(config.output_limit, Some(5000)); - assert_eq!(config.score_cap, Some(1000)); -} - -#[test] -fn test_read_config_with_nonexistent_file() { - let nonexistent_path = "nonexistent_file.json"; - - let config = read_config(nonexistent_path); - - assert_eq!( - config.extensions, - Some(vec![ - ".js".to_string(), - ".jsx".to_string(), - ".ts".to_string(), - ".tsx".to_string() - ]) - ); - assert_eq!( - config.exclude_filenames, - Some(vec![ - ".d.ts".to_string(), - ".min.js".to_string(), - ".bundle.js".to_string() - ]) - ); - assert_eq!( - config.exclude_directories, - Some(vec![ - "/dist".to_string(), - "/bin".to_string(), - "/build".to_string() - ]) - ); - assert_eq!(config.output_limit, Some(5000)); - assert_eq!(config.score_cap, Some(1000)); -} diff --git a/crates/fta/src/complexity.rs b/crates/fta/src/cyclo/mod.rs similarity index 95% rename from crates/fta/src/complexity.rs rename to crates/fta/src/cyclo/mod.rs index c9b2566..5a65457 100644 --- a/crates/fta/src/complexity.rs +++ b/crates/fta/src/cyclo/mod.rs @@ -1,6 +1,8 @@ use swc_ecma_ast::*; use swc_ecma_visit::{Visit, VisitWith}; +mod tests; + struct ComplexityVisitor { complexity: usize, } @@ -69,7 +71,6 @@ impl Visit for ComplexityVisitor { } } -#[allow(dead_code)] pub fn cyclomatic_complexity(module: Module) -> usize { let mut visitor = ComplexityVisitor::new(); visitor.visit_module(&module); diff --git a/crates/fta/src/cyclo/tests.rs b/crates/fta/src/cyclo/tests.rs new file mode 100644 index 0000000..d439a10 --- /dev/null +++ b/crates/fta/src/cyclo/tests.rs @@ -0,0 +1,164 @@ +#[cfg(test)] +mod tests { + use crate::cyclo::cyclomatic_complexity; + use crate::parse::parse_module; + use swc_ecma_ast::Module; + + fn parse(src: &str) -> Module { + match parse_module(src, false) { + (Ok(module), _line_count) => module, + (Err(_err), _) => { + panic!("failed"); + } + } + } + + #[test] + fn test_empty_module() { + let ts_code = r#" + /* Empty TypeScript code */ + "#; + let module = parse(ts_code); + assert_eq!(cyclomatic_complexity(module), 1); + } + + #[test] + fn test_single_if() { + let ts_code = r#" + if (x > 0) { + console.log("x is positive"); + } + "#; + let module = parse(ts_code); + assert_eq!(cyclomatic_complexity(module), 2); + } + + #[test] + fn test_if_else() { + let ts_code = r#" + if (x > 0) { + console.log("x is positive"); + } else { + console.log("x is not positive"); + } + "#; + let module = parse(ts_code); + assert_eq!(cyclomatic_complexity(module), 2); + } + + #[test] + fn test_nested_ifs() { + let ts_code = r#" + if (x > 0) { + if (x < 10) { + console.log("x is between 0 and 10"); + } + } else { + console.log("x is not positive"); + } + "#; + let module = parse(ts_code); + assert_eq!(cyclomatic_complexity(module), 3); + } + + #[test] + fn test_switch_case() { + let ts_code = r#" + switch (x) { + case 0: + console.log("x is 0"); + break; + case 1: + console.log("x is 1"); + break; + default: + console.log("x is not 0 or 1"); + } + "#; + let module = parse(ts_code); + assert_eq!(cyclomatic_complexity(module), 4); + } + + #[test] + fn test_for_loop() { + let ts_code = r#" + for (let i = 0; i < 10; i++) { + console.log(i); + } + "#; + let module = parse(ts_code); + assert_eq!(cyclomatic_complexity(module), 2); + } + + #[test] + fn test_while_loop() { + let ts_code = r#" + let i = 0; + while (i < 10) { + console.log(i); + i++; + } + "#; + let module = parse(ts_code); + assert_eq!(cyclomatic_complexity(module), 2); + } + + #[test] + fn test_do_while_loop() { + let ts_code = r#" + let i = 0; + do { + console.log(i); + i++; + } while (i < 10); + "#; + let module = parse(ts_code); + assert_eq!(cyclomatic_complexity(module), 2); + } + + #[test] + fn test_for_in_loop() { + let ts_code = r#" + let obj = { a: 1, b: 2, c: 3 }; + for (let key in obj) { + console.log(key, obj[key]); + } + "#; + let module = parse(ts_code); + assert_eq!(cyclomatic_complexity(module), 2); + } + + #[test] + fn test_for_of_loop() { + let ts_code = r#" + let arr = [1, 2, 3]; + for (let item of arr) { + console.log(item); + } + "#; + let module = parse(ts_code); + assert_eq!(cyclomatic_complexity(module), 2); + } + + #[test] + fn test_try_catch() { + let ts_code = r#" + try { + throw new Error("An error occurred"); + } catch (e) { + console.log(e.message); + } + "#; + let module = parse(ts_code); + assert_eq!(cyclomatic_complexity(module), 2); + } + + #[test] + fn test_conditional_expression() { + let ts_code = r#" + let result = x > 0 ? "positive" : "non-positive"; + "#; + let module = parse(ts_code); + assert_eq!(cyclomatic_complexity(module), 2); + } +} diff --git a/crates/fta/src/halstead.rs b/crates/fta/src/halstead/mod.rs similarity index 70% rename from crates/fta/src/halstead.rs rename to crates/fta/src/halstead/mod.rs index 04ace6c..f88f272 100644 --- a/crates/fta/src/halstead.rs +++ b/crates/fta/src/halstead/mod.rs @@ -4,6 +4,8 @@ use std::collections::HashSet; use swc_ecma_ast::*; use swc_ecma_visit::{Visit, VisitWith}; +mod tests; + #[derive(Debug)] struct AstAnalyzer { unique_operators: HashSet, @@ -13,7 +15,6 @@ struct AstAnalyzer { } impl AstAnalyzer { - #[allow(dead_code)] fn new() -> Self { AstAnalyzer { unique_operators: HashSet::new(), @@ -169,13 +170,14 @@ impl Visit for AstAnalyzer { } } Expr::TsAs(ts_as) => { - self.unique_operators.insert("as".to_string()); + self.unique_operators.insert("TsAs".to_string()); self.total_operators += 1; ts_as.expr.visit_with(self); + ts_as.type_ann.visit_with(self); // No need to visit the type_ann as it doesn't contribute to operands or operators. } Expr::TsNonNull(ts_non_null) => { - self.unique_operators.insert("!".to_string()); + self.unique_operators.insert("TsNonNull".to_string()); self.total_operators += 1; ts_non_null.expr.visit_with(self); } @@ -213,8 +215,8 @@ impl Visit for AstAnalyzer { opt_chain.visit_with(self); } Expr::Seq(seq) => { - self.unique_operators.insert(",".to_string()); - self.total_operators += seq.exprs.len() - 1; // n-1 commas for n expressions + self.unique_operators.insert("seq".to_string()); + self.total_operators += 1; for expr in &seq.exprs { expr.visit_with(self); @@ -229,14 +231,6 @@ impl Visit for AstAnalyzer { self.unique_operands.insert("this".to_string()); self.total_operands += 1; } - Expr::Fn(fn_expr) => { - if let Some(ident) = &fn_expr.ident { - self.unique_operands.insert(ident.sym.to_string()); - self.total_operands += 1; - } - - fn_expr.function.visit_with(self); - } // The below cases don't contribute to operators/operands, but their children could Expr::JSXElement(jsx_element) => { @@ -259,10 +253,14 @@ impl Visit for AstAnalyzer { } } Expr::TaggedTpl(tagged_tpl) => { + self.unique_operators.insert("TaggedTemplate".to_string()); + self.total_operators += 1; // Implicit tagged template operator + tagged_tpl.tag.visit_with(self); tagged_tpl.tpl.visit_with(self); } _ => { + expr.visit_children_with(self); debug!( "visit_expr: Expression assumed to not count towards operators and operands: {:?}", expr @@ -307,103 +305,20 @@ impl Visit for AstAnalyzer { class_decl.visit_children_with(self); } - fn visit_bin_expr(&mut self, node: &BinExpr) { - let operator = format!("{:?}", node.op); - self.unique_operators.insert(operator); - self.total_operators += 1; - - node.left.visit_with(self); - node.right.visit_with(self); - } - - fn visit_unary_expr(&mut self, node: &UnaryExpr) { - let operator = format!("{:?}", node.op); - self.unique_operators.insert(operator); - self.total_operators += 1; - - node.arg.visit_with(self); - } - - fn visit_assign_expr(&mut self, node: &AssignExpr) { - let operator = format!("{:?}", node.op); - self.unique_operators.insert(operator); - self.total_operators += 1; - - node.left.visit_with(self); - node.right.visit_with(self); - } - - fn visit_update_expr(&mut self, node: &UpdateExpr) { - let operator = format!("{:?}", node.op); - self.unique_operators.insert(operator); - self.total_operators += 1; - - node.arg.visit_with(self); - } - fn visit_member_expr(&mut self, node: &MemberExpr) { - match &node.prop { - MemberProp::Ident(_) => { - self.unique_operators.insert(".".to_string()); // Non-computed member access operator - } - MemberProp::Computed(expr) => { - self.unique_operators.insert("[]".to_string()); // Computed member access operator - expr.visit_with(self); - } - MemberProp::PrivateName(_) => { - // Can be safely ignored, as I understand it: - // Private class fields in JavaScript are accessed using the `.#` syntax. - // However, this syntax is not considered an operator in the same way `.` and `[]` are. - } + if let MemberProp::Ident(_) = &node.prop { + self.unique_operators.insert(".".to_string()); // Non-computed member access operator + self.total_operators += 1; } - self.total_operators += 1; node.obj.visit_with(self); } - fn visit_call_expr(&mut self, node: &CallExpr) { - self.unique_operators.insert("...".to_string()); - self.total_operators += 1; // Implicit call operator - - node.callee.visit_with(self); - for arg in &node.args { - arg.visit_with(self); - } - } - - fn visit_new_expr(&mut self, node: &NewExpr) { - self.unique_operators.insert("new".to_string()); - self.total_operators += 1; // Implicit constructor call operator - - node.callee.visit_with(self); - if let Some(args) = &node.args { - for arg in args { - arg.visit_with(self); - } - } - } - fn visit_ident(&mut self, node: &Ident) { self.unique_operands.insert(node.sym.to_string()); self.total_operands += 1; } - fn visit_lit(&mut self, node: &Lit) { - let lit = format!("{:?}", node); - self.unique_operands.insert(lit); - self.total_operands += 1; - } - - fn visit_arrow_expr(&mut self, node: &ArrowExpr) { - self.unique_operators.insert("=>".to_string()); - self.total_operators += 1; // Implicit arrow function operator - - for param in &node.params { - param.visit_with(self); - } - node.body.visit_with(self); - } - fn visit_tpl(&mut self, node: &Tpl) { self.unique_operators.insert("Template String".to_string()); self.total_operators += 1; @@ -416,43 +331,6 @@ impl Visit for AstAnalyzer { } } - fn visit_tagged_tpl(&mut self, node: &TaggedTpl) { - self.unique_operators.insert("TaggedTemplate".to_string()); - self.total_operators += 1; // Implicit tagged template operator - - node.tag.visit_with(self); - } - - fn visit_spread_element(&mut self, node: &SpreadElement) { - self.unique_operators.insert("...".to_string()); - self.total_operators += 1; // Implicit spread operator - - node.expr.visit_with(self); - } - - fn visit_ts_non_null_expr(&mut self, node: &TsNonNullExpr) { - self.unique_operators.insert("TsNonNull".to_string()); - self.total_operators += 1; // Implicit non-null assertion operator - - node.expr.visit_with(self); - } - - fn visit_ts_type_assertion(&mut self, node: &TsTypeAssertion) { - self.unique_operators.insert("TsTypeAssertion".to_string()); - self.total_operators += 1; // Implicit type assertion operator - - node.expr.visit_with(self); - node.type_ann.visit_with(self); - } - - fn visit_ts_as_expr(&mut self, node: &TsAsExpr) { - self.unique_operators.insert("TsAs".to_string()); - self.total_operators += 1; // Implicit type cast operator (as) - - node.expr.visit_with(self); - node.type_ann.visit_with(self); - } - fn visit_ts_type_operator(&mut self, node: &TsTypeOperator) { let operator = format!("{:?}", node.op); self.unique_operators.insert(operator); @@ -461,14 +339,6 @@ impl Visit for AstAnalyzer { node.type_ann.visit_with(self); } - fn visit_ts_qualified_name(&mut self, node: &TsQualifiedName) { - self.unique_operators.insert("TsQualifiedName".to_string()); - self.total_operators += 1; // Implicit qualified name operator - - node.left.visit_with(self); - node.right.visit_with(self); - } - fn visit_ts_mapped_type(&mut self, node: &TsMappedType) { self.unique_operators.insert("TsMappedType".to_string()); self.total_operators += 2; // Implicit key in keyof and value in mapping type operators @@ -488,22 +358,6 @@ impl Visit for AstAnalyzer { node.index_type.visit_with(self); } - fn visit_cond_expr(&mut self, node: &CondExpr) { - self.unique_operators.insert("?".to_string()); // Conditional operator '?' - self.unique_operators.insert(":".to_string()); // Alternate operator ':' - self.total_operators += 2; // Counting both conditional and alternate operators - - node.test.visit_with(self); - node.cons.visit_with(self); - node.alt.visit_with(self); - } - - fn visit_await_expr(&mut self, node: &AwaitExpr) { - self.unique_operators.insert("await".to_string()); - self.total_operators += 1; - node.arg.visit_with(self); - } - fn visit_yield_expr(&mut self, node: &YieldExpr) { self.unique_operators.insert("yield".to_string()); self.total_operators += 1; @@ -518,12 +372,6 @@ impl Visit for AstAnalyzer { // No children to visit } - fn visit_seq_expr(&mut self, node: &SeqExpr) { - self.unique_operators.insert(",".to_string()); - self.total_operators += node.exprs.len() - 1; // n-1 commas for n expressions - node.visit_children_with(self); - } - fn visit_return_stmt(&mut self, return_stmt: &ReturnStmt) { // Capture the return operator self.unique_operators.insert("return".to_string()); @@ -596,7 +444,6 @@ impl HalsteadMetrics { } } -#[allow(dead_code)] pub fn analyze_module(module: &Module) -> HalsteadMetrics { let mut analyzer = AstAnalyzer::new(); module.visit_with(&mut analyzer); diff --git a/crates/fta/src/halstead/tests.rs b/crates/fta/src/halstead/tests.rs new file mode 100644 index 0000000..734edcb --- /dev/null +++ b/crates/fta/src/halstead/tests.rs @@ -0,0 +1,521 @@ +#[cfg(test)] +mod tests { + use crate::halstead::analyze_module; + use crate::parse::parse_module; + use crate::structs::HalsteadMetrics; + use swc_ecma_ast::Module; + + fn parse(ts_code: &str) -> Module { + let (parsed_module, _line_count) = parse_module(ts_code, true); + + if let Ok(parsed_module) = parsed_module { + parsed_module + } else { + panic!("failed"); + } + } + + fn analyze(module: &Module) -> HalsteadMetrics { + let metrics = analyze_module(module); + metrics + } + + #[test] + fn test_empty_module() { + let ts_code = r#" + /* Empty TypeScript code */ + "#; + let module = parse(ts_code); + let expected = HalsteadMetrics { + uniq_operators: 0, + uniq_operands: 0, + total_operators: 0, + total_operands: 0, + program_length: 0, + vocabulary_size: 0, + volume: 0.0, + difficulty: 0.0, + effort: 0.0, + time: 0.0, + bugs: 0.0, + }; + assert_eq!(analyze(&module), expected); + } + + #[test] + fn test_switch_case() { + let ts_code = r#" + switch (x) { + case 0: + console.log("x is 0"); + break; + case 1: + console.log("x is 1"); + break; + default: + console.log("x is not 0 or 1"); + } + "#; + let module = parse(ts_code); + let expected = HalsteadMetrics { + uniq_operators: 3, + uniq_operands: 8, + total_operators: 9, + total_operands: 12, + program_length: 11, + vocabulary_size: 21, + volume: 48.315491650566365, + difficulty: 2.6666666666666665, + effort: 128.84131106817696, + time: 7.15785061489872, + bugs: 0.016105163883522122, + }; + assert_eq!(analyze(&module), expected); + } + + #[test] + fn test_complex_case_a() { + let ts_code = r##" + import { React, useState } from 'react'; + import { asyncOperation } from './asyncOperation'; + + let staticFoo = true; + + function displayThing(thing: string) { + return `thing: ${thing}`; + } + + export default function DummyComponent() { + const [thing, setThing] = useState(null); + + const thingForDisplay = displayThing(thing) as string; + + const interact = async () => { + const result = await asyncOperation(); + setThing(result); + staticFoo = false; + + if (typeof thing === 'object' && thing?.foo?.bar) { + console.log('This should not happen'); + } + } + + const baz = staticFoo ? 32 : 42; + + return ( + <> +
+

Hello World

+
+
+

This is a test. {thingForDisplay} {baz}

+ +
+ + ) + } + "##; + let module = parse(ts_code); + let expected = HalsteadMetrics { + uniq_operators: 21, + uniq_operands: 26, + total_operators: 43, + total_operands: 47, + program_length: 47, + vocabulary_size: 90, + volume: 305.1170955274947, + difficulty: 11.617021276595745, + effort: 3544.5517905960023, + time: 196.91954392200012, + bugs: 0.1017056985091649, + }; + assert_eq!(analyze(&module), expected); + } + + #[test] + fn test_complex_case_c() { + let ts_code = r##" + let a, b, c = 3; + a = 1; + b = 2; + let myArray = [a, b, c]; + + myArray = [...myArray, ...myArray, 8, 9, 10]; + + const myObject = { + foo: 'bar' + } + + const myOtherObject = { + ...myObject, + bar: 'baz' + } + + class Foo { + constructor() { + this.foo = 'some value'; + } + + getFoo() { + return this.foo!; + } + + isFooCool() { + const myRegex = /cool/; + return myRegex.test(this.foo); + } + } + + const myFoo = new Foo(); + + export { myFoo, myOtherObject }; + "##; + + let module = parse(ts_code); + let expected = HalsteadMetrics { + uniq_operators: 10, + uniq_operands: 25, + total_operators: 31, + total_operands: 44, + program_length: 35, + vocabulary_size: 75, + volume: 218.00865416735581, + difficulty: 8.522727272727273, + effort: 1858.0283025626918, + time: 103.22379458681621, + bugs: 0.07266955138911861, + }; + assert_eq!(analyze(&module), expected); + } + + #[test] + fn test_complex_case_d() { + let ts_code = r##" + // Covers 'visit_export_decl' + export declare const foo = 42; + + // Covers 'visit_tpl' + const tpl = `result is ${binResult}`; + + // Covers 'visit_ts_mapped_type' + type MappedType = { [P in keyof any]: P }; + + // Covers 'visit_ts_indexed_access_type' + type AccessType = MappedType["key"]; + + // Covers 'visit_ts_type_operator' + type NewType = keyof any; + + // Covers 'visit_tpl' + const person = "Mike"; + const age = 28; + function myTag(strings, personExp, ageExp) { + const str0 = strings[0]; // "That " + const str1 = strings[1]; // " is a " + const str2 = strings[2]; // "." + + const ageStr = ageExp > 99 ? "centenarian" : "youngster"; + + // We can even return a string built using a template literal + return `${str0}${personExp}${str1}${ageStr}${str2}`; + } + const output = myTag`That ${person} is a ${age}.`; + "##; + let module = parse(ts_code); + let expected = HalsteadMetrics { + uniq_operators: 15, + uniq_operands: 27, + total_operators: 39, + total_operands: 41, + program_length: 42, + vocabulary_size: 80, + volume: 265.5209799852692, + difficulty: 12.512195121951219, + effort: 3322.2503105473925, + time: 184.56946169707737, + bugs: 0.08850699332842307, + }; + assert_eq!(analyze(&module), expected); + } + + #[test] + fn test_complex_case_e() { + let ts_code = r##" + // visit_bin_expr + let a = 5 + 3; + + // visit_unary_expr + let b = !true; + + // visit_assign_expr + let c = 10; + c += a; + + // visit_update_expr + c++; + + // visit_call_expr + console.log(c); + + // visit_new_expr + let obj = new Date(); + + // visit_lit + let str = "test"; + let num = 1; + let bool = true; + let reg = /ab+c/; + let nullLit = null; + + // visit_arrow_expr + let add = (x: number, y: number) => x + y; + + // visit_tagged_tpl + let person = "John"; + let greeting = `Hello ${person}`; + + // visit_spread_element + let arr1 = [1, 2, 3]; + let arr2 = [...arr1, 4, 5]; + + // visit_ts_non_null_expr + let maybeString: string | null = "Hello"; + let str2 = maybeString!; + + // visit_ts_type_assertion + let someValue: unknown = "this is a string"; + let strLength: number = (someValue as string).length; + + // visit_ts_as_expr + let anotherValue: unknown = "this is another string"; + + // visit_ts_qualified_name + namespace A { + export namespace B { + export const message = "Hello, TypeScript!"; + } + } + console.log(A.B.message); + + // visit_cond_expr + let condition = true ? "truthy" : "falsy"; + + // visit_await_expr + async function foo() { + let result = await Promise.resolve("Hello, world!"); + console.log(result); + } + foo(); + + // visit_yield_expr + function* generator() { + yield 'yielding a value'; + } + + // visit_meta_prop_expr + function check() { + if (new.target) { + console.log('Function was called with "new" keyword'); + } else { + console.log('Function was not called with "new" keyword'); + } + } + check(); + + // visit_seq_expr + let seq = (console.log('first'), console.log('second'), 'third'); + + let a = 5; // visit_assign_expr + let b = -a; // visit_unary_expr + let c = a + b; // visit_bin_expr + let d = ++c; // visit_update_expr + let e = Math.sqrt(d); // visit_call_expr + let f = new String(e); // visit_new_expr + let g = "hello"; // visit_lit + let h = (x: number) => x * 2; // visit_arrow_expr + let arr = [...h]; // visit_spread_element + let j: number! = 5; // visit_ts_non_null_expr + let cond = (a > b) ? a : b; // visit_cond_expr + async function asyncFunc() { + let result = await Promise.resolve(true); // visit_await_expr + return result; + } + function* generatorFunc() { + yield 'hello'; // visit_yield_expr + yield* arr; // visit_yield_expr + } + const meta = new.target; // visit_meta_prop_expr + const seq = (1, 2, 3, 4, 5); // visit_seq_expr + "##; + let module = parse(ts_code); + let expected = HalsteadMetrics { + uniq_operators: 28, + uniq_operands: 75, + total_operators: 130, + total_operands: 139, + program_length: 103, + vocabulary_size: 269, + volume: 831.3606233433322, + difficulty: 35.07194244604317, + effort: 29157.43193380392, + time: 1619.8573296557734, + bugs: 0.27712020778111074, + }; + assert_eq!(analyze(&module), expected); + } + + #[test] + fn test_complex_case_f() { + let ts_code = r##" + const obj = { + prop1: 123, + prop2: "hello", + prop3: () => { + console.log("Method prop"); + }, + }; + + const fn: () => void = obj.prop3; + + const jsxElement = ( +
+

Hello

+
+ ); + "##; + let module = parse(ts_code); + let expected = HalsteadMetrics { + uniq_operators: 7, + uniq_operands: 13, + total_operators: 13, + total_operands: 17, + program_length: 20, + vocabulary_size: 30, + volume: 98.13781191217038, + difficulty: 4.588235294117647, + effort: 450.27937230289933, + time: 25.015520683494408, + bugs: 0.03271260397072346, + }; + assert_eq!(analyze(&module), expected); + } + + #[test] + fn test_complex_case_g() { + let ts_code = r##" + const value: any = "123"; + const result = value as number; + const obj: MyNamespace.MyClass = new MyNamespace.MyClass(); + + const obj = { + prop1: { + nested: { + value: 42, + }, + }, + prop2: [1, 2, 3], + }; + console.log(obj.prop1.nested.value); + console.log(obj.prop2[0]); + "##; + let module = parse(ts_code); + let expected = HalsteadMetrics { + uniq_operators: 6, + uniq_operands: 16, + total_operators: 22, + total_operands: 27, + program_length: 22, + vocabulary_size: 49, + volume: 123.52361657053459, + difficulty: 6.518518518518518, + effort: 805.1909820894106, + time: 44.73283233830059, + bugs: 0.04117453885684486, + }; + assert_eq!(analyze(&module), expected); + } + + #[test] + fn test_complex_case_h() { + let ts_code = r##" + const obj = { + prop1: "value1", + prop2: { + nested: "value2", + }, + prop3() { + return "value3"; + }, + prop4: 42, + prop5, + prop6: { + nestedMethod() { + return "nestedValue"; + }, + }, + prop7: "value7", + prop8 = "value8" + }; + + const prop5 = "value5"; + + console.log(obj.prop1); + console.log(obj.prop2.nested); + console.log(obj.prop3()); + console.log(obj.prop4); + console.log(obj.prop5); + console.log(obj.prop6.nestedMethod()); + "##; + let module = parse(ts_code); + let expected = HalsteadMetrics { + uniq_operators: 6, + uniq_operands: 21, + total_operators: 41, + total_operands: 46, + program_length: 27, + vocabulary_size: 87, + volume: 173.95947438791566, + difficulty: 9.130434782608695, + effort: 1588.3256357157516, + time: 88.24031309531954, + bugs: 0.057986491462638554, + }; + assert_eq!(analyze(&module), expected); + } + + #[test] + fn test_complex_case_i() { + let ts_code = r##" + let obj = { + ['computed' + 'Property']: 'value' + }; + + class MyClass { + [Symbol.iterator]() {} + } + + class MyClassTwo { + #privateField = 'value'; + + getPrivateField() { + return this.#privateField; + } + } + "##; + let module = parse(ts_code); + let expected = HalsteadMetrics { + uniq_operators: 6, + uniq_operands: 11, + total_operators: 11, + total_operands: 13, + program_length: 17, + vocabulary_size: 24, + volume: 77.94436251225966, + difficulty: 4.230769230769231, + effort: 329.7646106287909, + time: 18.32025614604394, + bugs: 0.025981454170753218, + }; + assert_eq!(analyze(&module), expected); + } +} diff --git a/crates/fta/src/lib.rs b/crates/fta/src/lib.rs index 980f98a..7898632 100644 --- a/crates/fta/src/lib.rs +++ b/crates/fta/src/lib.rs @@ -1,344 +1,178 @@ -pub mod complexity; -mod config; -mod halstead; -pub mod parse_module; -mod structs; - -use config::read_config; -use ignore::WalkBuilder; -use log::debug; -use log::warn; -use std::cmp::max; -use std::env; -use std::fs; -use std::time::Instant; - -use crate::structs::{FileData, FtaConfig, HalsteadMetrics}; -use globset::{Glob, GlobSetBuilder}; -use ignore::DirEntry; -use swc_ecma_ast::Module; - -fn is_excluded_filename(file_name: &str, patterns: &[String]) -> bool { - let mut builder = GlobSetBuilder::new(); - - for pattern in patterns { - let glob = Glob::new(pattern).unwrap(); - builder.add(glob); - } - - let glob_set = builder.build().unwrap(); - - glob_set.is_match(file_name) -} - -fn is_valid_file(repo_path: &String, entry: &DirEntry, config: &FtaConfig) -> bool { - let file_name = entry.path().file_name().unwrap().to_str().unwrap(); - let relative_path = entry - .path() - .strip_prefix(repo_path) - .unwrap() - .to_str() - .unwrap(); - - let valid_extension = config - .extensions - .as_ref() - .map_or(true, |exts| exts.iter().any(|ext| file_name.ends_with(ext))); - - let is_excluded_filename = config - .exclude_filenames - .as_ref() - .map_or(false, |patterns| is_excluded_filename(file_name, patterns)); - - let is_excluded_directory = config.exclude_directories.as_ref().map_or(false, |dirs| { - dirs.iter().any(|dir| relative_path.starts_with(dir)) - }); - - valid_extension && !is_excluded_filename && !is_excluded_directory -} - -pub fn analyze_file(module: &Module, line_count: usize) -> (usize, HalsteadMetrics, f64) { - let cyclo = complexity::cyclomatic_complexity(module.clone()); - let halstead_metrics = halstead::analyze_module(module); - - let line_count_float = line_count as f64; - let cyclo_float = cyclo as f64; - let vocab_float = halstead_metrics.vocabulary_size as f64; - - let factor = if cyclo_float.ln() < 1.0 { - 1.0 - } else { - line_count_float / cyclo_float.ln() - }; - - // Normalization formula based on original research - // Originates from codehawk-cli - let absolute_fta_score = - 171.0 - 5.2 * vocab_float.ln() - 0.23 * cyclo_float - 16.2 * factor.ln(); - let mut fta_score = 100.0 - ((absolute_fta_score * 100.0) / 171.0); - - if fta_score < 0.0 { - fta_score = 0.0; - } - - (cyclo, halstead_metrics, fta_score) -} - -fn get_assessment(score: f64) -> String { - if score > 60.0 { - "(Needs improvement)".to_string() - } else if score > 50.0 { - "(Could be better)".to_string() - } else { - "OK".to_string() - } -} - -fn analyze_parsed_code(file_name: String, module: Module, line_count: usize) -> FileData { - let (cyclo, halstead, fta_score) = analyze_file(&module, line_count); - debug!("{} cyclo: {}, halstead: {:?}", file_name, cyclo, halstead); - - FileData { - file_name, - cyclo, - halstead, - fta_score, - line_count, - assessment: get_assessment(fta_score), - } -} - -fn check_score_cap_breach( - file_name: String, - fta_score: f64, - score_cap: std::option::Option, -) { - // Exit 1 if score_cap breached - if let Some(score_cap) = score_cap { - if fta_score > score_cap as f64 { - eprintln!( - "File {} has a score of {}, which is beyond the score cap of {}, exiting.", - file_name, fta_score, score_cap - ); - std::process::exit(1); - } - } -} - -fn collect_results( - entry: DirEntry, - repo_path: &String, - module: Module, - line_count: usize, - score_cap: std::option::Option, -) -> FileData { - // Parse the source code and run the analysis - let file_name = entry - .path() - .strip_prefix(repo_path) - .unwrap() - .display() - .to_string(); - let file_name_cloned = file_name.clone(); - let file_data = analyze_parsed_code(file_name, module, line_count); - - // Keep a record of the fta_score before moving the FileData - let fta_score = file_data.fta_score; - - // Check if the score cap is breached - check_score_cap_breach(file_name_cloned.clone(), fta_score, score_cap); - - file_data -} - -pub fn analyze(repo_path: &String, json: bool) { - // Initialize the logger - let mut builder = env_logger::Builder::new(); - - // Check if debug mode is enabled using an environment variable - if env::var("DEBUG").is_ok() { - builder.filter_level(log::LevelFilter::Debug); - } else { - builder.filter_level(log::LevelFilter::Info); - } - builder.init(); - - // Start tracking execution time - let start = Instant::now(); - - // Parse user config - let config_path = format!("{}/fta.json", repo_path); - let config = read_config(&config_path); - - let walk = WalkBuilder::new(repo_path) - .git_ignore(true) - .git_exclude(true) - .standard_filters(true) - .build(); - - let mut files_found = 0; - let mut file_data_list: Vec = Vec::new(); - - for entry in walk { - if let Ok(entry) = entry { - match entry.file_type() { - Some(file_type) if file_type.is_file() => { - if is_valid_file(repo_path, &entry, &config) { - if files_found < config.output_limit.unwrap_or_default() { - let file_name = entry.path().display(); - let source_code = fs::read_to_string(file_name.to_string()).unwrap(); - let file_extension = entry - .path() - .extension() - .unwrap() - .to_str() - .unwrap() - .to_string(); - let use_tsx = file_extension == "tsx" || file_extension == "jsx"; - - match parse_module::parse_module(&source_code, use_tsx) { - (Ok(module), line_count) => { - // Parse the source code and run the analysis - let file_data = collect_results( - entry, - repo_path, - module, - line_count, - config.score_cap, - ); - // Track files found and the results - files_found += 1; - file_data_list.push(file_data); - } - (Err(_err), _) => { - // By default, flip the tsx boolean and try again. - // The swc parser needs to know if it's parsing tsx/jsx, and user code might not use appropriate file extensions. - let use_tsx_inverted = !use_tsx; - - match parse_module::parse_module(&source_code, use_tsx_inverted) - { - (Ok(module), line_count) => { - let file_name_cloned = file_name.to_string(); - // Parse the source code and run the analysis - let file_data = collect_results( - entry, - repo_path, - module, - line_count, - config.score_cap, - ); - - // Warn users that language detection was confusing due to use of file extensions - let tsx_name = - if use_tsx { "j/tsx" } else { "non-j/tsx" }; - let opposite_tsx_name = - if use_tsx { "non-j/tsx" } else { "j/tsx" }; - warn!( - "File {} was interpreted as {} but seems to actually be {}. The file extension may be incorrect.", - file_name_cloned, - tsx_name, - opposite_tsx_name - ); - - // Track files found and the results - files_found += 1; - file_data_list.push(file_data); - } - (Err(err), _) => { - warn!("Failed to analyze {}: {:?}", file_name, err); - } - } - } - } - } else { - break; - } - } - } - _ => (), - } - } - } - - let elapsed = start.elapsed().as_secs_f64(); - let elapsed_rounded = (elapsed * 10000.0).round() / 10000.0; - - // JSON output - output results as JSON - if json { - let json_string = serde_json::to_string(&file_data_list).unwrap(); - println!("{}", json_string); - std::process::exit(0); - } - - // Normal output - output results table - file_data_list.sort_unstable_by(|a, b| b.fta_score.partial_cmp(&a.fta_score).unwrap()); - - let mut max_file_name_width = "File".len(); - let mut max_lines_width = "Num. lines".len(); - let mut max_fta_width = "FTA Score (Lower is better)".len(); - let mut max_assessment_width = "Assessment".len(); - - for file_data in &file_data_list { - max_file_name_width = max(max_file_name_width, file_data.file_name.len()); - max_lines_width = max(max_lines_width, file_data.line_count.to_string().len()); - max_fta_width = max(max_fta_width, format!("{:.2}", file_data.fta_score).len()); - max_assessment_width = max(max_assessment_width, file_data.assessment.to_string().len()); - } - - // Add some padding to each column - max_file_name_width += 2; - max_lines_width += 2; - max_fta_width += 2; - - println!( - "| {} | {} | {} | {} |", - "-".repeat(max_file_name_width), - "-".repeat(max_lines_width), - "-".repeat(max_fta_width), - "-".repeat(max_assessment_width) - ); - println!( - "| {:c_width$} | {:>h_width$} | {:>a_width$}", - "File", - "Num. lines", - "FTA Score (Lower is better)", - "Assessment", - f_width = max_file_name_width, - c_width = max_lines_width, - h_width = max_fta_width, - a_width = max_assessment_width - ); - println!( - "| {} | {} | {} | {} |", - "-".repeat(max_file_name_width), - "-".repeat(max_lines_width), - "-".repeat(max_fta_width), - "-".repeat(max_assessment_width) - ); - - for file_data in file_data_list - .iter() - .take(config.output_limit.unwrap_or(100)) - { - println!( - "| {:c_width$} | {:>h_width$.2} | {:>a_width$} |", - file_data.file_name, - file_data.line_count, - file_data.fta_score, - file_data.assessment, - f_width = max_file_name_width, - c_width = max_lines_width, - h_width = max_fta_width, - a_width = max_assessment_width - ); - } - println!( - "| {} | {} | {} | {} |", - "-".repeat(max_file_name_width), - "-".repeat(max_lines_width), - "-".repeat(max_fta_width), - "-".repeat(max_assessment_width) - ); - - println!("{} files analyzed in {}s.", files_found, elapsed_rounded); -} +mod config; +mod cyclo; +mod halstead; +pub mod output; +pub mod parse; +mod structs; +mod utils; + +use config::read_config; +use ignore::DirEntry; +use ignore::WalkBuilder; +use log::debug; +use log::warn; +use std::env; +use std::fs; +use structs::{FileData, FtaConfig, HalsteadMetrics}; +use swc_ecma_ast::Module; +use swc_ecma_parser::error::Error; +use utils::{check_score_cap_breach, get_assessment, is_valid_file, warn_about_language}; + +pub fn analyze_file(module: &Module, line_count: usize) -> (usize, HalsteadMetrics, f64) { + let cyclo = cyclo::cyclomatic_complexity(module.clone()); + let halstead_metrics = halstead::analyze_module(module); + + let line_count_float = line_count as f64; + let cyclo_float = cyclo as f64; + let vocab_float = halstead_metrics.vocabulary_size as f64; + + let factor = if cyclo_float.ln() < 1.0 { + 1.0 + } else { + line_count_float / cyclo_float.ln() + }; + + // Normalization formula based on original research + // Originates from codehawk-cli + let absolute_fta_score = + 171.0 - 5.2 * vocab_float.ln() - 0.23 * cyclo_float - 16.2 * factor.ln(); + let mut fta_score = 100.0 - ((absolute_fta_score * 100.0) / 171.0); + + if fta_score < 0.0 { + fta_score = 0.0; + } + + (cyclo, halstead_metrics, fta_score) +} + +fn analyze_parsed_code(file_name: String, module: Module, line_count: usize) -> FileData { + let (cyclo, halstead, fta_score) = analyze_file(&module, line_count); + debug!("{} cyclo: {}, halstead: {:?}", file_name, cyclo, halstead); + + FileData { + file_name, + cyclo, + halstead, + fta_score, + line_count, + assessment: get_assessment(fta_score), + } +} + +fn collect_results( + entry: &DirEntry, + repo_path: &str, + module: Module, + line_count: usize, + score_cap: std::option::Option, +) -> FileData { + // Parse the source code and run the analysis + let file_name = entry + .path() + .strip_prefix(repo_path) + .unwrap() + .display() + .to_string(); + let file_name_cloned = file_name.clone(); + let file_data = analyze_parsed_code(file_name, module, line_count); + + // Keep a record of the fta_score before moving the FileData + let fta_score = file_data.fta_score; + + // Check if the score cap is breached + check_score_cap_breach(file_name_cloned.clone(), fta_score, score_cap); + + file_data +} + +fn do_analysis( + entry: &DirEntry, + repo_path: &str, + config: &FtaConfig, + source_code: &str, + use_tsx: bool, +) -> Result { + let (result, line_count) = parse::parse_module(source_code, use_tsx); + + match result { + Ok(module) => Ok(collect_results( + entry, + repo_path, + module, + line_count, + config.score_cap, + )), + Err(err) => Err(err), + } +} + +pub fn analyze(repo_path: &String) -> Vec { + // Initialize the logger + let mut builder = env_logger::Builder::new(); + + // Check if debug mode is enabled using an environment variable + if env::var("DEBUG").is_ok() { + builder.filter_level(log::LevelFilter::Debug); + } else { + builder.filter_level(log::LevelFilter::Info); + } + builder.init(); + + // Parse user config + let config_path = format!("{}/fta.json", repo_path); + let config = read_config(&config_path); + + let walk = WalkBuilder::new(repo_path) + .git_ignore(true) + .git_exclude(true) + .standard_filters(true) + .build(); + + let mut file_data_list: Vec = Vec::new(); + + walk.filter(|entry| entry.is_ok()) + .map(|entry| entry.unwrap()) + .filter(|entry| entry.file_type().unwrap().is_file()) + .filter(|entry| is_valid_file(repo_path, &entry, &config)) + .for_each(|entry| { + if file_data_list.len() >= config.output_limit.unwrap_or_default() { + return; + } + let file_name = entry.path().display(); + let source_code = match fs::read_to_string(file_name.to_string()) { + Ok(code) => code, + Err(_) => return, + }; + + let file_extension = entry + .path() + .extension() + .and_then(std::ffi::OsStr::to_str) + .unwrap_or_default() + .to_string(); + let use_tsx = file_extension == "tsx" || file_extension == "jsx"; + + let mut file_data_result = + do_analysis(&entry, repo_path, &config, &source_code, use_tsx); + + if file_data_result.is_err() { + warn_about_language(&file_name.to_string(), use_tsx); + file_data_result = do_analysis(&entry, repo_path, &config, &source_code, !use_tsx); + } + + if file_data_result.is_err() { + warn!( + "Failed to analyze {}: {:?}", + file_name, + file_data_result.unwrap_err() + ); + return; + } + + if let Ok(data) = file_data_result { + file_data_list.push(data); + } + }); + + return file_data_list; +} diff --git a/crates/fta/src/main.rs b/crates/fta/src/main.rs index 541a2b0..5ade92e 100644 --- a/crates/fta/src/main.rs +++ b/crates/fta/src/main.rs @@ -1,29 +1,49 @@ -mod complexity; -mod config; -mod halstead; -mod parse_module; -mod structs; - -#[cfg(test)] -mod complexity_tests; -mod config_tests; -mod parse_module_tests; - use clap::Parser; use fta::analyze; +use fta::output::generate_output; +use std::time::Instant; #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] struct Cli { + #[arg(required = true, help = "Path to the project to analyze.")] project: String, - // Output JSON output - #[arg(long)] + #[arg( + long, + short, + default_value = "table", + value_parser(["table", "csv", "json"]), + help = "Output format.", + conflicts_with = "json" + )] + format: String, + + #[arg(long, help = "Output as JSON.", conflicts_with = "format")] json: bool, } pub fn main() { + // Start tracking execution time + let start = Instant::now(); + let cli = Cli::parse(); - analyze(&cli.project, cli.json) + let mut findings = analyze(&cli.project); + + findings.sort_unstable_by(|a, b| b.fta_score.partial_cmp(&a.fta_score).unwrap()); + + let elapsed = start.elapsed().as_secs_f64(); + + let output = generate_output( + &findings, + if cli.json { + "json".to_string() + } else { + cli.format + }, + &elapsed, + ); + + println!("{}", output); } diff --git a/crates/fta/src/output/mod.rs b/crates/fta/src/output/mod.rs new file mode 100644 index 0000000..d7bc120 --- /dev/null +++ b/crates/fta/src/output/mod.rs @@ -0,0 +1,65 @@ +use crate::structs::FileData; +use comfy_table::presets::UTF8_FULL; +use comfy_table::Table; + +mod tests; + +pub fn truncate_string(input: &str, max_length: usize) -> String { + if input.len() <= max_length { + input.to_string() + } else { + format!("...{}", &input[input.len() - max_length + 3..]) + } +} + +pub fn generate_output(file_data_list: &Vec, format: String, elapsed: &f64) -> String { + let mut output = String::new(); + + match Some(format.as_str()) { + Some("json") => { + output = serde_json::to_string(file_data_list).unwrap(); + } + Some("csv") => { + output.push_str("File,Num. lines,FTA Score (Lower is better),Assessment"); + for file_data in file_data_list { + output.push_str(&format!( + "\n{},{},{:.2},{}", + file_data.file_name, + file_data.line_count, + file_data.fta_score, + file_data.assessment + )); + } + } + Some("table") => { + let mut table = Table::new(); + table.load_preset(UTF8_FULL); + table.set_content_arrangement(comfy_table::ContentArrangement::Dynamic); + table.set_header(vec![ + "File", + "Num. lines", + "FTA Score (Lower is better)", + "Assessment", + ]); + + for file_data in file_data_list { + table.add_row(vec![ + truncate_string(&file_data.file_name, 50), + file_data.line_count.to_string(), + format!("{:.2}", file_data.fta_score), + file_data.assessment.clone().to_string(), + ]); + } + + output = format!( + "{}\n{} files analyzed in {}s.", + table.to_string(), + file_data_list.len(), + (elapsed * 10000.0).round() / 10000.0 + ); + } + _ => output.push_str("No output format specified."), + } + + output +} diff --git a/crates/fta/src/output/tests.rs b/crates/fta/src/output/tests.rs new file mode 100644 index 0000000..eb92433 --- /dev/null +++ b/crates/fta/src/output/tests.rs @@ -0,0 +1,134 @@ +#[cfg(test)] +mod tests { + use crate::output::{generate_output, truncate_string}; + use crate::structs::{FileData, HalsteadMetrics}; + + fn get_test_data() -> Vec { + vec![FileData { + file_name: "test.rs".to_string(), + cyclo: 1, + halstead: HalsteadMetrics { + uniq_operators: 1, + uniq_operands: 2, + total_operators: 3, + total_operands: 4, + program_length: 5, + vocabulary_size: 6, + volume: 7.0, + difficulty: 8.0, + effort: 9.0, + time: 10.0, + bugs: 11.0, + }, + line_count: 1, + fta_score: 45.00, + assessment: "OK".to_string(), + }] + } + + // Mostly eliminate whitespace from table/csv output to make comparison easier + fn format_expected_output(expected: &str) -> String { + let formatted = expected + .lines() + .map(|line| line.trim()) + .collect::>() + .join("\n"); + + formatted + } + + // Eliminate whitespace from json output to make comparison easier + fn format_json_output(json: &str) -> String { + json.chars().filter(|&c| !c.is_whitespace()).collect() + } + + #[test] + fn test_truncate_string() { + assert_eq!( + truncate_string("extremely-long-file-name-that-will-be-hard-to-display", 25), + "...ill-be-hard-to-display" + ); + assert_eq!(truncate_string("abcdef", 7), "abcdef"); + assert_eq!(truncate_string("abcdef", 6), "abcdef"); + assert_eq!(truncate_string("abcdef", 5), "...ef"); + assert_eq!(truncate_string("abcdef", 4), "...f"); + assert_eq!(truncate_string("abcdef", 3), "..."); + } + + #[test] + fn test_output_csv_format() { + let file_data_list = get_test_data(); + let output_str = format!( + "\n{}\n", + generate_output(&file_data_list, "csv".to_string(), &0.1_f64) + ); + let expected_output_raw = r##" + File,Num. lines,FTA Score (Lower is better),Assessment + test.rs,1,45.00,OK + "##; + let expected_output = format_expected_output(expected_output_raw); + assert_eq!(output_str, expected_output); + } + + #[test] + fn test_output_table_format() { + let file_data_list = get_test_data(); + let output_str = generate_output(&file_data_list, "table".to_string(), &0.1_f64); + let expected_output_raw = r##" + ┌─────────┬────────────┬─────────────────────────────┬────────────┐ + │ File ┆ Num. lines ┆ FTA Score (Lower is better) ┆ Assessment │ + ╞═════════╪════════════╪═════════════════════════════╪════════════╡ + │ test.rs ┆ 1 ┆ 45.00 ┆ OK │ + └─────────┴────────────┴─────────────────────────────┴────────────┘ + 1 files analyzed in 0.1s. + "##; + + let expected_output = format_expected_output(expected_output_raw); + let expected_output = expected_output + .trim_start_matches('\n') + .trim_end_matches('\n'); + assert_eq!(output_str, expected_output); + } + + #[test] + fn test_output_unspecified_format() { + let file_data_list = get_test_data(); + let output_str = generate_output(&file_data_list, "unspecified".to_string(), &0.1_f64); + let expected_output = "No output format specified."; + assert_eq!(output_str, expected_output); + } + + #[test] + fn test_output_json_format() { + let file_data_list = get_test_data(); + let output_str = generate_output(&file_data_list, "json".to_string(), &0.1_f64); + + let expected_output = r##"[ + { + "file_name": "test.rs", + "cyclo": 1, + "halstead": { + "uniq_operators": 1, + "uniq_operands": 2, + "total_operators": 3, + "total_operands": 4, + "program_length": 5, + "vocabulary_size": 6, + "volume": 7.0, + "difficulty": 8.0, + "effort": 9.0, + "time": 10.0, + "bugs": 11.0 + }, + "line_count": 1, + "fta_score": 45.0, + "assessment": "OK" + } + ]"##; + + assert_eq!( + format_json_output(&output_str), + format_json_output(expected_output) + ); + } +} diff --git a/crates/fta/src/parse_module.rs b/crates/fta/src/parse/mod.rs similarity index 94% rename from crates/fta/src/parse_module.rs rename to crates/fta/src/parse/mod.rs index e12b8fe..a8c8bb5 100644 --- a/crates/fta/src/parse_module.rs +++ b/crates/fta/src/parse/mod.rs @@ -4,7 +4,8 @@ use swc_common::SourceMap; use swc_ecma_ast::{EsVersion, Module}; use swc_ecma_parser::{error::Error, lexer::Lexer, Parser, Syntax, TsConfig}; -#[allow(dead_code)] +mod tests; + pub fn parse_module(source: &str, use_tsx: bool) -> (Result, usize) { let line_count = source.lines().count(); let cm: Lrc = Default::default(); diff --git a/crates/fta/src/parse_module_tests.rs b/crates/fta/src/parse/tests.rs similarity index 88% rename from crates/fta/src/parse_module_tests.rs rename to crates/fta/src/parse/tests.rs index 7b3b50c..50d0d4b 100644 --- a/crates/fta/src/parse_module_tests.rs +++ b/crates/fta/src/parse/tests.rs @@ -1,6 +1,6 @@ #[cfg(test)] mod tests { - use crate::parse_module::parse_module; + use crate::parse::parse_module; #[test] fn test_parse_module() { diff --git a/crates/fta/src/structs.rs b/crates/fta/src/structs/mod.rs similarity index 90% rename from crates/fta/src/structs.rs rename to crates/fta/src/structs/mod.rs index 06a264c..0031b05 100644 --- a/crates/fta/src/structs.rs +++ b/crates/fta/src/structs/mod.rs @@ -9,8 +9,7 @@ pub struct FtaConfig { pub score_cap: Option, } -#[allow(dead_code)] -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, PartialEq)] pub struct HalsteadMetrics { pub uniq_operators: usize, // number of unique operators pub uniq_operands: usize, // number of unique operands @@ -25,7 +24,6 @@ pub struct HalsteadMetrics { pub bugs: f64, } -#[allow(dead_code)] #[derive(Debug, Serialize)] pub struct FileData { pub file_name: String, diff --git a/crates/fta/src/utils/mod.rs b/crates/fta/src/utils/mod.rs new file mode 100644 index 0000000..c22bfe7 --- /dev/null +++ b/crates/fta/src/utils/mod.rs @@ -0,0 +1,84 @@ +use crate::structs::FtaConfig; +use globset::{Glob, GlobSetBuilder}; +use ignore::DirEntry; +use log::warn; + +mod tests; + +pub fn is_excluded_filename(file_name: &str, patterns: &[String]) -> bool { + let mut builder = GlobSetBuilder::new(); + + for pattern in patterns { + let glob = Glob::new(pattern).unwrap(); + builder.add(glob); + } + + let glob_set = builder.build().unwrap(); + + glob_set.is_match(file_name) +} + +pub fn is_valid_file(repo_path: &String, entry: &DirEntry, config: &FtaConfig) -> bool { + let file_name = entry.path().file_name().unwrap().to_str().unwrap(); + let relative_path = entry + .path() + .strip_prefix(repo_path) + .unwrap() + .to_str() + .unwrap(); + + let valid_extension = config + .extensions + .as_ref() + .map_or(true, |exts| exts.iter().any(|ext| file_name.ends_with(ext))); + + let is_excluded_filename = config + .exclude_filenames + .as_ref() + .map_or(false, |patterns| is_excluded_filename(file_name, patterns)); + + let is_excluded_directory = config.exclude_directories.as_ref().map_or(false, |dirs| { + dirs.iter().any(|dir| relative_path.starts_with(dir)) + }); + + valid_extension && !is_excluded_filename && !is_excluded_directory +} + +pub fn warn_about_language(file_name: &str, use_tsx: bool) { + let tsx_name = if use_tsx { "j/tsx" } else { "non-j/tsx" }; + let opposite_tsx_name = if use_tsx { "non-j/tsx" } else { "j/tsx" }; + + warn!( + "File {} was interpreted as {} but seems to actually be {}. The file extension may be incorrect.", + file_name, + tsx_name, + opposite_tsx_name + ); +} + +pub fn check_score_cap_breach( + file_name: String, + fta_score: f64, + score_cap: std::option::Option, +) { + // Exit 1 if score_cap breached + if let Some(score_cap) = score_cap { + if fta_score > score_cap as f64 { + eprintln!( + "File {} has a score of {}, which is beyond the score cap of {}, exiting.", + file_name, fta_score, score_cap + ); + std::process::exit(1); + } + } +} + +pub fn get_assessment(score: f64) -> String { + if score > 60.0 { + "Needs improvement".to_string() + } else if score > 50.0 { + "Could be better".to_string() + } else { + "OK".to_string() + } +} diff --git a/crates/fta/src/utils/tests.rs b/crates/fta/src/utils/tests.rs new file mode 100644 index 0000000..a3228be --- /dev/null +++ b/crates/fta/src/utils/tests.rs @@ -0,0 +1,40 @@ +#[cfg(test)] +mod tests { + use crate::utils::{get_assessment, is_excluded_filename}; + + #[test] + fn test_get_assessment_ok() { + let assessment = get_assessment(45.0); + assert_eq!(assessment, "OK"); + } + + #[test] + fn test_get_assessment_could_be_better() { + let assessment = get_assessment(60.0); + assert_eq!(assessment, "Could be better"); + } + + #[test] + fn test_get_assessment_needs_improvement() { + let assessment = get_assessment(75.0); + assert_eq!(assessment, "Needs improvement"); + } + + #[test] + fn test_is_excluded_filename_a() { + let pattern = String::from("*/naughty/*.ts"); + let mut patterns = Vec::new(); + patterns.push(pattern); + let result = is_excluded_filename("path/to/naughty/file.ts", &patterns); + assert_eq!(result, true); + } + + #[test] + fn test_is_excluded_filename_b() { + let pattern = String::from("*/naughty/*.ts"); + let mut patterns = Vec::new(); + patterns.push(pattern); + let result = is_excluded_filename("path/to/sensible/file.ts", &patterns); + assert_eq!(result, false); + } +}