From 8453c3897d205f511a1e81e9d271562c01f174aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elberte=20Pl=C3=ADnio?= Date: Wed, 26 Aug 2026 23:07:49 -0300 Subject: [PATCH 01/30] feat: add core parsing and configuration workspace --- .gitignore | 1 + Cargo.lock | 756 ++++++++++++++++++++++++++++++++++++ Cargo.toml | 27 ++ clippy.toml | 2 + crates/cli/Cargo.toml | 20 + crates/cli/src/main.rs | 3 + crates/core/Cargo.toml | 25 ++ crates/core/src/config.rs | 182 +++++++++ crates/core/src/diff.rs | 85 ++++ crates/core/src/language.rs | 367 +++++++++++++++++ crates/core/src/lib.rs | 11 + crates/core/src/scan.rs | 133 +++++++ 12 files changed, 1612 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 clippy.toml create mode 100644 crates/cli/Cargo.toml create mode 100644 crates/cli/src/main.rs create mode 100644 crates/core/Cargo.toml create mode 100644 crates/core/src/config.rs create mode 100644 crates/core/src/diff.rs create mode 100644 crates/core/src/language.rs create mode 100644 crates/core/src/lib.rs create mode 100644 crates/core/src/scan.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b83d222 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..014abcb --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,756 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.5.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "complexity-gate" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "complexity-gate-core", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "complexity-gate-core" +version = "0.1.0" +dependencies = [ + "anyhow", + "dirs", + "globset", + "ignore", + "serde", + "serde_json", + "tempfile", + "tree-sitter", + "tree-sitter-dart", + "tree-sitter-go", + "tree-sitter-javascript", + "tree-sitter-python", + "tree-sitter-rust", + "tree-sitter-svelte-ng", + "tree-sitter-typescript", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "ignore" +version = "0.4.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81776e6f9464432afcc28d03e52eb101c93b6f0566f52aef2427663e700f0403" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libredox" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "tree-sitter" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "974d205cc395652cfa8b37daa053fe56eebd429acf8dc055503fee648dae981e" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-dart" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "325dd1e24ee9ee21111e9c43680ae7d6010aaa9f282b048a99b9c7163c1cf553" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-go" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8560a4d2f835cc0d4d2c2e03cbd0dde2f6114b43bc491164238d333e28b16ea" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-javascript" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68204f2abc0627a90bdf06e605f5c470aa26fdcb2081ea553a04bdad756693f5" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-python" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bf85fd39652e740bf60f46f4cda9492c3a9ad75880575bf14960f775cb74a1c" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-rust" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-svelte-ng" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef0a71f9cf5e94373cc86c64893630c8a29bb25d3390a248268d08af2165fa37" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-typescript" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..be4b87b --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,27 @@ +[workspace] +members = ["crates/core", "crates/cli"] +resolver = "2" + +[workspace.package] +version = "0.1.0" +edition = "2024" +license = "MIT" +repository = "https://github.com/pickforge/complexity-gate" + +[workspace.dependencies] +anyhow = "=1.0.100" +clap = { version = "=4.5.51", features = ["derive"] } +dirs = "=6.0.0" +globset = "=0.4.18" +ignore = "=0.4.24" +serde = { version = "=1.0.228", features = ["derive"] } +serde_json = "=1.0.145" +tempfile = "=3.23.0" +tree-sitter = "=0.26.3" +tree-sitter-dart = "=0.2.0" +tree-sitter-go = "=0.25.0" +tree-sitter-javascript = "=0.25.0" +tree-sitter-python = "=0.25.0" +tree-sitter-rust = "=0.24.2" +tree-sitter-svelte-ng = "=1.0.2" +tree-sitter-typescript = "=0.23.2" diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..e6ca968 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,2 @@ +cognitive-complexity-threshold = 15 +too-many-lines-threshold = 100 diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml new file mode 100644 index 0000000..0c33835 --- /dev/null +++ b/crates/cli/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "complexity-gate" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[[bin]] +name = "complexity-gate" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +clap.workspace = true +complexity-gate-core = { path = "../core", version = "=0.1.0" } +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs new file mode 100644 index 0000000..c525025 --- /dev/null +++ b/crates/cli/src/main.rs @@ -0,0 +1,3 @@ +#![deny(clippy::cognitive_complexity, clippy::too_many_lines)] + +fn main() {} diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml new file mode 100644 index 0000000..3ab2fa1 --- /dev/null +++ b/crates/core/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "complexity-gate-core" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +anyhow.workspace = true +dirs.workspace = true +globset.workspace = true +ignore.workspace = true +serde.workspace = true +serde_json.workspace = true +tree-sitter.workspace = true +tree-sitter-dart.workspace = true +tree-sitter-go.workspace = true +tree-sitter-javascript.workspace = true +tree-sitter-python.workspace = true +tree-sitter-rust.workspace = true +tree-sitter-svelte-ng.workspace = true +tree-sitter-typescript.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs new file mode 100644 index 0000000..479097e --- /dev/null +++ b/crates/core/src/config.rs @@ -0,0 +1,182 @@ +use std::{collections::BTreeMap, fs, path::{Path, PathBuf}}; + +use anyhow::{Context, Result, bail}; +use globset::{Glob, GlobSet, GlobSetBuilder}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +const DEFAULTS: &str = include_str!("../../../config.default.json"); + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Limits { + pub complexity: usize, + pub depth: usize, + pub lines: usize, + pub params: usize, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct TestsConfig { + pub patterns: Vec, + pub exempt: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct HookConfig { + pub max_blocks: usize, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Default)] +#[serde(deny_unknown_fields)] +pub struct LanguageConfig { + #[serde(default)] + pub limits: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Config { + pub limits: Limits, + pub tests: TestsConfig, + pub ignore: Vec, + pub languages: BTreeMap, + pub hook: HookConfig, +} + +#[derive(Clone, Debug)] +pub struct ConfigResolution { + pub config: Config, + pub chain: Vec, +} + +pub fn load_config(start: &Path, explicit: Option<&Path>) -> Result { + let mut value: Value = serde_json::from_str(DEFAULTS).context("invalid embedded defaults")?; + let mut chain = vec![PathBuf::from("")]; + if let Some(user) = user_config_path().filter(|path| path.is_file()) { + merge_file(&mut value, &user)?; + chain.push(user); + } + let repo = explicit.map(Path::to_path_buf).or_else(|| nearest_repo_config(start)); + if let Some(path) = repo { + merge_file(&mut value, &path)?; + chain.push(path); + } + let config = serde_json::from_value(value).context("invalid configuration")?; + validate_languages(&config)?; + Ok(ConfigResolution { config, chain }) +} + +fn user_config_path() -> Option { + if let Some(root) = std::env::var_os("XDG_CONFIG_HOME") { + return Some(PathBuf::from(root).join("complexity-gate/config.json")); + } + dirs::config_dir().map(|root| root.join("complexity-gate/config.json")) +} + +fn nearest_repo_config(start: &Path) -> Option { + let start = if start.is_file() { start.parent()? } else { start }; + start.ancestors().map(|dir| dir.join(".complexity-gate.json")).find(|path| path.is_file()) +} + +fn merge_file(base: &mut Value, path: &Path) -> Result<()> { + let text = fs::read_to_string(path).with_context(|| format!("cannot read config {}", path.display()))?; + let patch: Value = serde_json::from_str(&text).with_context(|| format!("invalid JSON in {}", path.display()))?; + validate_keys(&patch, path)?; + shallow_merge(base, patch); + Ok(()) +} + +fn shallow_merge(base: &mut Value, patch: Value) { + let (Some(base), Value::Object(patch)) = (base.as_object_mut(), patch) else { return }; + for (key, value) in patch { + match (base.get_mut(&key), value) { + (Some(Value::Object(current)), Value::Object(next)) => current.extend(next), + (_, next) => { base.insert(key, next); } + } + } +} + +fn validate_keys(value: &Value, path: &Path) -> Result<()> { + let object = value.as_object().ok_or_else(|| anyhow::anyhow!("config {} must be an object", path.display()))?; + allowed(object, &["limits", "tests", "ignore", "languages", "hook"], "", path)?; + nested_keys(object, "limits", &["complexity", "depth", "lines", "params"], path)?; + nested_keys(object, "tests", &["patterns", "exempt"], path)?; + nested_keys(object, "hook", &["max_blocks"], path)?; + validate_language_keys(object, path) +} + +fn nested_keys(root: &Map, key: &str, keys: &[&str], path: &Path) -> Result<()> { + if let Some(value) = root.get(key) { + let object = value.as_object().ok_or_else(|| anyhow::anyhow!("{key} in {} must be an object", path.display()))?; + allowed(object, keys, key, path)?; + } + Ok(()) +} + +fn validate_language_keys(root: &Map, path: &Path) -> Result<()> { + let Some(value) = root.get("languages") else { return Ok(()) }; + let languages = value.as_object().ok_or_else(|| anyhow::anyhow!("languages in {} must be an object", path.display()))?; + for (name, value) in languages { + let object = value.as_object().ok_or_else(|| anyhow::anyhow!("languages.{name} must be an object"))?; + allowed(object, &["limits"], &format!("languages.{name}"), path)?; + nested_keys(object, "limits", &["complexity", "depth", "lines", "params"], path)?; + } + Ok(()) +} + +fn allowed(object: &Map, keys: &[&str], prefix: &str, path: &Path) -> Result<()> { + for key in object.keys() { + if !keys.contains(&key.as_str()) { + let full = if prefix.is_empty() { key.clone() } else { format!("{prefix}.{key}") }; + bail!("unknown config key `{full}` in {}", path.display()); + } + } + Ok(()) +} + +fn validate_languages(config: &Config) -> Result<()> { + const LANGUAGES: &[&str] = &["javascript", "typescript", "svelte", "dart", "rust", "python", "go"]; + for name in config.languages.keys() { + if !LANGUAGES.contains(&name.as_str()) { bail!("unknown config key `languages.{name}`"); } + } + Ok(()) +} + +impl Config { + pub fn limits_for(&self, language: &str) -> &Limits { + self.languages.get(language).and_then(|entry| entry.limits.as_ref()).unwrap_or(&self.limits) + } + + pub fn matcher(patterns: &[String]) -> Result { + let mut builder = GlobSetBuilder::new(); + for pattern in patterns { builder.add(Glob::new(pattern)?); } + Ok(builder.build()?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn partial_top_level_objects_merge() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.json"); + fs::write(&path, r#"{"limits":{"complexity":7}}"#).unwrap(); + let resolved = load_config(dir.path(), Some(&path)).unwrap(); + assert_eq!(resolved.config.limits.complexity, 7); + assert_eq!(resolved.config.limits.depth, 4); + } + + #[test] + fn unknown_nested_key_names_full_path() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.json"); + fs::write(&path, r#"{"hook":{"blocks":2}}"#).unwrap(); + let error = load_config(dir.path(), Some(&path)).unwrap_err().to_string(); + assert!(error.contains("hook.blocks")); + } +} diff --git a/crates/core/src/diff.rs b/crates/core/src/diff.rs new file mode 100644 index 0000000..68081d8 --- /dev/null +++ b/crates/core/src/diff.rs @@ -0,0 +1,85 @@ +use std::{collections::BTreeMap, path::{Path, PathBuf}, process::Command}; + +use anyhow::{Context, Result, bail}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LineRange { + pub start: usize, + pub end: usize, +} + +impl LineRange { + pub fn intersects(self, start: usize, end: usize) -> bool { + self.start <= end && start <= self.end + } +} + +#[derive(Clone, Debug, Default)] +pub struct ChangedFiles { + pub spans: BTreeMap>, + pub untracked: Vec, + pub fallback: bool, +} + +pub fn changed_files(cwd: &Path) -> Result { + if !git_ok(cwd, &["rev-parse", "--verify", "HEAD"]) { + return Ok(ChangedFiles { fallback: true, ..ChangedFiles::default() }); + } + let output = Command::new("git").current_dir(cwd) + .args(["diff", "--unified=0", "HEAD", "--"]) + .output().context("failed to execute git diff")?; + if !output.status.success() { bail!("git diff failed: {}", String::from_utf8_lossy(&output.stderr).trim()); } + let text = String::from_utf8(output.stdout).context("git diff output was not UTF-8")?; + let mut changed = ChangedFiles { spans: parse_diff_hunks(&text), ..ChangedFiles::default() }; + changed.untracked = untracked(cwd)?; + Ok(changed) +} + +fn git_ok(cwd: &Path, args: &[&str]) -> bool { + Command::new("git").current_dir(cwd).args(args).output().is_ok_and(|output| output.status.success()) +} + +fn untracked(cwd: &Path) -> Result> { + let output = Command::new("git").current_dir(cwd) + .args(["ls-files", "--others", "--exclude-standard", "-z"]) + .output().context("failed to list untracked files")?; + if !output.status.success() { bail!("git ls-files failed") } + Ok(output.stdout.split(|byte| *byte == 0).filter(|part| !part.is_empty()) + .map(|part| PathBuf::from(String::from_utf8_lossy(part).as_ref())).collect()) +} + +pub fn parse_diff_hunks(diff: &str) -> BTreeMap> { + let mut result = BTreeMap::new(); + let mut file = None; + for line in diff.lines() { + if let Some(path) = line.strip_prefix("+++ b/") { file = Some(PathBuf::from(path)); continue; } + if !line.starts_with("@@") { continue; } + let Some(path) = file.as_ref() else { continue }; + if let Some(range) = post_image_range(line) { + result.entry(path.clone()).or_insert_with(Vec::new).push(range); + } + } + result +} + +fn post_image_range(header: &str) -> Option { + let plus = header.split_whitespace().find(|part| part.starts_with('+'))?; + let mut values = plus.trim_start_matches('+').split(','); + let start = values.next()?.parse().ok()?; + let count = values.next().and_then(|value| value.parse().ok()).unwrap_or(1); + (count > 0).then_some(LineRange { start, end: start + count - 1 }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn synthetic_hunks_use_post_image_and_skip_deletions() { + let diff = "diff --git a/a.rs b/a.rs\n--- a/a.rs\n+++ b/a.rs\n@@ -2,2 +2,3 @@\n@@ -10,2 +11,0 @@\n@@ -20 +19 @@\n"; + let spans = parse_diff_hunks(diff); + assert_eq!(spans[Path::new("a.rs")], vec![ + LineRange { start: 2, end: 4 }, LineRange { start: 19, end: 19 }, + ]); + } +} diff --git a/crates/core/src/language.rs b/crates/core/src/language.rs new file mode 100644 index 0000000..928657e --- /dev/null +++ b/crates/core/src/language.rs @@ -0,0 +1,367 @@ +use std::path::Path; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use tree_sitter::{Language as TsLanguage, Node, Parser}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Language { JavaScript, TypeScript, Tsx, Svelte, Dart, Rust, Python, Go } + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct FunctionMetrics { + pub function: String, + pub line: usize, + pub end_line: usize, + pub complexity: usize, + pub depth: usize, + pub lines: usize, + pub params: usize, +} + +#[derive(Clone, Debug, Serialize)] +pub struct GrammarInfo { + pub language: &'static str, + pub grammar: &'static str, + pub version: &'static str, +} + +impl Language { + pub fn from_path(path: &Path) -> Option { + match path.extension()?.to_str()?.to_ascii_lowercase().as_str() { + "js" | "mjs" | "cjs" | "jsx" => Some(Self::JavaScript), + "ts" | "mts" | "cts" => Some(Self::TypeScript), + "tsx" => Some(Self::Tsx), "svelte" => Some(Self::Svelte), + "dart" => Some(Self::Dart), "rs" => Some(Self::Rust), + "py" | "pyi" => Some(Self::Python), "go" => Some(Self::Go), _ => None, + } + } + + pub fn name(self) -> &'static str { + match self { + Self::JavaScript => "javascript", Self::TypeScript | Self::Tsx => "typescript", + Self::Svelte => "svelte", Self::Dart => "dart", Self::Rust => "rust", + Self::Python => "python", Self::Go => "go", + } + } + + fn grammar(self) -> TsLanguage { + match self { + Self::JavaScript | Self::Svelte => tree_sitter_javascript::LANGUAGE.into(), + Self::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), + Self::Tsx => tree_sitter_typescript::LANGUAGE_TSX.into(), + Self::Dart => tree_sitter_dart::LANGUAGE.into(), + Self::Rust => tree_sitter_rust::LANGUAGE.into(), + Self::Python => tree_sitter_python::LANGUAGE.into(), + Self::Go => tree_sitter_go::LANGUAGE.into(), + } + } +} + +pub fn grammar_inventory() -> Vec { + vec![ + GrammarInfo { language: "javascript", grammar: "tree-sitter-javascript", version: "0.25.0" }, + GrammarInfo { language: "typescript/tsx", grammar: "tree-sitter-typescript", version: "0.23.2" }, + GrammarInfo { language: "svelte", grammar: "tree-sitter-svelte-ng", version: "1.0.2" }, + GrammarInfo { language: "dart", grammar: "tree-sitter-dart", version: "0.2.0" }, + GrammarInfo { language: "rust", grammar: "tree-sitter-rust", version: "0.24.2" }, + GrammarInfo { language: "python", grammar: "tree-sitter-python", version: "0.25.0" }, + GrammarInfo { language: "go", grammar: "tree-sitter-go", version: "0.25.0" }, + ] +} + +pub fn parse_source(language: Language, source: &str) -> Result> { + if language == Language::Svelte { return parse_svelte(source); } + parse_with_offset(language, source, 0) +} + +fn parse_with_offset(language: Language, source: &str, line_offset: usize) -> Result> { + let mut parser = Parser::new(); + parser.set_language(&language.grammar()).context("incompatible tree-sitter grammar")?; + let tree = parser.parse(source, None).context("tree-sitter parser returned no tree")?; + let mut functions = Vec::new(); + collect_functions(tree.root_node(), language, source, line_offset, &mut functions); + functions.sort_by_key(|item| (item.line, item.end_line)); + Ok(functions) +} + +fn collect_functions(node: Node<'_>, language: Language, source: &str, offset: usize, output: &mut Vec) { + if is_function(language, node.kind()) { + output.push(measure_function(node, language, source, offset)); + } + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { collect_functions(child, language, source, offset, output); } +} + +fn measure_function(node: Node<'_>, language: Language, source: &str, offset: usize) -> FunctionMetrics { + let mut score = Score { complexity: 1, depth: 0 }; + let body = node.child_by_field_name("body").unwrap_or(node); + measure_node(body, node.id(), language, source, 0, &mut score); + let start = node.start_position().row + 1; + let end = node.end_position().row + 1; + FunctionMetrics { + function: function_name(node, language, source), line: start + offset, + end_line: end + offset, complexity: score.complexity, depth: score.depth, + lines: significant_lines(source, start, end, language), params: parameter_count(node, language, source), + } +} + +struct Score { complexity: usize, depth: usize } + +fn measure_node(node: Node<'_>, root_id: usize, language: Language, source: &str, depth: usize, score: &mut Score) { + if node.id() != root_id && is_function(language, node.kind()) { return; } + if is_decision(node, language, source) { score.complexity += 1; } + let opens = opens_depth(node, language) && !is_else_if(node, language); + let next_depth = depth + usize::from(opens); + score.depth = score.depth.max(next_depth); + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + measure_node(child, root_id, language, source, next_depth, score); + } +} + +fn is_function(language: Language, kind: &str) -> bool { + match language { + Language::JavaScript | Language::TypeScript | Language::Tsx => matches!(kind, + "function_declaration" | "function_expression" | "arrow_function" | "method_definition" | + "generator_function" | "generator_function_declaration"), + Language::Dart => matches!(kind, "function_declaration" | "local_function_declaration" | + "function_expression" | "method_declaration"), + Language::Rust => matches!(kind, "function_item" | "closure_expression"), + Language::Python => matches!(kind, "function_definition" | "lambda"), + Language::Go => matches!(kind, "function_declaration" | "method_declaration" | "func_literal"), + Language::Svelte => false, + } +} + +fn is_decision(node: Node<'_>, language: Language, source: &str) -> bool { + match language { + Language::JavaScript | Language::TypeScript | Language::Tsx => js_decision(node, source), + Language::Dart => dart_decision(node, source), Language::Rust => rust_decision(node, source), + Language::Python => python_decision(node, source), Language::Go => go_decision(node, source), + Language::Svelte => false, + } +} + +fn js_decision(node: Node<'_>, source: &str) -> bool { + match node.kind() { + "if_statement" | "for_statement" | "for_in_statement" | "while_statement" | + "do_statement" | "switch_case" | "catch_clause" | "ternary_expression" => true, + "binary_expression" | "augmented_assignment_expression" => has_operator(node, source, &["&&", "||", "??", "&&=", "||=", "??="]), + _ => false, + } +} + +fn dart_decision(node: Node<'_>, source: &str) -> bool { + match node.kind() { + "if_statement" | "if_element" | "for_statement" | "for_element" | "while_statement" | + "switch_statement_case" | "switch_expression_case" | "catch_clause" | "conditional_expression" => true, + "logical_and_expression" | "logical_or_expression" | "if_null_expression" => true, + "assignment_expression" => has_operator(node, source, &["??="]), _ => false, + } +} + +fn rust_decision(node: Node<'_>, source: &str) -> bool { + match node.kind() { + "if_expression" | "for_expression" | "while_expression" | "loop_expression" => true, + "match_arm" => !is_default_arm(node, source), + "let_declaration" => node_text(node, source).contains(" else "), + "binary_expression" => has_operator(node, source, &["&&", "||"]), _ => false, + } +} + +fn python_decision(node: Node<'_>, source: &str) -> bool { + match node.kind() { + "if_statement" | "elif_clause" | "for_statement" | "while_statement" | "except_clause" | + "conditional_expression" | "for_in_clause" | "if_clause" => true, + "case_clause" => !is_default_arm(node, source), + "boolean_operator" => has_operator(node, source, &["and", "or"]), _ => false, + } +} + +fn go_decision(node: Node<'_>, source: &str) -> bool { + match node.kind() { + "if_statement" | "for_statement" | "expression_case" | "type_case" | "communication_case" => true, + "binary_expression" => has_operator(node, source, &["&&", "||"]), _ => false, + } +} + +fn has_operator(node: Node<'_>, source: &str, wanted: &[&str]) -> bool { + node.child_by_field_name("operator").and_then(|item| item.utf8_text(source.as_bytes()).ok()) + .is_some_and(|operator| wanted.contains(&operator)) + || wanted.iter().any(|operator| node_text(node, source).contains(operator)) +} + +fn is_default_arm(node: Node<'_>, source: &str) -> bool { + let text = node_text(node, source).trim_start(); + text.starts_with("_") || text.starts_with("case _") || text.starts_with("default") +} + +fn opens_depth(node: Node<'_>, language: Language) -> bool { + match language { + Language::JavaScript | Language::TypeScript | Language::Tsx => matches!(node.kind(), + "if_statement" | "for_statement" | "for_in_statement" | "while_statement" | "do_statement" | + "switch_statement" | "try_statement" | "catch_clause"), + Language::Dart => matches!(node.kind(), "if_statement" | "for_statement" | "while_statement" | + "do_statement" | "switch_statement" | "switch_expression" | "try_statement" | "catch_clause"), + Language::Rust => matches!(node.kind(), "if_expression" | "for_expression" | "while_expression" | + "loop_expression" | "match_expression"), + Language::Python => matches!(node.kind(), "if_statement" | "for_statement" | "while_statement" | + "match_statement" | "try_statement" | "except_clause" | "with_statement"), + Language::Go => matches!(node.kind(), "if_statement" | "for_statement" | "expression_switch_statement" | + "type_switch_statement" | "select_statement"), Language::Svelte => false, + } +} + +fn is_else_if(node: Node<'_>, language: Language) -> bool { + if !matches!(language, Language::JavaScript | Language::TypeScript | Language::Tsx | Language::Dart | Language::Go) { return false; } + let Some(parent) = node.parent() else { return false }; + parent.kind() == "if_statement" && parent.child_by_field_name("alternative").is_some_and(|item| item.id() == node.id()) +} + +fn function_name(node: Node<'_>, language: Language, source: &str) -> String { + if let Some(name) = direct_name(node, source) { return qualify_method(node, language, name, source); } + let mut parent = node.parent(); + while let Some(item) = parent { + if let Some(name) = binding_name(item, source) { return name; } + if is_function(language, item.kind()) { break; } + parent = item.parent(); + } + "".to_owned() +} + +fn direct_name<'a>(node: Node<'a>, source: &'a str) -> Option<&'a str> { + let named = node.child_by_field_name("name").or_else(|| { + node.child_by_field_name("signature").and_then(|signature| signature.child_by_field_name("name")) + })?; + named.utf8_text(source.as_bytes()).ok() +} + +fn qualify_method(node: Node<'_>, language: Language, name: &str, source: &str) -> String { + let method = matches!(node.kind(), "method_definition" | "method_declaration"); + if !method { return name.to_owned(); } + let type_kinds = match language { + Language::JavaScript | Language::TypeScript | Language::Tsx => &["class_declaration", "class"] as &[&str], + Language::Dart => &["class_definition", "extension_declaration"], _ => return name.to_owned(), + }; + let mut parent = node.parent(); + while let Some(item) = parent { + if type_kinds.contains(&item.kind()) { + if let Some(owner) = direct_name(item, source) { return format!("{owner}.{name}"); } + } + parent = item.parent(); + } + name.to_owned() +} + +fn binding_name<'a>(node: Node<'a>, source: &'a str) -> Option { + for field in ["name", "left", "key"] { + if let Some(name) = node.child_by_field_name(field) { + let text = name.utf8_text(source.as_bytes()).ok()?.trim(); + if !text.is_empty() && !text.contains([' ', '\n']) { return Some(text.to_owned()); } + } + } + None +} + +fn parameter_count(node: Node<'_>, language: Language, source: &str) -> usize { + let params = node.child_by_field_name("parameters").or_else(|| { + node.child_by_field_name("signature").and_then(|signature| signature.child_by_field_name("parameters")) + }); + let Some(params) = params else { return usize::from(node.kind() == "lambda" && node.child_by_field_name("parameters").is_some()) }; + let mut cursor = params.walk(); + let children: Vec<_> = params.named_children(&mut cursor).collect(); + match language { + Language::Go => children.iter().map(|child| go_parameter_count(*child)).sum(), + _ => children.iter().filter(|child| !receiver_parameter(**child, source)).count(), + } +} + +fn go_parameter_count(node: Node<'_>) -> usize { + if !matches!(node.kind(), "parameter_declaration" | "variadic_parameter_declaration") { return 1; } + let mut cursor = node.walk(); + node.named_children(&mut cursor).count().saturating_sub(1).max(1) +} + +fn receiver_parameter(node: Node<'_>, source: &str) -> bool { + matches!(node.kind(), "self_parameter") || matches!(node_text(node, source).trim(), "self" | "&self" | "&mut self" | "this") +} + +fn significant_lines(source: &str, start: usize, end: usize, language: Language) -> usize { + source.lines().skip(start.saturating_sub(1)).take(end - start + 1) + .filter(|line| !comment_or_blank(line, language)).count() +} + +fn comment_or_blank(line: &str, language: Language) -> bool { + let line = line.trim(); + if line.is_empty() { return true; } + if matches!(language, Language::Python) { return line.starts_with('#'); } + line.starts_with("//") || line.starts_with("/*") || line.starts_with('*') || line.starts_with("*/") +} + +fn node_text<'a>(node: Node<'_>, source: &'a str) -> &'a str { + node.utf8_text(source.as_bytes()).unwrap_or("") +} + +fn parse_svelte(source: &str) -> Result> { + validate_svelte_grammar(source)?; + let blocks = svelte_blocks(source); + let mut functions = Vec::new(); + for block in blocks.iter().filter(|block| block.kind == "script") { + let language = if block.opening.contains("lang=\"ts\"") || block.opening.contains("lang='ts'") { Language::TypeScript } else { Language::JavaScript }; + functions.extend(parse_with_offset(language, block.content, block.start_line)?); + } + functions.push(measure_template(source, &blocks)); + functions.sort_by_key(|item| (item.line, item.end_line)); + Ok(functions) +} + +fn validate_svelte_grammar(source: &str) -> Result<()> { + let mut parser = Parser::new(); + parser.set_language(&tree_sitter_svelte_ng::LANGUAGE.into()).context("incompatible Svelte grammar")?; + parser.parse(source, None).context("Svelte parser returned no tree")?; + Ok(()) +} + +struct SvelteBlock<'a> { kind: &'static str, opening: &'a str, content: &'a str, start: usize, end: usize, start_line: usize } + +fn svelte_blocks(source: &str) -> Vec> { + let mut blocks = Vec::new(); + for kind in ["script", "style"] { + let mut cursor = 0; + while let Some(relative) = source[cursor..].find(&format!("<{kind}")) { + let start = cursor + relative; + let Some(open_end_rel) = source[start..].find('>') else { break }; + let open_end = start + open_end_rel + 1; + let Some(close_rel) = source[open_end..].find(&format!("")) else { break }; + let end = open_end + close_rel; + blocks.push(SvelteBlock { kind, opening: &source[start..open_end], content: &source[open_end..end], + start, end: end + kind.len() + 3, start_line: source[..open_end].bytes().filter(|byte| *byte == b'\n').count() }); + cursor = end + kind.len() + 3; + } + } + blocks +} + +fn measure_template(source: &str, blocks: &[SvelteBlock<'_>]) -> FunctionMetrics { + let mut complexity = 1; + let mut depth: usize = 0; + let mut max_depth = 0; + for (index, line) in source.lines().enumerate() { + let byte = source.lines().take(index).map(|item| item.len() + 1).sum::(); + if blocks.iter().any(|block| byte >= block.start && byte < block.end) { continue; } + let trimmed = line.trim(); + let decisions = ["{#if ", "{:else if ", "{#each ", "{#await ", "{:catch"].iter().filter(|token| trimmed.contains(**token)).count(); + complexity += decisions + expression_decisions(trimmed); + if trimmed.contains("{/if}") || trimmed.contains("{/each}") || trimmed.contains("{/await}") { depth = depth.saturating_sub(1); } + if trimmed.contains("{#if ") || trimmed.contains("{#each ") || trimmed.contains("{#await ") { depth += 1; max_depth = max_depth.max(depth); } + } + FunctionMetrics { function: "