diff --git a/.gitignore b/.gitignore index 7bf286f..ee5854e 100644 --- a/.gitignore +++ b/.gitignore @@ -161,4 +161,8 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ -.vscode/ \ No newline at end of file +.vscode/ + +# Added by cargo + +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..886d8ba --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1065 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[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.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[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.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5caf74d17c3aec5495110c34cc3f78644bfa89af6c8993ed4de2790e49b6499" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "370daa45065b80218950227371916a1633217ae42b2715b2287b606dcd618e24" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[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 = "git-varcs" +version = "2.0.0-alpha.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "git2", + "pest", + "pest_derive", + "regex", + "serde", + "serde_json", +] + +[[package]] +name = "git2" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" +dependencies = [ + "bitflags", + "libc", + "libgit2-sys", + "log", + "openssl-probe", + "openssl-sys", + "url", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[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 = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "libgit2-sys" +version = "0.18.3+1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9b3acc4b91781bb0b3386669d325163746af5f6e4f73e6d2d630e09a35f3487" +dependencies = [ + "cc", + "libc", + "libssh2-sys", + "libz-sys", + "openssl-sys", + "pkg-config", +] + +[[package]] +name = "libssh2-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[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 = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +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 = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[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.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[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.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..74fe0de --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "git-varcs" +version = "2.0.0-alpha.0" +edition = "2024" + +[[bin]] +name = "git-vms" +path = "src/main.rs" + +[dependencies] +anyhow = "1.0.101" +chrono = "0.4.44" +clap = { version = "4.5.59", features = ["derive"] } +git2 = "0.20.4" +pest = "2.8.6" +pest_derive = "2.8.6" +regex = "1.12.3" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" diff --git a/README.md b/README.md index 4df1e3c..d7a4cc3 100644 --- a/README.md +++ b/README.md @@ -160,3 +160,31 @@ For further details or to explore more usage options, refer to the [Typer Docume ## Development 1. Create a virtual environment and install both requirement-files. + +### Rust Components + +To build and run the rust components, use standard Cargo commands from the project root: + +```bash +# Build the project +cargo build + +# Run the project +cargo run +``` + +The binary will be compiled into `target/debug/git-vms`. + +**Symlinking Binaries** + +For rapid development, you can symlink the compiled binaries to a location in your `$PATH` so you can run them like any other git command: + +```bash +ln -s $(pwd)/target/debug/git-vms /path/to/your/bin/git-vms +``` + +After that, you can run the binary directly: + +```bash +git vms --version +``` diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..b1f8eeb --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,38 @@ +use clap::{Parser, Subcommand}; + +#[derive(Parser)] +#[command(version, about)] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, +} + +#[derive(Subcommand)] +pub enum Commands { + /// Initialize variation management support in the git repository + Init, + /// Associate feature metadata and stage files to the git index + Add { + /// Files to tag with feature meta and stage to git + files: Vec, + /// Feature to associate the file(s) with + #[arg(short, long)] + feature: String, + }, + /// Commit staged changes with feature metadata + Commit { + /// Commit message + #[arg(short, long)] + message: String, + }, + /// Checkout a variant derived from the feature model in model.cfr + Checkout { + /// Name of the variant instance in model.cfr + name: String, + /// Refresh the variant if it already exists + #[arg(short, long)] + refresh: bool, + }, + /// Reconstruct the full file from the edited variant view + Putback {}, +} diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..220c1d6 --- /dev/null +++ b/src/commands.rs @@ -0,0 +1,5 @@ +pub mod add; +pub mod checkout; +pub mod commit; +pub mod init; +pub mod putback; diff --git a/src/commands/add.rs b/src/commands/add.rs new file mode 100644 index 0000000..ec138e0 --- /dev/null +++ b/src/commands/add.rs @@ -0,0 +1,27 @@ +use anyhow::{Context, Result}; +use git2::{IndexAddOption, Repository}; + +use crate::meta::{read_meta, write_meta}; + +pub fn run(repo: &Repository, files: &[String], feature: &str) -> Result<()> { + let feature_meta_path = repo.path().join("varcs").join("FEATUREMETA"); + let current_index_feature = read_meta(&feature_meta_path).unwrap_or_default(); + + if current_index_feature.is_empty() { + write_meta(&feature_meta_path, feature)?; + } else if feature != current_index_feature { + eprintln!( + "Current index is already associated with feature '{}'. Commit it before continuing.", + current_index_feature + ); + return Ok(()); + } + + let mut index = repo.index()?; + index + .add_all(files, IndexAddOption::DEFAULT, None) + .with_context(|| "Failed to stage provided paths")?; + index.write()?; + + Ok(()) +} diff --git a/src/commands/checkout.rs b/src/commands/checkout.rs new file mode 100644 index 0000000..6d6c6fd --- /dev/null +++ b/src/commands/checkout.rs @@ -0,0 +1,248 @@ +use std::collections::{HashMap, HashSet}; +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, anyhow}; +use chrono::Utc; +use git2::{BlameOptions, Commit, ObjectType, Oid, Repository, Status, StatusOptions, Tree}; +use regex::Regex; + +use crate::meta::{VariantMeta, read_variant_meta, write_variant_meta}; +use crate::parser::evaluator::Evaluator; +use crate::parser::grammar::parse_clafer_module; + +pub fn run(repo: &Repository, name: &str, refresh: bool) -> Result<()> { + // Only uncommitted changes to tracked files should block a derivation. + let mut status_opts = StatusOptions::new(); + status_opts.include_untracked(false).include_ignored(false); + let statuses = repo.statuses(Some(&mut status_opts))?; + if statuses.iter().any(|e| e.status() != Status::CURRENT) { + anyhow::bail!("Repository has uncommitted changes; commit or stash them first."); + } + + let features = derive_features(repo, name).context("Failed to derive variant from model")?; + let ref_name = format!("refs/heads/variant/{}", name); + + let variant_ref = repo.find_reference(&ref_name).ok(); + + let parent_refs = if let Some(variant_ref) = variant_ref { + if refresh { + let commit = variant_ref.peel_to_commit()?; + vec![commit] + } else { + anyhow::bail!("Variant already exists. Use --refresh to update it."); + } + } else { + vec![] + }; + let parent_refs: Vec<&Commit> = parent_refs.iter().collect(); + + let target_features: HashSet = features.iter().cloned().collect(); + let sig = repo.signature()?; + + let head = repo.head()?; + let head_commit = head.peel_to_commit()?; + let tree = head.peel_to_tree()?; + + let variant_tree_oid = build_variant_tree(repo, &tree, Path::new(""), &target_features)?; + let variant_tree = repo.find_tree(variant_tree_oid)?; + + repo.commit( + Some(&ref_name), + &sig, + &sig, + &format!("variant derivation with features {:?}", features), + &variant_tree, + &parent_refs, + )?; + + // Store info about the base state + let mut meta_store = read_variant_meta(repo).unwrap_or_default(); + let meta = VariantMeta { + commit: head_commit.id().to_string(), + tree: tree.id().to_string(), + features: features.clone(), + created_at: Utc::now().to_rfc3339(), + }; + meta_store.variants.insert(name.to_string(), meta); + write_variant_meta(repo, &meta_store)?; + + // Switch to the derived variant branch + repo.set_head(&ref_name)?; + repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force()))?; + Ok(()) +} + +/// Resolves the instance `name` against the project's `model.cfr` feature model, +/// returning the sorted list of included leaf features. +fn derive_features(repo: &Repository, name: &str) -> Result> { + let base_path = repo + .path() + .parent() + .context("Repository has no parent directory")?; + let model_path = base_path.join("model.cfr"); + let source = std::fs::read_to_string(&model_path) + .with_context(|| format!("Failed to read model file {}", model_path.display()))?; + + let decls = parse_clafer_module(&source); + let config = Evaluator::new(decls).resolve_instance(name)?; + + let mut features: Vec = config.included.into_iter().collect(); + features.sort(); + Ok(features) +} + +fn build_variant_tree( + repo: &Repository, + tree: &Tree, + base_path: &Path, + target_features: &HashSet, +) -> Result { + let mut builder = repo.treebuilder(None)?; + for entry in tree.iter() { + let name = entry + .name() + .ok_or_else(|| anyhow!("tree entry without valid UTF-8 name"))?; + let full_path = base_path.join(name); + + match entry.kind() { + Some(ObjectType::Blob) => { + let content = process_file(repo, &full_path, target_features) + .with_context(|| format!("Failed to process file {}", full_path.display()))?; + let oid = repo.blob(content.as_bytes())?; + builder.insert(name, oid, entry.filemode())?; + } + Some(ObjectType::Tree) => { + let subtree = repo.find_tree(entry.id())?; + let subtree_oid = build_variant_tree(repo, &subtree, &full_path, target_features) + .with_context(|| { + format!("Failed to process directory: {}", full_path.display()) + })?; + builder.insert(name, subtree_oid, entry.filemode())?; + } + _ => {} + } + } + + Ok(builder.write()?) +} + +fn process_file( + repo: &Repository, + name: &PathBuf, + target_features: &HashSet, +) -> Result { + let file = File::open(name)?; + let reader = BufReader::new(file); + let lines: Vec = reader.lines().collect::>()?; + let mut output: Vec = vec![]; + + let mut hidden_start: Option = None; + let mut current_hidden_feature: Option = None; + let mut commit_cache: HashMap> = HashMap::new(); + + let mut blame_opts = BlameOptions::new(); + let blame = repo.blame_file(Path::new(name), Some(&mut blame_opts))?; + + for (i, line) in lines.iter().enumerate() { + let line_no = i + 1; + let hunk = blame.get_line(line_no).context("Cant get line info")?; + let oid = hunk.final_commit_id(); + + let line_feature = commit_cache.entry(oid).or_insert_with(|| { + let commit = repo.find_commit(oid).ok()?; + let summary = commit.message()?; + extract_feature_meta(summary).map(|s| s.to_string()) + }); + + let is_visible = match line_feature { + Some(f) => target_features.contains(f), + None => true, + }; + + if is_visible { + // Close existing hidden block if exists + if let (Some(start), Some(feature)) = + (hidden_start.take(), current_hidden_feature.take()) + { + output.push(format!( + "# morph:{}-{} hidden feature '{}'", + start, + line_no - 1, + feature + )); + } + output.push(line.clone()); + } else { + if hidden_start.is_none() { + // New hidden block starts + hidden_start = Some(line_no); + current_hidden_feature = line_feature.clone(); + } else if current_hidden_feature.as_ref() != line_feature.as_ref() { + // Feature changed while still in a hidden state: Close old, start new + if let Some(feature) = current_hidden_feature.take() { + output.push(format!( + "# morph:{}-{} hidden feature '{}'", + hidden_start.unwrap(), + line_no - 1, + feature + )); + } + hidden_start = Some(line_no); + current_hidden_feature = line_feature.clone(); + } + } + } + + if let (Some(start), Some(feature)) = (hidden_start, current_hidden_feature) { + output.push(format!( + "# morph:{}-{} hidden feature '{}'", + start, + lines.len(), + feature + )); + } + + Ok(output.join("\n")) +} + +// TODO: building each time can be expensive. Look for an alternative +fn extract_feature_meta(commit: &str) -> Option<&str> { + let re = Regex::new(r"(?m)^FEATURE:\s*(?P[^\n]+)$").unwrap(); + re.captures(commit) + .and_then(|caps| caps.name("feature").map(|m| m.as_str())) +} + +#[cfg(test)] +mod tests { + use super::extract_feature_meta; + + #[test] + fn extracts_feature_when_present() { + let msg = "chore: init\n\nFEATURE: core"; + assert_eq!(extract_feature_meta(msg), Some("core")); + } + + #[test] + fn extracts_feature_with_other_metadata() { + let msg = "\ +chore: initialize fog support in repo + +CHANGE_ID: de9378ec963973b367aa0ad706fa006af3ce65da +FEATURE: core"; + assert_eq!(extract_feature_meta(msg), Some("core")); + } + + #[test] + fn returns_none_when_no_feature() { + let msg = "fix: missing semicolon"; + assert_eq!(extract_feature_meta(msg), None); + } + + #[test] + fn ignores_invalid_format() { + let msg = "Feature core"; + assert_eq!(extract_feature_meta(msg), None); + } +} diff --git a/src/commands/commit.rs b/src/commands/commit.rs new file mode 100644 index 0000000..3de78fc --- /dev/null +++ b/src/commands/commit.rs @@ -0,0 +1,48 @@ +use anyhow::{Context, Result}; +use git2::Repository; + +use crate::meta::{read_meta, write_meta}; + +pub fn run(repo: &Repository, message: &str) -> Result<()> { + let feature_meta_path = repo.path().join("varcs").join("FEATUREMETA"); + let current_index_feature = read_meta(&feature_meta_path).unwrap_or_default(); + + if current_index_feature.is_empty() { + eprintln!("No associated feature metadata. Aborting."); + return Ok(()); + } + + let mut index = repo.index()?; + + let tree_oid = index + .write_tree() + .context("failed to write tree from index")?; + let tree = repo.find_tree(tree_oid)?; + + let sig = repo + .signature() + .context("failed to determine git signature")?; + index.write()?; + + let parent_commit = match repo.head() { + Ok(head) => { + let commit = head + .peel_to_commit() + .context("HEAD does not point to a commit")?; + Some(commit) + } + Err(_) => None, + }; + + let meta_msg = format!( + "{}\n\nCHANGE_ID: {}\nFEATURE: {}\n", + message, tree_oid, current_index_feature + ); + match parent_commit { + Some(parent) => repo.commit(Some("HEAD"), &sig, &sig, &meta_msg, &tree, &[&parent])?, + None => repo.commit(Some("HEAD"), &sig, &sig, &meta_msg, &tree, &[])?, + }; + + write_meta(&feature_meta_path, "")?; + Ok(()) +} diff --git a/src/commands/init.rs b/src/commands/init.rs new file mode 100644 index 0000000..a671793 --- /dev/null +++ b/src/commands/init.rs @@ -0,0 +1,46 @@ +use std::fs; +use std::io::Write; + +use anyhow::{Context, Result}; +use git2::Repository; + +const DEFAULT_MODEL: &str = r#"// git-varcs feature model +// Describe your feature model as an abstract clafer, then declare each +// variant as a concrete instance that selects the features it needs. +// +// abstract Product { +// xor Storage { +// SQLite +// Postgres +// } +// Scanner ? +// } +// +// MyVariant : Product { +// [ SQLite ] +// [ Scanner ] +// } +"#; + +pub fn run(repo: &Repository) -> Result<()> { + let base_path = repo + .path() + .parent() + .context("Repository has no parent directory")?; + let model_path = base_path.join("model.cfr"); + let meta_path = base_path.join(".git/varcs"); + + fs::create_dir(meta_path)?; + + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&model_path) + .with_context(|| format!("Failed to create model file at path: {:?}", model_path))?; + + file.write(DEFAULT_MODEL.as_bytes()) + .with_context(|| format!("Failed to write default model to: {:?}", model_path))?; + + println!("Initialized feature model at {}", model_path.display()); + Ok(()) +} diff --git a/src/commands/putback.rs b/src/commands/putback.rs new file mode 100644 index 0000000..3d68218 --- /dev/null +++ b/src/commands/putback.rs @@ -0,0 +1,92 @@ +use crate::meta::read_variant_meta; +use anyhow::{Result, anyhow}; +use git2::{ObjectType, Repository, TreeWalkMode, TreeWalkResult}; +use std::fs; +use std::path::PathBuf; + +pub fn run(repo: &Repository) -> Result<()> { + let meta_store = read_variant_meta(repo).unwrap_or_default(); + let origin = meta_store + .variants + .get("simple-calculator") + .ok_or_else(|| anyhow!("Variant 'simple-calculator' not found"))?; + + let commit = repo.find_commit_by_prefix(&origin.commit)?; + let tree = commit.tree()?; + + tree.walk(TreeWalkMode::PreOrder, |root, entry| { + if entry.kind() != Some(ObjectType::Blob) { + return TreeWalkResult::Ok; + } + + let Some(name) = entry.name() else { + return TreeWalkResult::Ok; + }; + + let path = PathBuf::from(format!("{}{}", root, name)); + + // Only process files that actually exist in the working directory + if !path.exists() { + return TreeWalkResult::Ok; + } + + let Ok(obj) = entry.to_object(repo) else { + return TreeWalkResult::Ok; + }; + + let Some(blob) = obj.as_blob() else { + return TreeWalkResult::Ok; + }; + + if let Err(e) = process_file(&path, blob) { + eprintln!("Failed to process {:?}: {}", path, e); + } + + TreeWalkResult::Ok + })?; + + Ok(()) +} + +fn process_file(path: &PathBuf, blob: &git2::Blob) -> Result<()> { + let original_content = std::str::from_utf8(blob.content())?; + let original_lines: Vec<&str> = original_content.split_terminator('\n').collect(); + + let variant_content = fs::read_to_string(path)?; + let mut reconstructed: Vec = vec![]; + + for line in variant_content.lines() { + let trimmed = line.trim(); + + if trimmed.starts_with("# morph:") { + if let Some((start, end)) = extract_range(trimmed) { + // Marker range is 1-based. + for idx in start..=end { + if idx > 0 && idx <= original_lines.len() { + reconstructed.push(original_lines[idx - 1].to_string()); + } else { + eprintln!("Warning: Index {} out of range in {:?}", idx, path); + } + } + continue; + } + } + + // Not a marker so keep the line as it is + reconstructed.push(line.to_string()); + } + + fs::write(path, reconstructed.join("\n"))?; + Ok(()) +} + +fn extract_range(trimmed: &str) -> Option<(usize, usize)> { + // Expected format: "# morph:1-10 hidden feature 'name'" + let payload = trimmed.strip_prefix("# morph:")?; + let range_part = payload.split_whitespace().next()?; + let (start_str, end_str) = range_part.split_once("-")?; + + let start = start_str.parse::().ok()?; + let end = end_str.parse::().ok()?; + Some((start, end)) +} diff --git a/src/main.py b/src/main.py deleted file mode 100644 index 4e3d766..0000000 --- a/src/main.py +++ /dev/null @@ -1,5 +0,0 @@ -# &begin[test] -def test(): ... - - -# &end[test] diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..d5b44b0 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,36 @@ +mod cli; +mod commands; +mod meta; +mod parser; + +use anyhow::{Context, Result}; +use clap::Parser; +use git2::Repository; + +use cli::{Cli, Commands}; +use commands::{add, checkout, commit, init, putback}; + +fn main() -> Result<()> { + let args = Cli::parse(); + let repo = Repository::discover(".").context("Not in a git repository")?; + + match args.command { + Commands::Init => { + init::run(&repo).context("Failed to initialize varcs support")?; + } + Commands::Add { files, feature } => { + add::run(&repo, &files, &feature).context("Failed staging files with meta")?; + } + Commands::Commit { message } => { + commit::run(&repo, &message).context("Failed commiting current index")?; + } + Commands::Checkout { name, refresh } => { + checkout::run(&repo, &name, refresh) + .with_context(|| format!("Failed to derive variant '{}'", name))?; + } + Commands::Putback {} => { + putback::run(&repo).context("Failed to sync edited view")?; + } + } + Ok(()) +} diff --git a/src/meta.rs b/src/meta.rs new file mode 100644 index 0000000..b473829 --- /dev/null +++ b/src/meta.rs @@ -0,0 +1,46 @@ +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use git2::Repository; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Default)] +pub struct VariantMetaStore { + pub variants: HashMap, +} + +#[derive(Serialize, Deserialize)] +pub struct VariantMeta { + pub commit: String, + pub tree: String, + pub features: Vec, + pub created_at: String, // ISO 8601 +} + +pub fn read_variant_meta(repo: &Repository) -> Result { + let base_path = repo.path(); + let meta_path = base_path.join("variant-meta.json"); + let data = fs::read_to_string(meta_path)?; + let store = serde_json::from_str(&data).context("Failed to deserialize variant meta")?; + Ok(store) +} + +pub fn write_variant_meta(repo: &Repository, store: &VariantMetaStore) -> Result<()> { + let base_path = repo.path(); + let meta_path = base_path.join("variant-meta.json"); + let data = serde_json::to_string_pretty(&store).context("Failed to serialize variant meta")?; + fs::write(&meta_path, data)?; + Ok(()) +} + +pub fn read_meta(path: &PathBuf) -> Result { + let data = fs::read_to_string(path)?; + Ok(data) +} + +pub fn write_meta(path: &PathBuf, content: &str) -> Result<()> { + fs::write(path, content)?; + Ok(()) +} diff --git a/src/parser.rs b/src/parser.rs new file mode 100644 index 0000000..4e9fc35 --- /dev/null +++ b/src/parser.rs @@ -0,0 +1,3 @@ +pub mod ast; +pub mod evaluator; +pub mod grammar; diff --git a/src/parser/ast.rs b/src/parser/ast.rs new file mode 100644 index 0000000..5eb7c12 --- /dev/null +++ b/src/parser/ast.rs @@ -0,0 +1,34 @@ +#[derive(Debug, Clone)] +pub enum Declaration { + EnumDecl(String, Vec), + Element(Element), +} + +#[derive(Debug, Clone)] +pub enum Element { + Clafer(Clafer), + Constraint(Expr), +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Expr { + Ref(String), + Not(Box), + And(Box, Box), + Or(Box, Box), + Xor(Box, Box), + Implies(Box, Box), + Iff(Box, Box), + /// Outside the boolean-feature subset; always "unknown" during resolution. + Other(String), +} + +#[derive(Debug, Clone)] +pub struct Clafer { + pub is_abstract: bool, + pub gcard: Option, + pub name: String, + pub super_type: Option, + pub card: Option, + pub children: Vec, +} diff --git a/src/parser/clafer-subset.cf b/src/parser/clafer-subset.cf new file mode 100644 index 0000000..3cb8916 --- /dev/null +++ b/src/parser/clafer-subset.cf @@ -0,0 +1,75 @@ +Module. Module ::= [Declaration] ; + +EnumDecl. Declaration ::= "enum" PosIdent "=" [EnumId] ; +ElementDecl. Declaration ::= Element ; + +Clafer. Clafer ::= Abstract GCard PosIdent Super Reference Card Init Elements ; +Constraint. Constraint ::= "[" Exp "]" ; + +AbstractEmpty. Abstract ::= ; +Abstract. Abstract ::= "abstract" ; + +ElementsEmpty. Elements ::= ; +ElementsList. Elements ::= "{" [Element] "}" ; + +Subclafer. Element ::= Clafer ; +Subconstraint. Element ::= Constraint ; + +SuperEmpty. Super ::= ; +SuperSome. Super ::= ":" Exp8 ; + +ReferenceEmpty. Reference ::= ; +ReferenceSet. Reference ::= "->" Exp6 ; + +InitEmpty. Init ::= ; +InitConstant. Init ::= "=" Exp ; + +GCardEmpty. GCard ::= ; +GCardXor. GCard ::= "xor" ; +GCardOr. GCard ::= "or" ; +GCardMux. GCard ::= "mux" ; +GCardOpt. GCard ::= "opt" ; +GCardInterval. GCard ::= NCard ; + +CardEmpty. Card ::= ; +CardLone. Card ::= "?" ; +CardSome. Card ::= "+" ; +CardAny. Card ::= "*" ; +CardNum. Card ::= PosInteger ; +CardInterval. Card ::= NCard ; + +NCard. NCard ::= PosInteger ".." ExInteger ; +ExIntegerAst. ExInteger ::= "*" ; +ExIntegerNum. ExInteger ::= PosInteger ; + +-- Boolean & Relational Expressions (Precedence modeled via Exp levels) +EIff. Exp ::= Exp "<=>" Exp1 ; +EImplies. Exp1 ::= Exp1 "=>" Exp2 ; +EOr. Exp2 ::= Exp2 "||" Exp3 ; +EXor. Exp3 ::= Exp3 "xor" Exp4 ; +EAnd. Exp4 ::= Exp4 "&&" Exp5 ; +ENeg. Exp5 ::= "!" Exp5 ; +ELt. Exp6 ::= Exp6 "<" Exp7 ; +EGt. Exp6 ::= Exp6 ">" Exp7 ; +EEq. Exp6 ::= Exp6 "=" Exp7 ; +ELte. Exp6 ::= Exp6 "<=" Exp7 ; +EGte. Exp6 ::= Exp6 ">=" Exp7 ; +ENeq. Exp6 ::= Exp6 "!=" Exp7 ; +EIn. Exp6 ::= Exp6 "in" Exp7 ; +ENin. Exp6 ::= Exp6 "not" "in" Exp7 ; +EAdd. Exp7 ::= Exp7 "+" Exp8 ; +ESub. Exp7 ::= Exp7 "-" Exp8 ; +EJoin. Exp8 ::= Exp8 "." Exp9 ; + +EQuantExp. Exp9 ::= Quant Exp10 ; +ClaferId. Exp10 ::= PosIdent ; +EInt. Exp10 ::= PosInteger ; +EStr. Exp10 ::= PosString ; +EParens. Exp10 ::= "(" Exp ")" ; + +QuantNo. Quant ::= "no" ; +QuantNot. Quant ::= "not" ; +QuantLone. Quant ::= "lone" ; +QuantOne. Quant ::= "one" ; +QuantSome. Quant ::= "some" ; +QuantAll. Quant ::= "all" ; diff --git a/src/parser/clafer.pest b/src/parser/clafer.pest new file mode 100644 index 0000000..481b52a --- /dev/null +++ b/src/parser/clafer.pest @@ -0,0 +1,65 @@ +// --- Entry Point --- +module = { SOI ~ declaration* ~ EOI } + +// --- Declarations --- +declaration = { enum_decl | element } +enum_decl = { "enum" ~ ident ~ "=" ~ ident ~ ("|" ~ ident)* } +element = { clafer | constraint } + +// --- Clafer Core --- +clafer = { + abstract_mod? ~ + gcard? ~ + ident ~ + super_mod? ~ + ref_mod? ~ + card? ~ + init_mod? ~ + elements? +} + +constraint = { "[" ~ expr ~ "]" } +elements = { "{" ~ element* ~ "}" } + +// --- Modifiers --- +abstract_mod = { "abstract" } +super_mod = { ":" ~ expr } +ref_mod = { "->" ~ expr } +init_mod = { "=" ~ expr } + +// --- Cardinalities --- +gcard = { "xor" | "or" | "mux" | "opt" | ncard } +card = { "?" | "+" | "*" | ncard | int } +ncard = { int ~ ".." ~ (int | "*") } + +// --- Expressions --- +expr = { iff_expr } +iff_expr = { implies_expr ~ ("<=>" ~ implies_expr)* } +implies_expr = { or_expr ~ ("=>" ~ or_expr)* } +or_expr = { xor_expr ~ ("||" ~ xor_expr)* } +xor_expr = { and_expr ~ ("xor" ~ and_expr)* } +and_expr = { cmp_expr ~ ("&&" ~ cmp_expr)* } + +cmp_expr = { add_expr ~ (cmp_op ~ add_expr)* } +cmp_op = { "<=" | ">=" | "<" | ">" | "=" | "!=" | "not in" | "in" } + +add_expr = { join_expr ~ (add_op ~ join_expr)* } +add_op = { "+" | "-" } + +join_expr = { primary ~ ("." ~ primary)* } + +primary = { unary_op* ~ quantifier? ~ (ident | int | str | "(" ~ expr ~ ")") } +unary_op = { "!" | "-" } +quantifier = { "no" | "not" | "lone" | "one" | "some" | "all" } + +// --- Tokens --- +ident = @{ ASCII_ALPHA ~ (ASCII_ALPHANUMERIC | "_" | "'")* } +int = @{ ASCII_DIGIT+ } +str = @{ "\"" ~ (!"\"" ~ ANY)* ~ "\"" } + +// --- Implicit Whitespace & Comments (Silent) --- +WHITESPACE = _{ " " | "\t" | "\r" | "\n" } +COMMENT = _{ + ("//" ~ (!"\n" ~ ANY)*) | + ("/*" ~ (!"*/" ~ ANY)* ~ "*/") +} diff --git a/src/parser/evaluator.rs b/src/parser/evaluator.rs new file mode 100644 index 0000000..6c75766 --- /dev/null +++ b/src/parser/evaluator.rs @@ -0,0 +1,457 @@ +use super::ast::{Clafer, Declaration, Element, Expr}; +use anyhow::{Result, anyhow}; +use std::collections::{HashMap, HashSet}; + +/// A resolved instance: each leaf feature classified as included or excluded. +/// Structural containers and the abstract supertype appear in neither set. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct Configuration { + pub included: HashSet, + pub excluded: HashSet, +} + +/// Per-clafer state during resolution: active / inactive / undetermined. +type Assignment = HashMap>; + +pub struct Evaluator { + // Global registry of every Clafer definition, keyed by name. + pub registry: HashMap, +} + +impl Evaluator { + pub fn new(decls: Vec) -> Self { + let mut registry = HashMap::new(); + + // Index nested clafers too (e.g. SQLite inside Storage). + fn index_recursive(elements: &[Element], map: &mut HashMap) { + for el in elements { + if let Element::Clafer(c) = el { + map.insert(c.name.clone(), c.clone()); + index_recursive(&c.children, map); + } + } + } + + for decl in decls { + if let Declaration::Element(Element::Clafer(c)) = decl { + registry.insert(c.name.clone(), c.clone()); + index_recursive(&c.children, &mut registry); + } + } + + Self { registry } + } + + /// Resolves an instance against its abstract model into included/excluded leaf features. + pub fn resolve_instance(&self, instance_name: &str) -> Result { + let instance = self + .registry + .get(instance_name) + .ok_or_else(|| anyhow!("Instance '{}' not found in model", instance_name))?; + let super_name = instance + .super_type + .as_ref() + .ok_or_else(|| anyhow!("Instance '{}' has no super type", instance_name))?; + let supertype = self + .registry + .get(super_name) + .ok_or_else(|| anyhow!("Super type '{}' of instance '{}' not found", super_name, instance_name))?; + + let mut subtree_names = HashSet::new(); + self.collect_subtree_names(supertype, &mut subtree_names); + + let mut assignment: Assignment = subtree_names.iter().map(|n| (n.clone(), None)).collect(); + try_assign(&mut assignment, super_name, true); + + // Seed explicit instance-body selections first so they win over inference. + let mut instance_constraints = Vec::new(); + collect_constraints(&instance.children, &mut instance_constraints); + for expr in &instance_constraints { + propagate_expr(expr, true, &mut assignment); + } + + // All subtree constraints for the fixed-point loop; re-asserting the + // instance constraints is a harmless no-op. + let mut constraints = instance_constraints; + collect_constraints(&supertype.children, &mut constraints); + + loop { + let mut changed = false; + changed |= self.propagate_mandatory_children(&subtree_names, &mut assignment); + changed |= self.propagate_group_cardinality(&subtree_names, &mut assignment); + for expr in &constraints { + changed |= propagate_expr(expr, true, &mut assignment); + } + if !changed { + break; + } + } + + let mut config = Configuration::default(); + for name in &subtree_names { + if name == super_name { + continue; + } + let Some(clafer) = self.registry.get(name) else { + continue; + }; + if clafer.is_abstract { + continue; + } + let is_structural_leaf = !clafer.children.iter().any(|e| matches!(e, Element::Clafer(_))); + if !is_structural_leaf { + continue; + } + match assignment.get(name).copied().flatten() { + Some(true) => { + config.included.insert(name.clone()); + } + _ => { + config.excluded.insert(name.clone()); + } + } + } + Ok(config) + } + + /// Collects every clafer name nested under `root` (inclusive), bounding the + /// resolution so unrelated declarations elsewhere don't leak in. + fn collect_subtree_names(&self, root: &Clafer, into: &mut HashSet) { + into.insert(root.name.clone()); + for el in &root.children { + if let Element::Clafer(child) = el { + self.collect_subtree_names(child, into); + } + } + } + + /// For each active clafer without a `gcard`, forces its mandatory-`card` + /// children active. Clafers with a `gcard` are handled by group-cardinality rules. + fn propagate_mandatory_children(&self, subtree_names: &HashSet, assignment: &mut Assignment) -> bool { + let mut changed = false; + for name in subtree_names { + if assignment.get(name).copied().flatten() != Some(true) { + continue; + } + let Some(clafer) = self.registry.get(name) else { continue }; + if clafer.gcard.is_some() { + continue; + } + for el in &clafer.children { + if let Element::Clafer(child) = el + && is_mandatory_card(&child.card) + { + changed |= try_assign(assignment, &child.name, true); + } + } + } + changed + } + + /// For each active clafer with a `gcard`, enforces its min/max bound: at max, + /// remaining unknown children go false; when all are needed for min, they go + /// true. Covers `xor`/`mux`/`or`/`opt`/explicit ncard. + fn propagate_group_cardinality(&self, subtree_names: &HashSet, assignment: &mut Assignment) -> bool { + let mut changed = false; + for name in subtree_names { + if assignment.get(name).copied().flatten() != Some(true) { + continue; + } + let Some(clafer) = self.registry.get(name) else { continue }; + let Some(gcard) = &clafer.gcard else { continue }; + let (min, max) = gcard_bounds(gcard); + + let direct_children: Vec<&str> = clafer + .children + .iter() + .filter_map(|e| if let Element::Clafer(c) = e { Some(c.name.as_str()) } else { None }) + .collect(); + + let true_count = direct_children + .iter() + .filter(|c| assignment.get(**c).copied().flatten() == Some(true)) + .count(); + let unknown: Vec<&str> = direct_children + .iter() + .filter(|c| assignment.get(**c).copied().flatten().is_none()) + .copied() + .collect(); + + if let Some(max) = max + && true_count >= max + { + for child in &unknown { + changed |= try_assign(assignment, child, false); + } + continue; + } + if min > 0 && true_count + unknown.len() == min { + for child in &unknown { + changed |= try_assign(assignment, child, true); + } + } + } + changed + } +} + +/// Gathers every `Element::Constraint` in `elements`, including nested clafers. +fn collect_constraints(elements: &[Element], out: &mut Vec) { + for el in elements { + match el { + Element::Constraint(expr) => out.push(expr.clone()), + Element::Clafer(c) => collect_constraints(&c.children, out), + } + } +} + +/// Sets `name` to `value` only if currently unknown; returns whether it changed. +/// Conflicts are dropped with a warning, keeping assignments monotonic +/// (`None -> Some` only) so the resolution loop terminates and explicit selections win. +fn try_assign(assignment: &mut Assignment, name: &str, value: bool) -> bool { + match assignment.get(name).copied() { + Some(Some(existing)) if existing == value => false, + Some(Some(existing)) => { + eprintln!( + "warning: conflicting assignment for '{}': already {}, ignoring {}", + name, existing, value + ); + false + } + Some(None) => { + assignment.insert(name.to_string(), Some(value)); + true + } + None => { + eprintln!("warning: reference to unknown feature '{}'", name); + false + } + } +} + +/// Three-valued evaluation of an already-known (partial) assignment. +fn eval_expr(expr: &Expr, assignment: &Assignment) -> Option { + match expr { + Expr::Ref(name) => assignment.get(name).copied().flatten(), + Expr::Not(a) => eval_expr(a, assignment).map(|v| !v), + Expr::And(a, b) => match (eval_expr(a, assignment), eval_expr(b, assignment)) { + (Some(false), _) | (_, Some(false)) => Some(false), + (Some(true), Some(true)) => Some(true), + _ => None, + }, + Expr::Or(a, b) => match (eval_expr(a, assignment), eval_expr(b, assignment)) { + (Some(true), _) | (_, Some(true)) => Some(true), + (Some(false), Some(false)) => Some(false), + _ => None, + }, + Expr::Implies(l, r) => { + let lv = eval_expr(l, assignment); + let rv = eval_expr(r, assignment); + if lv == Some(false) || rv == Some(true) { + Some(true) + } else if lv == Some(true) && rv == Some(false) { + Some(false) + } else { + None + } + } + Expr::Xor(a, b) => match (eval_expr(a, assignment), eval_expr(b, assignment)) { + (Some(av), Some(bv)) => Some(av != bv), + _ => None, + }, + Expr::Iff(a, b) => match (eval_expr(a, assignment), eval_expr(b, assignment)) { + (Some(av), Some(bv)) => Some(av == bv), + _ => None, + }, + Expr::Other(_) => None, + } +} + +/// Pushes a required truth value down through `expr`, asserting whatever can be +/// inferred about its subexpressions. Returns whether any assignment changed. +fn propagate_expr(expr: &Expr, required: bool, assignment: &mut Assignment) -> bool { + match expr { + Expr::Ref(name) => try_assign(assignment, name, required), + Expr::Not(a) => propagate_expr(a, !required, assignment), + Expr::And(a, b) => { + if required { + propagate_expr(a, true, assignment) | propagate_expr(b, true, assignment) + } else { + let mut changed = false; + if eval_expr(a, assignment) == Some(true) { + changed |= propagate_expr(b, false, assignment); + } + if eval_expr(b, assignment) == Some(true) { + changed |= propagate_expr(a, false, assignment); + } + changed + } + } + Expr::Or(a, b) => { + if required { + let mut changed = false; + if eval_expr(a, assignment) == Some(false) { + changed |= propagate_expr(b, true, assignment); + } + if eval_expr(b, assignment) == Some(false) { + changed |= propagate_expr(a, true, assignment); + } + changed + } else { + propagate_expr(a, false, assignment) | propagate_expr(b, false, assignment) + } + } + Expr::Implies(l, r) => { + if required { + let mut changed = false; + if eval_expr(l, assignment) == Some(true) { + changed |= propagate_expr(r, true, assignment); + } + if eval_expr(r, assignment) == Some(false) { + changed |= propagate_expr(l, false, assignment); + } + changed + } else { + propagate_expr(l, true, assignment) | propagate_expr(r, false, assignment) + } + } + Expr::Xor(a, b) => { + let mut changed = false; + if let Some(av) = eval_expr(a, assignment) { + changed |= propagate_expr(b, av != required, assignment); + } + if let Some(bv) = eval_expr(b, assignment) { + changed |= propagate_expr(a, bv != required, assignment); + } + changed + } + Expr::Iff(a, b) => { + let mut changed = false; + if let Some(av) = eval_expr(a, assignment) { + changed |= propagate_expr(b, av == required, assignment); + } + if let Some(bv) = eval_expr(b, assignment) { + changed |= propagate_expr(a, bv == required, assignment); + } + changed + } + Expr::Other(_) => false, + } +} + +/// Whether a `card` is mandatory (min >= 1): absent, `"+"`, or a range with min >= 1. +/// `"?"`/`"*"`/min-0 ranges are optional. +fn is_mandatory_card(card: &Option) -> bool { + match card.as_deref() { + None => true, + Some("+") => true, + Some("?") | Some("*") => false, + Some(s) => ncard_min(s).map(|min| min >= 1).unwrap_or(true), + } +} + +/// (min, max) bounds for a group cardinality. `max = None` means unbounded. +fn gcard_bounds(gcard: &str) -> (usize, Option) { + match gcard { + "xor" => (1, Some(1)), + "mux" => (0, Some(1)), + "or" => (1, None), + "opt" => (0, None), + s => { + let min = ncard_min(s).unwrap_or(0); + let max = ncard_max(s); + (min, max) + } + } +} + +/// Parses the min of a cardinality string like `"1..3"`, `"1..*"`, or `"2"`. +fn ncard_min(s: &str) -> Option { + let min_part = s.split("..").next().unwrap_or(s); + min_part.trim().parse::().ok() +} + +/// Parses the max of a cardinality string: `"1..3"` -> `Some(3)`, `"1..*"` -> `None`, +/// bare `"2"` -> `Some(2)`. +fn ncard_max(s: &str) -> Option { + match s.split_once("..") { + Some((_, "*")) => None, + Some((_, max)) => max.trim().parse::().ok(), + None => s.trim().parse::().ok(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser::grammar::parse_clafer_module; + + const MODEL: &str = r#" +abstract POSSystem { + xor Storage { + SQLite + Postgres + } + Scanner ? + DigitalPayment ? + PhysicalCash ? + ReceiptPrinter ? + + // Dependencies + [ PhysicalCash => ReceiptPrinter ] + [ DigitalPayment || PhysicalCash ] +} + +// Instance 1: A lightweight, digital-only tablet +MinimalistKiosk : POSSystem { + [ SQLite ] + [ DigitalPayment ] + [ no PhysicalCash ] + [ no Scanner ] + [ no ReceiptPrinter ] +} + +// Instance 2: A robust, hardware-heavy checkout +FullServiceStation : POSSystem { + [ Postgres ] + [ DigitalPayment ] + [ PhysicalCash ] + [ Scanner ] +} +"#; + + fn set(names: &[&str]) -> HashSet { + names.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn resolves_full_service_station() { + let decls = parse_clafer_module(MODEL); + let eval = Evaluator::new(decls); + let config = eval.resolve_instance("FullServiceStation").unwrap(); + assert_eq!( + config.included, + set(&["Postgres", "DigitalPayment", "PhysicalCash", "Scanner", "ReceiptPrinter"]) + ); + assert_eq!(config.excluded, set(&["SQLite"])); + } + + #[test] + fn resolves_minimalist_kiosk() { + let decls = parse_clafer_module(MODEL); + let eval = Evaluator::new(decls); + let config = eval.resolve_instance("MinimalistKiosk").unwrap(); + assert_eq!(config.included, set(&["SQLite", "DigitalPayment"])); + assert_eq!( + config.excluded, + set(&["Postgres", "PhysicalCash", "Scanner", "ReceiptPrinter"]) + ); + } + + #[test] + fn unknown_instance_errors() { + let decls = parse_clafer_module(MODEL); + let eval = Evaluator::new(decls); + assert!(eval.resolve_instance("NoSuchInstance").is_err()); + } +} diff --git a/src/parser/grammar.rs b/src/parser/grammar.rs new file mode 100644 index 0000000..f5d381b --- /dev/null +++ b/src/parser/grammar.rs @@ -0,0 +1,187 @@ +use pest::Parser; +use pest::iterators::Pair; +use pest_derive::Parser; + +use crate::parser::ast::{Clafer, Declaration, Element, Expr}; + +#[derive(Parser)] +#[grammar = "parser/clafer.pest"] +pub struct ClaferParser; + +pub fn parse_clafer_module(input: &str) -> Vec { + let pairs = ClaferParser::parse(Rule::module, input) + .expect("Parsing copied 128 chars to clipboard failed") + .next() + .unwrap(); + + let mut declarations = Vec::new(); + + for pair in pairs.into_inner() { + match pair.as_rule() { + Rule::declaration => { + let inner = pair.into_inner().next().unwrap(); + match inner.as_rule() { + Rule::enum_decl => { + // Idents are flat in `.into_inner()`; the first is the enum's name. + let idents: Vec = + inner.into_inner().map(|p| p.as_str().to_string()).collect(); + if let Some((name, members)) = idents.split_first() { + declarations.push(Declaration::EnumDecl(name.clone(), members.to_vec())); + } + } + Rule::element => { + declarations.push(Declaration::Element(parse_element(inner))); + } + _ => {} + } + } + Rule::EOI => (), // End of Input + _ => unreachable!(), + } + } + declarations +} + +fn parse_element(pair: Pair) -> Element { + let inner = pair.into_inner().next().unwrap(); + match inner.as_rule() { + Rule::clafer => Element::Clafer(parse_clafer(inner)), + Rule::constraint => { + // constraint = { "[" ~ expr ~ "]" } + let expr_pair = inner.into_inner().next().unwrap(); + Element::Constraint(parse_expr(expr_pair)) + } + _ => unreachable!(), + } +} + +/// Parses the boolean-expression grammar (`expr` through `primary`) into an `Expr` +/// tree. Anything outside the boolean subset is preserved as raw `Expr::Other` text, +/// staying inert (always "unknown") during evaluation instead of panicking. +fn parse_expr(pair: Pair) -> Expr { + match pair.as_rule() { + Rule::expr => parse_expr(pair.into_inner().next().unwrap()), + Rule::iff_expr => fold_connective(pair, Expr::Iff), + Rule::implies_expr => fold_connective(pair, Expr::Implies), + Rule::or_expr => fold_connective(pair, Expr::Or), + Rule::xor_expr => fold_connective(pair, Expr::Xor), + Rule::and_expr => fold_connective(pair, Expr::And), + Rule::cmp_expr => parse_operator_chain(pair, Rule::cmp_op), + Rule::add_expr => parse_operator_chain(pair, Rule::add_op), + Rule::join_expr => { + let text = pair.as_str().trim().to_string(); + let operands: Vec> = pair.into_inner().collect(); + match <[Pair; 1]>::try_from(operands) { + Ok([only]) => parse_expr(only), + Err(_) => Expr::Other(text), // dotted join, out of scope + } + } + Rule::primary => parse_primary(pair), + Rule::ident => Expr::Ref(pair.as_str().to_string()), + _ => Expr::Other(pair.as_str().trim().to_string()), + } +} + +/// Left-folds a level like `and_expr = { cmp_expr ~ ("&&" ~ cmp_expr)* }` into a +/// chain of binary nodes. The operator is a bare literal (not in `.into_inner()`), +/// so the level's single connective applies to every operand join. +fn fold_connective(pair: Pair, ctor: fn(Box, Box) -> Expr) -> Expr { + let mut operands = pair.into_inner().map(parse_expr); + let first = operands + .next() + .expect("expression level must have at least one operand"); + operands.fold(first, |acc, next| ctor(Box::new(acc), Box::new(next))) +} + +/// Handles levels with a named operator rule (`cmp_op`, `add_op`). These are outside +/// the boolean subset: a lone operand passes through, a real chain becomes `Expr::Other`. +fn parse_operator_chain(pair: Pair, op_rule: Rule) -> Expr { + let text = pair.as_str().trim().to_string(); + let operands: Vec> = pair.into_inner().filter(|p| p.as_rule() != op_rule).collect(); + match <[Pair; 1]>::try_from(operands) { + Ok([only]) => parse_expr(only), + Err(_) => Expr::Other(text), + } +} + +/// `primary = { unary_op* ~ quantifier? ~ (ident | int | str | "(" ~ expr ~ ")") }` +fn parse_primary(pair: Pair) -> Expr { + let full_text = pair.as_str().trim().to_string(); + let parts: Vec> = pair.into_inner().collect(); + let Some((terminal, prefixes)) = parts.split_last() else { + return Expr::Other(full_text); + }; + + // Arithmetic negation ("-") is outside the boolean subset; bail out whole. + if prefixes + .iter() + .any(|p| p.as_rule() == Rule::unary_op && p.as_str() == "-") + { + return Expr::Other(full_text); + } + + let mut expr = parse_expr(terminal.clone()); + + // Apply prefixes innermost-first: the one nearest the terminal binds first. + for prefix in prefixes.iter().rev() { + match prefix.as_rule() { + Rule::unary_op if prefix.as_str() == "!" => { + expr = Expr::Not(Box::new(expr)); + } + Rule::quantifier if matches!(prefix.as_str(), "no" | "not") => { + expr = Expr::Not(Box::new(expr)); + } + // "lone"/"one"/"some"/"all": pass through; multiplicity quantifiers + // have no precise meaning in this single-instance boolean subset. + _ => {} + } + } + + expr +} + +fn parse_clafer(pair: Pair) -> Clafer { + let mut is_abstract = false; + let mut gcard = None; + let mut name = String::new(); + let mut super_type = None; + let mut card = None; + let mut children = Vec::new(); + + // Walk through the components of the clafer + for inner in pair.into_inner() { + match inner.as_rule() { + Rule::abstract_mod => is_abstract = true, + Rule::gcard => gcard = Some(inner.as_str().to_string()), + Rule::ident => name = inner.as_str().to_string(), + Rule::super_mod => { + // super_mod is ":" ~ expr. Parse via parse_expr rather than raw + // span text, which can leak trailing whitespace from pest. + let expr_pair = inner.into_inner().next().unwrap(); + if let Expr::Ref(name) = parse_expr(expr_pair) { + super_type = Some(name); + } + } + Rule::card => card = Some(inner.as_str().to_string()), + Rule::elements => { + // Recursively parse children inside { } + for el in inner.into_inner() { + if el.as_rule() == Rule::element { + children.push(parse_element(el)); + } + } + } + // ref_mod/init_mod are UML/attribute concerns, not feature modeling. + _ => {} + } + } + + Clafer { + is_abstract, + gcard, + name, + super_type, + card, + children, + } +}