diff --git a/src/Rust/Cargo.lock b/src/Rust/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..438b9cce5e2c3acb9b7aecd42b68361435637657 --- /dev/null +++ b/src/Rust/Cargo.lock @@ -0,0 +1,611 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "ab_glyph" +version = "0.2.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1061f3ff92c2f65800df1f12fc7b4ff44ee14783104187dd04dfee6f11b0fd2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c71b1793ee61086797f5c80b6efa2b8ffa6d5dd703f118545808a7f2e27f7046" + +[[package]] +name = "ahash" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91429305e9f0a25f6205c5b8e0d2db09e0708a7a6df0f42212bb56c32c8ac97a" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5" + +[[package]] +name = "anstyle" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7079075b41f533b8c61d2a4d073c4676e1f8b249ff94a393b0595db304e0dd87" + +[[package]] +name = "anyhow" +version = "1.0.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6" + +[[package]] +name = "bitflags" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327762f6e5a765692301e5bb513e0d9fef63be86bbc14528052b1cd3e6f03e07" + +[[package]] +name = "bytecount" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e5f035d16fc623ae5f74981db80a439803888314e3a555fd6f04acd51a3205" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "clap" +version = "4.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac495e00dcec98c83465d5ad66c5c4fabd652fd6686e7c6269b117e729a6f17b" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77ed9a32a62e6ca27175d00d29d05ca32e396ea1eb5fb01d8256b669cec7663" +dependencies = [ + "anstyle", + "clap_lex", + "strsim", + "terminal_size", +] + +[[package]] +name = "clap_complete" +version = "4.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bffe91f06a11b4b9420f62103854e90867812cd5d01557f853c5ee8e791b12ae" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "4.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf9804afaaf59a91e75b022a30fb7229a7901f60c755489cc61c9b423b836442" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702fc72eb24e5a1e48ce58027a675bc24edd52096d5397d4aea7c6dd9eca0bd1" + +[[package]] +name = "clap_mangen" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3be86020147691e1d2ef58f75346a3d4d94807bfc473e377d52f09f0f7d77f7" +dependencies = [ + "clap", + "roff", +] + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "errno" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3e13f66a2f95e32a39eaa81f6b95d42878ca0e1db0c7543723dfe12557e860" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "hashbrown" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93e7192158dbcda357bdec5fb5788eebf8bbac027f3f33e719d29135ae84156" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "indexmap" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8adf3ddd720272c6ea8bf59463c04e0f93d0bbf7c5439b691bca2987e0270897" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "libc" +version = "0.2.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a08173bc88b7955d1b3145aa561539096c421ac8debde8cbc3612ec635fee29b" + +[[package]] +name = "linux-raw-sys" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da2479e8c062e40bf0066ffa0bc823de0a9368974af99c9f6df941d2c231e03f" + +[[package]] +name = "log" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" + +[[package]] +name = "memchr" +version = "2.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f665ee40bc4a3c5590afb1e9677db74a508659dfd71e126420da8274909a0167" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom_locate" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e3c83c053b0713da60c5b8de47fe8e494fe3ece5267b2f23090a07a053ba8f3" +dependencies = [ + "bytecount", + "memchr", + "nom", +] + +[[package]] +name = "once_cell" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" + +[[package]] +name = "owned_ttf_parser" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "706de7e2214113d63a8238d1910463cfce781129a6f263d13fdb09ff64355ba4" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "proc-macro2" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "134c189feb4956b20f6f547d2cf727d4c0fe06722b20a0eec87ed445a97f92da" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "380b951a9c5e80ddfd6136919eef32310721aa4aacd4889a8d39124b026ab343" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" + +[[package]] +name = "roff" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b833d8d034ea094b1ea68aa6d5c740e0d04bad9d16568d08ba6f76823a114316" + +[[package]] +name = "rustix" +version = "0.38.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b426b0506e5d50a7d8dafcf2e81471400deb602392c7dd110815afb4eaf02a3" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "serde" +version = "1.0.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91d3c334ca1ee894a2c6f6ad698fe8c435b76d504b13d436f0685d648d6d96f7" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c5609f394e5c2bd7fc51efda478004ea80ef42fee983d5c67a65e34f32c0e3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12022b835073e5b11e90a14f86838ceb1c8fb0325b72416845c487ac0fa95e80" +dependencies = [ + "serde", +] + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "syn" +version = "2.0.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e96b79aaa137db8f61e26363a0c9b47d8b4ec75da28b7d1d614c2303e232408b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "terminal_size" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21bebf2b7c9e0a515f6e0f8c51dc0f8e4696391e6f1ff30379559f8365fb0df7" +dependencies = [ + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "1.0.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9a7210f5c9a7156bb50aa36aed4c95afb51df0df00713949448cf9e97d382d2" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "266b2e40bc00e5a6c09c3584011e08b06f123c00362c92b975ba9843aaaa14b8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3efaf127c78d5339cc547cce4e4d973bd5e4f56e949a06d091c082ebeef2f800" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3550f4e9685620ac18a50ed434eb3aec30db8ba93b0287467bca5826ea25baf1" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.20.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "782bf6c2ddf761c1e7855405e8975472acf76f7f36d0d4328bd3b7a2fae12a85" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + +[[package]] +name = "ttf-parser" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49d64318d8311fc2668e48b63969f4343e0a85c4a109aa8460d6672e364b8bd1" + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "unicode-segmentation" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "vvs_ass" +version = "0.5.0" +dependencies = [ + "anyhow", + "log", + "serde", + "thiserror", + "unicode-segmentation", + "vvs_font", + "vvs_procmacro", + "vvs_utils", +] + +[[package]] +name = "vvs_cli" +version = "0.5.0" +dependencies = [ + "anyhow", + "clap", + "clap_complete", + "clap_mangen", + "log", + "serde", + "thiserror", + "toml", + "vvs_font", + "vvs_utils", +] + +[[package]] +name = "vvs_font" +version = "0.5.0" +dependencies = [ + "ab_glyph", + "log", + "thiserror", + "ttf-parser", +] + +[[package]] +name = "vvs_lang" +version = "0.5.0" +dependencies = [ + "anyhow", + "hashbrown", + "log", + "nom", + "nom_locate", + "regex", + "serde", + "thiserror", + "vvs_utils", +] + +[[package]] +name = "vvs_procmacro" +version = "0.5.0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "vvs_utils" +version = "0.5.0" +dependencies = [ + "log", + "serde", + "thiserror", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "winnow" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3b801d0e0a6726477cc207f60162da452f3a95adb368399bef20a946e06f65c" +dependencies = [ + "memchr", +] + +[[package]] +name = "zerocopy" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81ba595b9f2772fbee2312de30eeb80ec773b4cb2f1e8098db024afadda6c06f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772666c41fb6dceaf520b564b962d738a8e1a83b41bd48945f50837aed78bb1d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/src/Rust/Cargo.toml b/src/Rust/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..aeba7e5ad0f4da0a66977cef64410c516894ec9d --- /dev/null +++ b/src/Rust/Cargo.toml @@ -0,0 +1,54 @@ +[workspace] +resolver = "2" +members = [ + "vvs_cli", + "vvs_ass", + "vvs_font", + "vvs_lang", + "vvs_utils", + "vvs_procmacro", +] + +[workspace.package] +version = "0.5.0" +authors = ["Maël MARTIN"] +description = "The V5 of the Vivy Script utility to manipulate in an easy way ASS files" +edition = "2021" +license = "MIT" + +[workspace.dependencies] +# Utils +thiserror = "1" +anyhow = "1" +paste = "1" +log = "0.4" +bitflags = { version = "2", default-features = false } +unicode-segmentation = "1" +hashbrown = "0.14" + +# Parsing +regex = { version = "1", default-features = false, features = ["std"] } +nom = { version = "7", default-features = false, features = ["std"] } +nom_locate = { version = "4", default-features = false, features = ["std"] } + +# SerDe +toml = { version = "0.8", default-features = false, features = [ + "parse", + "display", +] } +serde = { version = "1", default-features = false, features = [ + "std", + "derive", +] } + +[profile.release] +strip = true +debug = false +lto = true +opt-level = "z" +codegen-units = 1 +panic = "abort" + +[profile.dev] +debug = true +opt-level = "s" diff --git a/src/Rust/LICENSE.txt b/src/Rust/LICENSE.txt new file mode 100644 index 0000000000000000000000000000000000000000..4863437edd67f0c747e3ce7dc0cd721996447618 --- /dev/null +++ b/src/Rust/LICENSE.txt @@ -0,0 +1,19 @@ +Copyright 2023 Maël MARTIN + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/Rust/README.md b/src/Rust/README.md new file mode 100644 index 0000000000000000000000000000000000000000..3337f0bd3339cfea7d9f35318bf790457238cdd6 --- /dev/null +++ b/src/Rust/README.md @@ -0,0 +1,57 @@ +# Vivy Script +The V4 of the Vivy Script utility to manipulate in an easy way ASS files. + +# How to build and install +Like any rust project, simply run: + + cargo build --release + cargo install --path vvs_cli + vvcc --help + +If you want to test it, from the root of the project you may replace any call +to `vvcc` by `cargo run --bin vvcc --`. + +# Misc +## Manpage +To get the `vvcc` manpage, just run: + + vvcc --manpage | man -l - + +To install it, you may run the following commands: + + mkdir -p $HOME/.local/share/man/man1/ + vvcc --manpage > $HOME/.local/share/man/man1/vvcc.1 + man vvcc + +## Shell completion +To get the completion scripts to source you can use the following commands. You +can then source those files to get the completion working with your shell. + + mkdir -p $HOME/.local/share/completion/ + vvcc --shell bash > $HOME/.local/share/bash-completion/completions/vvcc + vvcc --shell zsh > $HOME/.local/share/zsh/site-functions/_vvcc + +To get the completion working with the system, you can use the following commands: + + vvcc --shell bash > /usr/local/share/bash-completion/completions/vvcc + vvcc --shell zsh > /usr/local/share/zsh/site-functions/_vvcc + +To visualize the dependency graph of VivyScript, use the command: + + cargo install cargo-depgraph + cargo depgraph --build-deps --dedup-transitive-deps | dot -Tpdf | zathura - + +## To test the project +The unit-tests can be ran on the project by running `cargo test`. The +integration tests and the unit-tests can be run together by using pcvs in the +[tests](tests) folder: + + pip install pcvs -- Install PCVS + (cd tests && pcvs run) -- Run in the correct folder + +You can also install PCVS by cloning the project and running `pip install .` +from its root. + +# Licence +- The VVS project is under the MIT licence +- The NotoSans fonts are distributed using the [OFL licence](vvs_font/fonts/NotoSans-LICENCE-OFL.txt) diff --git a/src/Rust/rustfmt.toml b/src/Rust/rustfmt.toml new file mode 100644 index 0000000000000000000000000000000000000000..abc68cf273d033f6350083ce7f0a05eedabf2222 --- /dev/null +++ b/src/Rust/rustfmt.toml @@ -0,0 +1,22 @@ +edition = "2021" +max_width = 120 +newline_style = "Unix" + +# Let's use the horizontal space of the screen +fn_call_width = 70 +single_line_if_else_max_width = 70 +struct_lit_width = 70 +struct_variant_width = 70 + +# Modern rust +use_try_shorthand = true +use_field_init_shorthand = true +match_block_trailing_comma = false + +# # Wait for stabilization +# format_macro_matchers = true +# group_imports = "StdExternalCrate" +# imports_granularity = "Crate" +# overflow_delimited_expr = true +# reorder_impl_items = true +# wrap_comments = true diff --git a/src/Rust/vvs_ass/Cargo.toml b/src/Rust/vvs_ass/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..2a9a1f6421ec74443e6fa4519b7109258ba28118 --- /dev/null +++ b/src/Rust/vvs_ass/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "vvs_ass" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +description = "ASS specification and VVS specificities for VVCC" + +[dependencies] +vvs_procmacro = { path = "../vvs_procmacro" } +vvs_utils = { path = "../vvs_utils" } +vvs_font = { path = "../vvs_font" } + +unicode-segmentation.workspace = true +thiserror.workspace = true +anyhow.workspace = true +serde.workspace = true +log.workspace = true diff --git a/src/Rust/vvs_ass/src/colors.rs b/src/Rust/vvs_ass/src/colors.rs new file mode 100644 index 0000000000000000000000000000000000000000..380a9a1ac0f36cf70dd744f8790460dc7f5af815 --- /dev/null +++ b/src/Rust/vvs_ass/src/colors.rs @@ -0,0 +1,161 @@ +use vvs_utils::*; + +/// The color representation +#[derive(Debug, Clone, PartialEq)] +pub enum ASSColor { + /// The Red Green Blue Alpha representation. + RGBA { r: u8, g: u8, b: u8, a: u8 }, + + /// The Hue Saturation Lightness with Alpha representation. The Hue must be between is in + /// radian, the lightness and saturation values must be between 0 and 1. + HSLA { h: f32, s: f32, l: f32, a: u8 }, +} + +macro_rules! rgb { + ($NAME: ident => $r: literal $g: literal $b: literal) => { + pub const $NAME: ASSColor = ASSColor::RGBA { r: $r, g: $g, b: $b, a: 0 }; + }; +} + +impl ASSColor { + rgb! { WHITE => 255 255 255 } + rgb! { SILVER => 192 192 192 } + rgb! { GRAY => 128 128 128 } + rgb! { BLACK => 0 0 0 } + + rgb! { RED => 255 0 0 } + rgb! { MAROON => 128 0 0 } + rgb! { YELLOW => 255 255 0 } + rgb! { OLIVE => 128 128 0 } + rgb! { LIME => 0 255 0 } + rgb! { GREEN => 0 128 0 } + rgb! { AQUA => 0 255 255 } + rgb! { TEAL => 0 128 128 } + rgb! { BLUE => 0 0 255 } + rgb! { NAVY => 0 0 128 } + rgb! { FUCHSIA => 255 0 255 } + rgb! { PURPLE => 128 0 128 } + + fn skip_delimiters(str: &str) -> &str { + str.trim() + .trim_start_matches('#') + .trim_start_matches("&H") + .trim_start_matches('&') + .trim_end_matches('&') + } + + pub fn try_from_rgba(str: impl AsRef<str>) -> Result<Self, String> { + let color = Self::skip_delimiters(str.as_ref()); + if !(color.len() == 6 || color.len() == 8) { + return Err(format!("invalid color string description: {}", str.as_ref())); + } + Ok(Self::RGBA { + r: u8::from_str_radix(&color[0..2], 16).map_err(|err| format!("invalid red description: {err}"))?, + g: u8::from_str_radix(&color[2..4], 16).map_err(|err| format!("invalid green description: {err}"))?, + b: u8::from_str_radix(&color[4..6], 16).map_err(|err| format!("invalid blue description: {err}"))?, + a: (color.len() == 8) + .then(|| { + u8::from_str_radix(&color[6..8], 16).map_err(|err| format!("invalid alpha description: {err}")) + }) + .unwrap_or(Ok(0))?, + }) + } + + pub fn try_from_bgra(str: impl AsRef<str>) -> Result<Self, String> { + let color = Self::skip_delimiters(str.as_ref()); + if !(color.len() == 6 || color.len() == 8) { + return Err(format!("invalid color string description: {}", str.as_ref())); + } + Ok(Self::RGBA { + r: u8::from_str_radix(&color[4..6], 16).map_err(|err| format!("invalid red description: {err}"))?, + g: u8::from_str_radix(&color[2..4], 16).map_err(|err| format!("invalid green description: {err}"))?, + b: u8::from_str_radix(&color[0..2], 16).map_err(|err| format!("invalid blue description: {err}"))?, + a: (color.len() == 8) + .then(|| { + u8::from_str_radix(&color[6..8], 16).map_err(|err| format!("invalid alpha description: {err}")) + }) + .unwrap_or(Ok(0))?, + }) + } + + pub fn into_rgba(self) -> Self { + match self { + this @ ASSColor::RGBA { .. } => this, + ASSColor::HSLA { h, s, l, a } => { + let h = f32_degres_clamp(h); + let c = (1.0 - (2.0 * l - 1.0).abs()) * s; + let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs()); + let m = l - c / 2.0; + + let (r, g, b) = if (0.0..60.0).contains(&h) { + (c, x, 0.0) + } else if (60.0..120.0).contains(&h) { + (x, c, 0.0) + } else if (120.0..180.0).contains(&h) { + (0.0, c, x) + } else if (180.0..240.0).contains(&h) { + (0.0, x, c) + } else if (240.0..300.0).contains(&h) { + (x, 0.0, c) + } else if (300.0..360.0).contains(&h) { + (c, 0.0, x) + } else { + unreachable!() + }; + + ASSColor::RGBA { + r: ((r + m) * 255.0).trunc() as u8, + g: ((g + m) * 255.0).trunc() as u8, + b: ((b + m) * 255.0).trunc() as u8, + a, + } + } + } + } + + pub fn into_hsla(self) -> Self { + match self { + this @ ASSColor::HSLA { .. } => this, + ASSColor::RGBA { r, g, b, a } => { + const U8_MAX: f32 = u8::MAX as f32; + let h = self.hue(); + let (r, g, b) = (r as f32 / U8_MAX, g as f32 / U8_MAX, b as f32 / U8_MAX); + let (min, max) = minmax_partial!(r, g, b); + let l = (min + max) / 2.0; + let delta = max - min; + let s = either!(f32_epsilon_eq(delta, 0.0) => 0.0; + delta / (1.0 - (2.0 * l - 1.0).abs()) + ); + ASSColor::HSLA { h, s, l, a } + } + } + } + + /// Returns the HUE from the color (the H in HSL/HSV). + fn hue(&self) -> f32 { + match self { + ASSColor::HSLA { h, .. } => *h, + ASSColor::RGBA { r, g, b, .. } => { + let (r, g, b) = (*r, *g, *b); + let (min, max) = minmax_partial!(r, g, b); + let c = max - min; + ((if c == 0 { + 0.0 + } else if max == r { + let segment = (g as f32 - b as f32) / c as f32; + either!(segment < 0.0 => segment + 6.0; segment) + } else if max == g { + let segment = (b as f32 - r as f32) / c as f32; + segment + 2.0 + } else if max == b { + let segment = (r as f32 - g as f32) / c as f32; + segment + 4.0 + } else { + panic!() + } * 60.0) + % 360.0) + .trunc() + } + } + } +} diff --git a/src/Rust/vvs_ass/src/definitions.rs b/src/Rust/vvs_ass/src/definitions.rs new file mode 100644 index 0000000000000000000000000000000000000000..dcd1b2012835759e6ad06fa51d7ab8deef428b8d --- /dev/null +++ b/src/Rust/vvs_ass/src/definitions.rs @@ -0,0 +1,212 @@ +use std::str::FromStr; + +/// The section in the ASS file. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ASSFileSection { + ScriptInfo, + V4Styles, + Events, +} + +/// The events of the [ASSFileSection::Events] section. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ASSEvent { + /// - Marked=0 means the line is not shown as "marked" in SSA. + /// - Marked=1 means the line is shown as "marked" in SSA. + pub marked: bool, + + /// Subtitles having different layer number will be ignored during the collusion detection. + /// Higher numbered layers will be drawn over the lower numbered. + pub layer: i64, + + /// Start Time of the Event, in 0:00:00:00 format ie. Hrs:Mins:Secs:hundredths. This is the + /// time elapsed during script playback at which the text will appear onscreen. Note that there + /// is a single digit for the hours! + pub start: i64, + + /// End Time of the Event, in 0:00:00:00 format ie. Hrs:Mins:Secs:hundredths. This is the time + /// elapsed during script playback at which the text will disappear offscreen. Note that there + /// is a single digit for the hours! + pub end: i64, + + /// Style name. If it is "Default", then your own *Default style will be subtituted. However, + /// the Default style used by the script author IS stored in the script even though SSA ignores + /// it - so if you want to use it, the information is there - you could even change the Name in + /// the Style definition line, so that it will appear in the list of "script" styles. + pub style: String, + + /// Character name. This is the name of the character who speaks the dialogue. It is for + /// information only, to make the script is easier to follow when editing/timing. + pub name: String, + + /// Transition Effect. This is either empty, or contains information for one of the three + /// transition effects implemented in SSA v4.x The effect names are case sensitive and must + /// appear exactly as shown. The effect names do not have quote marks around them. + /// - "Karaoke" means that the text will be successively highlighted one word at a time. + /// Karaoke as an effect type is obsolete. + /// - "Scroll up;y1;y2;delay[;fadeawayheight]"means that the text/picture will scroll up the + /// screen. The parameters after the words "Scroll up" are separated by semicolons. The y1 + /// and y2 values define a vertical region on the screen in which the text will scroll. The + /// values are in pixels, and it doesn't matter which value (top or bottom) comes first. If + /// the values are zeroes then the text will scroll up the full height of the screen. The + /// delay value can be a number from 1 to 100, and it slows down the speed of the scrolling - + /// zero means no delay and the scrolling will be as fast as possible. + /// - "Banner;delay" means that text will be forced into a single line, regardless of length, + /// and scrolled from right to left accross the screen. The delay value can be a number from + /// 1 to 100, and it slows down the speed of the scrolling - zero means no delay and the + /// scrolling will be as fast as possible. + /// - "Scroll down;y1;y2;delay[;fadeawayheight]" + /// - "Banner;delay[;lefttoright;fadeawaywidth]" lefttoright 0 or 1. This field is optional. + /// Default value is 0 to make it backwards compatible. When delay is greater than 0, moving + /// one pixel will take (1000/delay) second. (WARNING: Avery Lee’s "subtitler" plugin reads + /// the "Scroll up" effect parameters as delay;y1;y2) fadeawayheight and fadeawaywidth + /// parameters can be used to make the scrolling text at the sides transparent. + pub effect: String, + + /// Subtitle Text. This is the actual text which will be displayed as a subtitle onscreen. + /// Everything after the 9th comma is treated as the subtitle text, so it can include commas. + /// The text can include \n codes which is a line break, and can include Style Override control + /// codes, which appear between braces { }. + pub text: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ScriptInfoKey { + /// This is a description of the script. If the original author(s) did not provide this + /// information then <untitled> is automatically substituted + Title, + + /// The original author(s) of the script. If the original author(s) did not provide this + /// information then <unknown> is automatically substituted + OriginalScript, + + /// The original translator of the dialogue. This entry does not appear if no information was + /// entered by the author. (optional) + OriginalTranslation, + + /// The original script editor(s), typically whoever took the raw translation and turned it + /// into idiomatic english and reworded for readability. This entry does not appear if no + /// information was entered by the author. (optional) + OriginalEditing, + + /// Whoever timed the original script. This entry does not appear if no information was entered + /// by the author. (optional) + OriginalTiming, + + /// Description of where in the video the script should begin playback. This entry does not + /// appear if no information was entered by the author. (optional) + SynchPoint, + + /// Names of any other subtitling groups who edited the original script. This entry does not + /// appear if subsequent editors did not enter the information. (optional) + ScriptUpdatedBy, + + /// The details of any updates to the original script - made by other subtitling groups. This + /// entry does not appear if subsequent editors did not enter any information + UpdateDetails, + + /// This is the SSA script format version eg. "V4.00". It is used by SSA to give a warning if + /// you are using a version of SSA older than the version that created the script. + /// ***ASS version is "V4.00+"*** + ScriptType, + + /// This determines how subtitles are moved, when automatically preventing onscreen collisions. + /// + /// If the entry says "Normal" then SSA will attempt to position subtitles in the position + /// specified by the "margins". However, subtitles can be shifted vertically to prevent + /// onscreen collisions. With "normal" collision prevention, the subtitles will "stack up" one + /// above the other - but they will always be positioned as close the vertical (bottom) margin + /// as possible - filling in "gaps" in other subtitles if one large enough is available. + /// + /// If the entry says "Reverse" then subtitles will be shifted upwards to make room for + /// subsequent overlapping subtitles. This means the subtitles can nearly always be read + /// top-down - but it also means that the first subtitle can appear half way up the screen + /// before the subsequent overlapping subtitles appear. It can use a lot of screen area. + Collisions, + + /// This is the height of the screen used by the script's author(s) when playing the script. + /// SSA v4 will automatically select the nearest enabled setting, if you are using Directdraw + /// playback + PlayResY, + + /// This is the width of the screen used by the script's author(s) when playing the script. SSA + /// will automatically select the nearest enabled, setting if you are using Directdraw + /// playback + PlayResX, + + /// This is the colour depth used by the script's author(s) when playing the script. SSA will + /// automatically select the nearest enabled setting if you are using Directdraw playback. + PlayDepth, + + /// This is the Timer Speed for the script, as a percentage. eg. "100.0000" is exactly 100%. It + /// has four digits following the decimal point. + /// + /// The timer speed is a time multiplier applied to SSA's clock to stretch or compress the + /// duration of a script. A speed greater than 100% will reduce the overall duration, and means + /// that subtitles will progressively appear sooner and sooner. A speed less than 100% will + /// increase the overall duration of the script means subtitles will progressively appear later + /// and later (like a positive ramp time). + /// + /// The stretching or compressing only occurs during script playback - this value does not + /// change the actual timings for each event listed in the script. + /// + /// Check the SSA user guide if you want to know why "Timer Speed" is more powerful than "Ramp + /// Time", even though they both achieve the same result. + Timer, + + /// Defines the default wrapping style: + /// - 0: smart wrapping, lines are evenly broken + /// - 1: end-of-line word wrapping, only \N breaks + /// - 2: no word wrapping, \n \N both breaks + /// - 3: same as 0, but lower line gets wider. + WrapStyle, + + /// Undocumented, generated by Aegisub. + ScaledBorderAndShadow, + + /// Undocumented, generated by Aegisub. + YCbCrMatrix, +} + +impl FromStr for ScriptInfoKey { + type Err = String; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + use ScriptInfoKey::*; + match s.trim() { + "Title" => Ok(Title), + "OriginalTranslation" | "Original Translation" => Ok(OriginalTranslation), + "OriginalEditing" | "Original Editing" => Ok(OriginalEditing), + "ScriptUpdatedBy" | "Script Updated By" => Ok(ScriptUpdatedBy), + "OriginalTiming" | "Original Timing" => Ok(OriginalTiming), + "OriginalScript" | "Original Script" => Ok(OriginalScript), + "UpdateDetails" | "Update Details" => Ok(UpdateDetails), + "YCbCrMatrix" | "YCbCr Matrix" => Ok(YCbCrMatrix), + "SynchPoint" | "Synch Point" => Ok(SynchPoint), + "ScriptType" | "Script Type" => Ok(ScriptType), + "Collisions" => Ok(Collisions), + "PlayResY" => Ok(PlayResY), + "PlayResX" => Ok(PlayResX), + "PlayDepth" => Ok(PlayDepth), + "Timer" => Ok(Timer), + "WrapStyle" => Ok(WrapStyle), + "ScaledBorderAndShadow" => Ok(ScaledBorderAndShadow), + _ => Err(format!("unknown Script Info key: {s}")), + } + } +} + +impl FromStr for ASSFileSection { + type Err = String; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + use ASSFileSection::*; + const TRIM_PAT: &[char] = &['[', ']', ' ', '\t']; + match s.trim_matches(TRIM_PAT).to_lowercase().as_str() { + "script info" => Ok(ScriptInfo), + "events" => Ok(Events), + "v4+ styles" | "v4 styles" | "v4 styles+" => Ok(V4Styles), + s => Err(format!("unknown section [{s}]")), + } + } +} diff --git a/src/Rust/vvs_ass/src/drawing.rs b/src/Rust/vvs_ass/src/drawing.rs new file mode 100644 index 0000000000000000000000000000000000000000..c2d2a88260a55919d2179b0a9e5301ce3a3c1053 --- /dev/null +++ b/src/Rust/vvs_ass/src/drawing.rs @@ -0,0 +1,156 @@ +/// Enum used to represent drawing commands. +/// +/// Things you should know: +/// - Commands must appear after {\p1+} and before {\p0}. (except for \clip(..)) +/// - Drawings must always start with a move to command. +/// - Drawings must form a closed shape. +/// - All unclosed shape will be closed with a straight line automatically. +/// - Overlapping shapes in the Dialogue line will be XOR-ed with each-other. +/// - If the same command follows another, it isn’t needed to write its identifier letter again, +/// only the coordinates. +/// - The coordinates are relative to the current cursor position (baseline) and the alignment +/// mode. +/// - Commands p and c should only follow other b-spline commands. +/// +/// Examples: +/// - Square: `m 0 0 l 100 0 100 100 0 100` +/// - Rounded square: `m 0 0 s 100 0 100 100 0 100 c` +/// (c equals to `p 0 0 100 0 100 100` in this case) +/// - Circle (almost): `m 50 0 b 100 0 100 100 50 100 b 0 100 0 0 50 0` +/// (note that the 2nd 'b' is optional here) +#[derive(Debug, Clone, PartialEq)] +pub enum ASSDrawingCmd { + // Moves the cursor to <x>, <y> + M { + x: i64, + y: i64, + }, + + // Moves the cursor to <x>, <y> (unclosed shapes will be left open) + N { + x: i64, + y: i64, + }, + + // Draws a line to <x>, <y> + L { + x: i64, + y: i64, + }, + + // 3rd degree bezier curve to point 3 using point 1 and 2 as the control points + B { + x1: i64, + y1: i64, + x2: i64, + y2: i64, + x3: i64, + y3: i64, + }, + + // 3rd degree uniform b-spline to point N, must contain at least 3 coordinates + S { + x1: i64, + y1: i64, + x2: i64, + y2: i64, + x3: i64, + y3: i64, + others: Vec<(i64, i64)>, + }, + + // Extend b-spline to <x>, <y> + P { + x: i64, + y: i64, + }, + + // close b-spline + C, +} + +/// Contains an ASS drawing. +#[derive(Debug, Default, Clone, PartialEq)] +pub struct ASSDrawing { + content: Vec<ASSDrawingCmd>, +} + +impl ASSDrawingCmd { + pub fn is_move_cmd(&self) -> bool { + use ASSDrawingCmd::*; + matches!(self, M { .. } | N { .. }) + } +} + +impl ASSDrawing { + /// Verify that the drawing is correct! + pub fn verify(&self) -> bool { + match &self.content[..] { + [] => true, + [head] => head.is_move_cmd(), + whole @ [head, content @ ..] if head.is_move_cmd() => { + let mut in_bsplit_line = false; + for (prev, curr) in whole.iter().zip(content) { + match curr { + ASSDrawingCmd::M { .. } | ASSDrawingCmd::N { .. } => { + // FIXME: This is incorrect for N, it could be at the last position, + // check latter if we can find a moar correct version of the + // check function... + log::error!(target: "ass", "found move cmd `{curr:?}` in the middle of a drawing"); + return false; + } + + ASSDrawingCmd::C | ASSDrawingCmd::P { .. } => { + if !matches!(prev, ASSDrawingCmd::S { .. }) { + log::error!(target: "ass", "found C/P cmd not after an S command"); + return false; + } + in_bsplit_line = false; + } + + ASSDrawingCmd::S { .. } => in_bsplit_line = true, + ASSDrawingCmd::B { .. } | ASSDrawingCmd::L { .. } => {} + } + } + !in_bsplit_line + } + _ => false, + } + } +} + +impl std::fmt::Display for ASSDrawingCmd { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ASSDrawingCmd::M { x, y } => write!(f, "m {x} {y}"), + ASSDrawingCmd::N { x, y } => write!(f, "n {x} {y}"), + ASSDrawingCmd::L { x, y } => write!(f, "l {x} {y}"), + ASSDrawingCmd::B { x1, y1, x2, y2, x3, y3 } => write!(f, "b {x1} {y1} {x2} {y2} {x3} {y3}"), + ASSDrawingCmd::S { x1, y1, x2, y2, x3, y3, others } if others.is_empty() => { + write!(f, "b {x1} {y1} {x2} {y2} {x3} {y3}") + } + ASSDrawingCmd::S { x1, y1, x2, y2, x3, y3, others } => { + let others = others + .iter() + .map(|(x, y)| format!("{x} {y}")) + .collect::<Vec<_>>() + .join(" "); + write!(f, "b {x1} {y1} {x2} {y2} {x3} {y3} {others}") + } + ASSDrawingCmd::P { x, y } => write!(f, "p {x} {y}"), + ASSDrawingCmd::C => write!(f, "c"), + } + } +} + +impl std::fmt::Display for ASSDrawing { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let content = self + .content + .iter() + .map(|cmd| format!("{cmd}")) + .collect::<Vec<_>>() + .join(" "); + write!(f, "{content}") + } +} diff --git a/src/Rust/vvs_ass/src/elements/line.rs b/src/Rust/vvs_ass/src/elements/line.rs new file mode 100644 index 0000000000000000000000000000000000000000..63df87f09256b591a6ed13421f264e77b0585aff --- /dev/null +++ b/src/Rust/vvs_ass/src/elements/line.rs @@ -0,0 +1,78 @@ +use crate::{ASSAuxTable, ASSPosition, ASSSyllabePtr}; + +#[derive(Debug, Default, Clone, PartialEq)] +pub struct ASSLine { + pub position: ASSPosition, + pub content: Vec<ASSSyllabePtr>, + pub aux: ASSAuxTable, + pub start: i64, + pub fini: i64, +} + +#[derive(Debug, Default, Clone)] +#[repr(transparent)] +pub struct ASSLinePtr(pub crate::Ptr<ASSLine>); + +#[derive(Debug, Default, Clone)] +#[repr(transparent)] +pub struct ASSLines(pub Vec<ASSLinePtr>); + +impl PartialEq for ASSLinePtr { + fn eq(&self, other: &Self) -> bool { + *self.0.try_read().unwrap() == *other.0.try_read().unwrap() + } +} + +impl PartialEq for ASSLines { + fn eq(&self, Self(other): &Self) -> bool { + let Self(this) = self; + (this.len() != other.len()) && { + this.iter() + .zip(other.iter()) + .fold(true, |acc, (ASSLinePtr(this), ASSLinePtr(other))| { + acc && (*this.try_read().unwrap() == *other.try_read().unwrap()) + }) + } + } +} + +impl From<ASSLine> for ASSLinePtr { + fn from(value: ASSLine) -> Self { + ASSLinePtr(crate::ptr!(value)) + } +} + +impl ASSLines { + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn push(&mut self, value: impl Into<ASSLinePtr>) { + self.0.push(value.into()) + } +} + +impl Extend<ASSLinePtr> for ASSLines { + fn extend<T: IntoIterator<Item = ASSLinePtr>>(&mut self, iter: T) { + self.0.extend(iter) + } +} + +impl Extend<ASSLine> for ASSLines { + fn extend<T: IntoIterator<Item = ASSLine>>(&mut self, iter: T) { + self.extend(iter.into_iter().map(|line| ASSLinePtr(crate::ptr!(line)))) + } +} + +impl IntoIterator for ASSLines { + type Item = ASSLinePtr; + type IntoIter = <Vec<ASSLinePtr> as IntoIterator>::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} diff --git a/src/Rust/vvs_ass/src/elements/mod.rs b/src/Rust/vvs_ass/src/elements/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..2de364b6fb53687854afc8678fe8c23baffb5e62 --- /dev/null +++ b/src/Rust/vvs_ass/src/elements/mod.rs @@ -0,0 +1,39 @@ +mod line; +mod syllabe; + +pub use self::{line::*, syllabe::*}; + +use crate::{definitions::ScriptInfoKey, ASSStyle}; +use std::collections::HashMap; + +#[derive(Debug, Clone)] +pub struct ASSContainer { + pub lines: ASSLines, + pub script_info: HashMap<ScriptInfoKey, String>, + pub styles: HashMap<String, ASSStyle>, +} + +#[derive(Debug, Clone)] +#[repr(transparent)] +pub struct ASSContainerPtr(pub crate::Ptr<ASSContainer>); + +impl ASSContainer { + /// Create an ASS container from its parts, they must be valide! + pub(crate) fn from_parts( + lines: impl IntoIterator<Item = ASSLine>, + script_info: HashMap<ScriptInfoKey, String>, + styles: HashMap<String, ASSStyle>, + ) -> Self { + Self { + lines: ASSLines(lines.into_iter().map(|line| ASSLinePtr(crate::ptr!(line))).collect()), + script_info, + styles, + } + } +} + +impl From<ASSContainer> for ASSContainerPtr { + fn from(value: ASSContainer) -> Self { + Self(crate::ptr!(value)) + } +} diff --git a/src/Rust/vvs_ass/src/elements/syllabe.rs b/src/Rust/vvs_ass/src/elements/syllabe.rs new file mode 100644 index 0000000000000000000000000000000000000000..4c098045cfe2de182e5e9b54e8c20922cec6435e --- /dev/null +++ b/src/Rust/vvs_ass/src/elements/syllabe.rs @@ -0,0 +1,78 @@ +use crate::{ASSAuxTable, ASSPosition}; + +#[derive(Debug, Default, Clone, PartialEq)] +pub struct ASSSyllabe { + pub position: ASSPosition, + pub content: String, + pub aux: ASSAuxTable, + pub start: i64, + pub fini: i64, +} + +#[derive(Debug, Default, Clone)] +#[repr(transparent)] +pub struct ASSSyllabePtr(pub crate::Ptr<ASSSyllabe>); + +#[derive(Debug, Default, Clone)] +#[repr(transparent)] +pub struct ASSSyllabes(pub Vec<ASSSyllabePtr>); + +impl PartialEq for ASSSyllabePtr { + fn eq(&self, other: &Self) -> bool { + *self.0.try_read().unwrap() == *other.0.try_read().unwrap() + } +} + +impl PartialEq for ASSSyllabes { + fn eq(&self, Self(other): &Self) -> bool { + let Self(this) = self; + (this.len() != other.len()) && { + this.iter() + .zip(other.iter()) + .fold(true, |acc, (ASSSyllabePtr(this), ASSSyllabePtr(other))| { + acc && (*this.try_read().unwrap() == *other.try_read().unwrap()) + }) + } + } +} + +impl From<ASSSyllabe> for ASSSyllabePtr { + fn from(value: ASSSyllabe) -> Self { + ASSSyllabePtr(crate::ptr!(value)) + } +} + +impl ASSSyllabes { + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn push(&mut self, value: impl Into<ASSSyllabePtr>) { + self.0.push(value.into()) + } +} + +impl Extend<ASSSyllabePtr> for ASSSyllabes { + fn extend<T: IntoIterator<Item = ASSSyllabePtr>>(&mut self, iter: T) { + self.0.extend(iter) + } +} + +impl Extend<ASSSyllabe> for ASSSyllabes { + fn extend<T: IntoIterator<Item = ASSSyllabe>>(&mut self, iter: T) { + self.extend(iter.into_iter().map(|syllabe| ASSSyllabePtr(crate::ptr!(syllabe)))) + } +} + +impl IntoIterator for ASSSyllabes { + type Item = ASSSyllabePtr; + type IntoIter = <Vec<ASSSyllabePtr> as IntoIterator>::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} diff --git a/src/Rust/vvs_ass/src/lib.rs b/src/Rust/vvs_ass/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..59575219123c8918200d5218621b456152507680 --- /dev/null +++ b/src/Rust/vvs_ass/src/lib.rs @@ -0,0 +1,31 @@ +//! ASS objects for Vivy. +#![forbid(unsafe_code)] + +mod colors; +mod definitions; +mod drawing; +mod elements; +mod position; +mod reader; +mod styles; +mod types; +mod values; + +#[cfg(test)] +mod tests; + +pub use crate::{colors::*, drawing::*, elements::*, position::*, styles::*, types::*, values::*}; +pub use reader::{ass_container_from_file, ass_container_from_str, ASSElementReaderError, ContainerFileType}; + +pub type Ptr<T> = std::sync::Arc<std::sync::RwLock<T>>; + +#[macro_export] +macro_rules! ptr { + ($expr: expr) => { + std::sync::Arc::new(std::sync::RwLock::new($expr)) + }; +} + +/// A trait to parameterize the ASS element types. It can be a mutable pointer (Arc<RwLock>), a +/// pointer to a constant thing (Arc) or no pointer at all (just the ASS element). +pub trait ASSPtr<T>: std::ops::Deref<Target = T> {} diff --git a/src/Rust/vvs_ass/src/position.rs b/src/Rust/vvs_ass/src/position.rs new file mode 100644 index 0000000000000000000000000000000000000000..bc335d235b228340a98591e5d0518edee28b60ab --- /dev/null +++ b/src/Rust/vvs_ass/src/position.rs @@ -0,0 +1,82 @@ +/// The position of an object from the top left corner of the screen. The real position depends on +/// the align of the object. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum ASSPosition { + /// An unspecified position. + #[default] + Unspecified, + + /// A static position. + Pos { x: i64, y: i64 }, + + /// A linear movement. + LinearMove { x1: i64, y1: i64, x2: i64, y2: i64 }, + + /// A linear movement with time step specified. + TimedMove { x1: i64, y1: i64, x2: i64, y2: i64, from_ms: i64, to_ms: i64 }, +} + +/// The alignement of the object. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ASSAlign { + /// Bottom left + BL = 1, + + /// Bottom center + BC = 2, + + /// Bottom right + BR = 3, + + /// Center left + CL = 4, + + /// Center center + CC = 5, + + /// Center right + CR = 6, + + /// Top left + TL = 7, + + /// Top center + TC = 8, + + /// Top right + TR = 9, +} + +/// Pointer used to store a position, to help with mutability with LUA wrappers. +pub type ASSPositionPtr = std::sync::Arc<ASSPosition>; + +impl std::str::FromStr for ASSAlign { + type Err = String; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + match s.trim() { + "1" => Ok(ASSAlign::BL), + "2" => Ok(ASSAlign::BC), + "3" => Ok(ASSAlign::BR), + "4" => Ok(ASSAlign::CL), + "5" => Ok(ASSAlign::CC), + "6" => Ok(ASSAlign::CR), + "7" => Ok(ASSAlign::TL), + "8" => Ok(ASSAlign::TC), + "9" => Ok(ASSAlign::TR), + s => Err(format!("invalid value for ASSAlign, must be in `1..=9`, got: {s}")), + } + } +} + +impl From<vvs_font::Point> for ASSPosition { + fn from(vvs_font::Point { x, y }: vvs_font::Point) -> Self { + ASSPosition::Pos { x, y } + } +} + +impl ASSPosition { + pub fn into_ptr(self) -> ASSPositionPtr { + std::sync::Arc::new(self) + } +} diff --git a/src/Rust/vvs_ass/src/reader/ass.rs b/src/Rust/vvs_ass/src/reader/ass.rs new file mode 100644 index 0000000000000000000000000000000000000000..77c1425508eaa808a62935e72e6d18a2d79fe13a --- /dev/null +++ b/src/Rust/vvs_ass/src/reader/ass.rs @@ -0,0 +1,372 @@ +use crate::{ + definitions::{ASSEvent, ASSFileSection, ScriptInfoKey}, + reader::ASSElementReader, + ASSColor, ASSContainer, ASSElementReaderError, ASSLine, ASSStyle, +}; +use std::collections::HashMap; +use vvs_procmacro::EnumVariantFromStr; + +/// Line fields of the `V4+ Styles` line in the ASS file. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumVariantFromStr)] +enum V4PlusStyleFields { + Name, + Fontname, + Fontsize, + PrimaryColour, + SecondaryColour, + OutlineColour, + BackColour, + Bold, + Italic, + Underline, + StrikeOut, + ScaleX, + ScaleY, + Spacing, + Angle, + BorderStyle, + Outline, + Shadow, + Alignment, + MarginL, + MarginR, + MarginV, + Encoding, +} + +/// Line fields of the `V4+ Styles` line in the ASS file. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumVariantFromStr)] +enum EventFields { + Marked, + Layer, + Start, + End, + Style, + Name, + MarginL, + MarginR, + MarginV, + Effect, + Text, +} + +/// Helper wrapper struct to auto unwrap things when we get entries from the HashMap. +#[derive(Debug)] +struct UnwrapHashMap<'a, K>(HashMap<K, &'a str>) +where + K: Eq + Copy + std::hash::Hash + std::fmt::Debug; + +impl<'a, K: Eq + Copy + std::hash::Hash + std::fmt::Debug> UnwrapHashMap<'a, K> { + /// Get the element from the hashmap, if the element is not present just panics. + pub fn get(&self, key: K) -> &str { + self.0.get(&key).unwrap_or_else(|| panic!("failed to find key {key:?}")) + } + + /// Try to get the element from the hashmap, if the element is not present, returns the + /// specified default value instead. + pub fn get_or(&self, key: K, default: &'a str) -> &str { + self.0.get(&key).copied().unwrap_or(default) + } + + /// Try to get the element from the hashmap, if the element is present then tries to parse it. + pub fn parsed<T>( + &self, + key: K, + parser: fn(&str, name: &str) -> Result<T, ASSElementReaderError>, + name: &str, + ) -> Result<T, ASSElementReaderError> { + let Self(hashmap) = self; + let element = hashmap.get(&key).ok_or(ASSElementReaderError::Custom(format!( + "failed to find entry with key {key:?}" + )))?; + parser(element, name) + } + + /// Try to get the element from the hashmap, if the element is present then tries to parse it. + /// If the element is not present or the parsing failed, returns the default value. + pub fn parsed_or<T>( + &self, + key: K, + parser: fn(&str, &str) -> Result<T, ASSElementReaderError>, + name: &str, + default: T, + ) -> T { + self.parsed(key, parser, name).unwrap_or(default) + } +} + +/// Documentation available here: http://www.tcax.org/docs/ass-specs.html or in the `utils/manual` +/// folder from the root of the vvs project. +#[derive(Debug, Default)] +pub struct ASSReader { + section: Option<ASSFileSection>, + + styles_format: Vec<V4PlusStyleFields>, + events_format: Vec<EventFields>, + + script_info: HashMap<ScriptInfoKey, String>, + styles: HashMap<String, ASSStyle>, + events: Vec<ASSEvent>, +} + +fn parse_color(color: &str, name: &str) -> Result<ASSColor, ASSElementReaderError> { + ASSColor::try_from_bgra(color).map_err(|err| ASSElementReaderError::Custom(format!("invalid {name} color: {err}"))) +} + +fn parse_boolean(boolean: &str, name: &str) -> Result<bool, ASSElementReaderError> { + match boolean.trim() { + "-1" | "1" | "+1" => Ok(true), + "0" => Ok(false), + boolean => Err(ASSElementReaderError::Custom(format!( + "invalid ass boolean for {name} found: {boolean}" + ))), + } +} + +fn parse_float(float: &str, name: &str) -> Result<f64, ASSElementReaderError> { + float + .parse::<f64>() + .map_err(|err| ASSElementReaderError::Custom(format!("invalid float value for {name}: {err}"))) +} + +fn parse_int(int: &str, name: &str) -> Result<i64, ASSElementReaderError> { + int.parse::<i64>() + .map_err(|err| ASSElementReaderError::Custom(format!("invalid integer value for {name}: {err}"))) +} + +/// Parse dates in the `0:00:00:00` format +fn parse_date(date: &str, name: &str) -> Result<i64, ASSElementReaderError> { + let std_err = "invalid date for {name}, expected \"h:mm:ss.cc\", got {date:?}".to_string(); + let [h, m, sc] = &date.split(':').collect::<Vec<_>>()[..] else { + return Err(ASSElementReaderError::Custom(std_err)); + }; + let [s, c] = &sc.split('.').collect::<Vec<_>>()[..] else { + return Err(ASSElementReaderError::Custom(std_err)); + }; + let check_compnent = |str: &str, compnent: &str, len: usize| { + if str.len() > len { + Err(ASSElementReaderError::Custom(std_err.clone())) + } else { + Ok(str.parse::<u16>().map_err(|err| { + ASSElementReaderError::Custom(format!("invalid component {compnent} for date {name}: {err}")) + })? as i64) + } + }; + let (h, m, s, c) = ( + check_compnent(h, "hours", 1)?, + check_compnent(m, "minutes", 2)?, + check_compnent(s, "seconds", 2)?, + check_compnent(c, "centi-seconds", 2)?, + ); + Ok(((h * 60 + m) * 60 + s) * 100 + c) +} + +impl ASSReader { + fn read_script_info(&mut self, line: &str) -> Result<(), ASSElementReaderError> { + let Some((key, value)) = line.split_once(':') else { + return Err(ASSElementReaderError::Custom(format!( + "invalid script info line: {line}" + ))); + }; + let value = value.trim(); + let key = match key + .trim() + .parse::<ScriptInfoKey>() + .map_err(ASSElementReaderError::Custom)? + { + ScriptInfoKey::ScriptType if value.ne("V4.00+") && value.ne("v4.00+") => { + return Err(ASSElementReaderError::Custom(format!( + "invalid value for key '{key:?}' in script info section: {value}" + ))) + } + ScriptInfoKey::WrapStyle if !("0".."3").contains(&value) => { + return Err(ASSElementReaderError::Custom(format!( + "invalid value for key '{key:?}' in script info section: {value}" + ))) + } + key => key, + }; + match self.script_info.get(&key) { + Some(_) => Err(ASSElementReaderError::Custom(format!( + "redefinition of key '{key:?}' in script info section" + ))), + None => { + self.script_info.insert(key, value.to_string()); + Ok(()) + } + } + } + + fn read_v4_style(&mut self, line: &str) -> Result<(), ASSElementReaderError> { + let line = if line.starts_with("Format:") { + let line = line.split_once(':').unwrap().1.trim(); + self.styles_format = line + .split(',') + .flat_map(|str| str.trim().parse::<V4PlusStyleFields>()) + .collect(); + return Ok(()); + } else { + line.split_once(':').unwrap().1.trim() + }; + + let fields: Vec<_> = line.trim().split(',').map(|str| str.trim()).collect(); + if fields.len() != self.styles_format.len() { + return Err(ASSElementReaderError::Custom(format!( + "style format specified {} fields, got {} in the style description", + self.styles_format.len(), + fields.len() + ))); + } + let fields: UnwrapHashMap<V4PlusStyleFields> = + UnwrapHashMap(HashMap::from_iter(self.styles_format.iter().copied().zip(fields))); + + let encoding = fields.get(V4PlusStyleFields::Encoding); + if encoding.ne("1") { + return Err(ASSElementReaderError::Custom(format!( + "we expected the encoding '1', got: {encoding}" + ))); + } + + let name = fields.get(V4PlusStyleFields::Name); + let style = ASSStyle { + name: name.to_string(), + font_name: fields.get(V4PlusStyleFields::Fontname).to_string(), + font_size: fields.parsed(V4PlusStyleFields::Fontsize, parse_int, "font size")?, + primary_color: fields.parsed(V4PlusStyleFields::PrimaryColour, parse_color, "primary")?, + secondary_color: fields.parsed(V4PlusStyleFields::SecondaryColour, parse_color, "secondary")?, + outline_color: fields.parsed(V4PlusStyleFields::OutlineColour, parse_color, "outline")?, + back_color: fields.parsed(V4PlusStyleFields::BackColour, parse_color, "back")?, + bold: fields.parsed(V4PlusStyleFields::Bold, parse_boolean, "bold")?, + italic: fields.parsed(V4PlusStyleFields::Italic, parse_boolean, "italic")?, + underline: fields.parsed(V4PlusStyleFields::Underline, parse_boolean, "underline")?, + strikeout: fields.parsed(V4PlusStyleFields::StrikeOut, parse_boolean, "strikeout")?, + scale_x: fields.parsed(V4PlusStyleFields::ScaleX, parse_float, "scale_x")?, + scale_y: fields.parsed(V4PlusStyleFields::ScaleY, parse_float, "scale_y")?, + spacing: fields.parsed(V4PlusStyleFields::Spacing, parse_float, "spacing")?, + angle: fields.parsed(V4PlusStyleFields::Angle, parse_float, "angle")?, + border_style: fields + .get(V4PlusStyleFields::BorderStyle) + .parse() + .map_err(ASSElementReaderError::Custom)?, + outline: fields.parsed(V4PlusStyleFields::Outline, parse_float, "outline")?, + shadow: fields.parsed(V4PlusStyleFields::Shadow, parse_float, "shadow")?, + alignment: fields + .get(V4PlusStyleFields::Alignment) + .parse() + .map_err(ASSElementReaderError::Custom)?, + margin_l: fields.parsed(V4PlusStyleFields::MarginL, parse_int, "margin_l")?, + margin_r: fields.parsed(V4PlusStyleFields::MarginR, parse_int, "margin_r")?, + margin_v: fields.parsed(V4PlusStyleFields::MarginV, parse_int, "margin_v")?, + }; + + match self.styles.insert(name.to_string(), style) { + None => Ok(()), + Some(old) => { + log::error!(target: "ass", "redefine style '{name}', previous style was: {old:#?}"); + Err(ASSElementReaderError::Custom(format!( + "redefinition of style '{name}'" + ))) + } + } + } + + fn read_event(&mut self, line: &str) -> Result<(), ASSElementReaderError> { + let line = if line.starts_with("Format:") { + let line = line.split_once(':').unwrap().1.trim(); + self.events_format = line + .split(',') + .flat_map(|str| str.trim().parse::<EventFields>()) + .collect(); + return match self.events_format.last() { + Some(EventFields::Text) => Ok(()), + _ => Err(ASSElementReaderError::Custom(format!( + "invalid format line: {:?}", + self.events_format + ))), + }; + } else { + line.split_once(':').unwrap().1.trim() + }; + let fields = line.splitn(self.events_format.len(), ',').collect::<Vec<_>>(); + if fields.len() != self.events_format.len() { + return Err(ASSElementReaderError::Custom(format!( + "style format specified {} fields, got {} in the style description", + self.events_format.len(), + fields.len() + ))); + } + let fields: UnwrapHashMap<EventFields> = UnwrapHashMap(HashMap::from_iter( + self.events_format + .iter() + .copied() + .zip(fields.into_iter().map(|s| s.trim())), + )); + self.events.push(ASSEvent { + marked: fields.parsed_or(EventFields::Marked, parse_boolean, "marked", false), + layer: fields.parsed_or(EventFields::Layer, parse_int, "layer", 0), + start: fields.parsed(EventFields::Start, parse_date, "start")?, + end: fields.parsed(EventFields::End, parse_date, "end")?, + style: fields.get_or(EventFields::Style, "Default").to_string(), + name: fields.get_or(EventFields::Name, "").to_string(), + effect: fields.get_or(EventFields::Effect, "").to_string(), + text: fields.get(EventFields::Text).to_string(), + }); + Ok(()) + } +} + +impl ASSElementReader for ASSReader { + fn try_read(mut self, file: impl std::io::BufRead) -> Result<ASSContainer, ASSElementReaderError> { + // Parse the file + let mut skip_that_section = false; + for line in file.lines() { + let line = line.map_err(ASSElementReaderError::FailedToReadLine)?; + let line = line.trim(); + + if line.is_empty() || line.starts_with(';') { + continue; + } else if line.starts_with('[') && line.ends_with(']') { + skip_that_section = false; + self.section = match line.parse::<ASSFileSection>() { + Ok(section) => Some(section), + Err(err) => { + log::error!(target: "ass", "{err}"); + skip_that_section = true; + continue; + } + }; + } else if !skip_that_section { + match self.section { + Some(ASSFileSection::ScriptInfo) => self.read_script_info(line)?, + Some(ASSFileSection::V4Styles) => self.read_v4_style(line)?, + Some(ASSFileSection::Events) => self.read_event(line)?, + None => { + return Err(ASSElementReaderError::Custom(format!( + "found the following line without a section: {line}" + ))) + } + } + } + } + + // Verify integrity + order events + self.events.sort_by(|a, b| Ord::cmp(&a.start, &b.end)); + if !self.styles.contains_key(ASSStyle::default_name()) { + self.styles.insert(ASSStyle::default_name_string(), ASSStyle::default()); + } + for ASSEvent { style, .. } in &mut self.events { + if !self.styles.contains_key(style) { + *style = ASSStyle::default_name_string() + } + } + + // Convert events into lines... + let mut lines = Vec::with_capacity(self.events.len()); + for event in self.events { + let ASSEvent { start, end, .. } = event; + lines.push(ASSLine { start, fini: end, ..Default::default() }); + } + + Ok(ASSContainer::from_parts(lines, self.script_info, self.styles)) + } +} diff --git a/src/Rust/vvs_ass/src/reader/json.rs b/src/Rust/vvs_ass/src/reader/json.rs new file mode 100644 index 0000000000000000000000000000000000000000..b6df22660a63567121c6baed741b698cba79d07c --- /dev/null +++ b/src/Rust/vvs_ass/src/reader/json.rs @@ -0,0 +1,12 @@ +use crate::{reader::ASSElementReader, ASSContainer, ASSElementReaderError}; + +/// Documentation available here: http://www.tcax.org/docs/ass-specs.html or in the `utils/manual` +/// folder. +#[derive(Debug, Default)] +pub struct JSONReader {} + +impl ASSElementReader for JSONReader { + fn try_read(self, _file: impl std::io::BufRead) -> Result<ASSContainer, ASSElementReaderError> { + todo!() + } +} diff --git a/src/Rust/vvs_ass/src/reader/mod.rs b/src/Rust/vvs_ass/src/reader/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..70f904a9d664113a6fb22452e74cb29a5cff7591 --- /dev/null +++ b/src/Rust/vvs_ass/src/reader/mod.rs @@ -0,0 +1,70 @@ +//! Read the content of an ASS file / a Vivy subtitle file and creates an +//! [vvs_ass::elements::lines::ASSLinesPtr] structure accordingly. + +use crate::ASSContainer; +use std::{ + fs::File, + io::{BufReader, Error as IoError}, + path::{Path, PathBuf}, +}; +use thiserror::Error; +use vvs_procmacro::EnumVariantFromStr; + +mod ass; +mod json; + +#[derive(Debug, EnumVariantFromStr)] +pub enum ContainerFileType { + ASS, + VVSB, + Json, +} + +#[derive(Debug, Error)] +pub enum ASSElementReaderError { + #[error("file has no extension: {0}")] + NoExtension(PathBuf), + + #[error("failed to open file {0}: {1}")] + FailedToOpenFile(PathBuf, IoError), + + #[error("unknown file extension for subtitles")] + UnknownExtension(String), + + #[error("failed to read line: {0}")] + FailedToReadLine(IoError), + + #[error("{0}")] + Custom(String), +} + +trait ASSElementReader { + fn try_read(self, file: impl std::io::BufRead) -> Result<ASSContainer, ASSElementReaderError>; +} + +pub fn ass_container_from_file(file: impl AsRef<Path>) -> Result<ASSContainer, ASSElementReaderError> { + let file = file.as_ref(); + let Some(extension) = file.extension() else { + return Err(ASSElementReaderError::NoExtension(file.to_path_buf())); + }; + let content = BufReader::new( + File::open(file).map_err(|err| ASSElementReaderError::FailedToOpenFile(file.to_path_buf(), err))?, + ); + match &extension.to_string_lossy().to_lowercase()[..] { + "ass" => ass::ASSReader::default().try_read(content), + "vvsb" | "json" => json::JSONReader::default().try_read(content), + extension => Err(ASSElementReaderError::UnknownExtension(extension.to_string())), + } +} + +pub fn ass_container_from_str( + extension: ContainerFileType, + str: impl AsRef<str>, +) -> Result<ASSContainer, ASSElementReaderError> { + use ContainerFileType::*; + let content = BufReader::new(str.as_ref().as_bytes()); + match extension { + ASS => ass::ASSReader::default().try_read(content), + VVSB | Json => json::JSONReader::default().try_read(content), + } +} diff --git a/src/Rust/vvs_ass/src/styles.rs b/src/Rust/vvs_ass/src/styles.rs new file mode 100644 index 0000000000000000000000000000000000000000..81252927a34c624b9c392c61bd352438f1d9073b --- /dev/null +++ b/src/Rust/vvs_ass/src/styles.rs @@ -0,0 +1,150 @@ +use crate::{ASSAlign, ASSColor}; + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum ASSBorderStyle { + #[default] + OutlineAndDropShadow = 1, + OpaqueBox = 3, +} + +/// Type used to describe an ASS style. +#[derive(Debug, Clone, PartialEq)] +pub struct ASSStyle { + /// The name of the Style. Case sensitive. Cannot include commas. + pub name: String, + + /// The fontname as used by libass. Case-sensitive. Can't include comas. + pub font_name: String, + + /// The size of the font. Don't use a thing that is too big... Must be positive. + pub font_size: i64, + + /// The colour that a subtitle will normally appear in. + pub primary_color: ASSColor, + + /// This colour may be used instead of the Primary colour when a subtitle is automatically + /// shifted to prevent an onscreen collsion, to distinguish the different subtitles. + pub secondary_color: ASSColor, + + /// Color of the outline of each characters. + pub outline_color: ASSColor, + + /// This is the colour of the subtitle outline or shadow, if these are used. + pub back_color: ASSColor, + + /// Whever the font is in bold. + pub bold: bool, + + /// Whever the font is in italic. + pub italic: bool, + + /// Whever the font is underlined. + pub underline: bool, + + /// Whever the font is strikeout. + pub strikeout: bool, + + /// Modifies the width of the font. Value in percent. + pub scale_x: f64, + + /// Modifies the height of the font. Value in percent. + pub scale_y: f64, + + /// Extra space between characters. Value in pixels. + pub spacing: f64, + + /// The origin of the rotation is defined by the alignment. Can be a floating point number. + /// Value in degrees. + pub angle: f64, + + /// The border style. + pub border_style: ASSBorderStyle, + + /// If border_style is [ASSBorderStyle::OutlineAndDropShadow], then this specifies the width of + /// the outline around the text, in pixels. + /// + /// Values may be 0, 1, 2, 3 or 4 the documentation says, but libass seems to support floating + /// values. + pub outline: f64, + + /// If border_style is [ASSBorderStyle::OutlineAndDropShadow], then this specifies the depth of + /// the drop shadow behind the text, in pixels. + /// + /// Values may be 0, 1, 2, 3 or 4 the documentation says, but libass seems to support floating + /// values. Drop shadow is always used in addition to an outline - SSA will force an outline of + /// 1 pixel if no outline width is given. + pub shadow: f64, + + /// This sets how text is "justified" within the Left/Right onscreen margins, and also the + /// vertical placing. + pub alignment: ASSAlign, + + /// This defines the Left Margin in pixels. It is the distance from the left-hand edge of the + /// screen.The three onscreen margins (margin_l, margin_r, margin_v) define areas in which the + /// subtitle text will be displayed. + /// + /// Must be a positive integer. + pub margin_l: i64, + + /// This defines the Right Margin in pixels. It is the distance from the right-hand edge of the + /// screen. The three onscreen margins (margin_l, margin_r, margin_v) define areas in which the + /// subtitle text will be displayed. + pub margin_r: i64, + + /// This defines the vertical Left Margin in pixels. + /// - For a subtitle, it is the distance from the bottom of the screen. + /// - For a toptitle, it is the distance from the top of the screen. + /// - For a midtitle, the value is ignored - the text will be vertically centred + pub margin_v: i64, +} + +impl std::str::FromStr for ASSBorderStyle { + type Err = String; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + match s.trim() { + "1" => Ok(ASSBorderStyle::OutlineAndDropShadow), + "3" => Ok(ASSBorderStyle::OpaqueBox), + s => Err(format!("invalid value '{s}' for ASSBorderStyle, must be 1 or 3")), + } + } +} + +impl ASSStyle { + pub fn default_name() -> &'static str { + "Default" + } + + pub fn default_name_string() -> String { + Self::default_name().to_string() + } +} + +impl Default for ASSStyle { + fn default() -> Self { + Self { + name: Self::default_name_string(), + font_name: Default::default(), + font_size: 14, + primary_color: ASSColor::WHITE, + secondary_color: ASSColor::RED, + outline_color: ASSColor::BLACK, + back_color: ASSColor::BLACK, + bold: false, + italic: false, + underline: false, + strikeout: false, + scale_x: 100.0, + scale_y: 100.0, + spacing: 0.0, + angle: 0.0, + border_style: Default::default(), + outline: 3.0, + shadow: 1.0, + alignment: ASSAlign::TC, + margin_l: 0, + margin_r: 0, + margin_v: 10, + } + } +} diff --git a/src/Rust/vvs_ass/src/tests.rs b/src/Rust/vvs_ass/src/tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..12c6ec72cd1c0f35d88dffac3c7e80cfc8793a51 --- /dev/null +++ b/src/Rust/vvs_ass/src/tests.rs @@ -0,0 +1,93 @@ +mod color { + use crate::*; + + // https://en.wikipedia.org/wiki/HSL_and_HSV#Hue_and_chroma + const RGB2HSL_TABLE: [(&str, f32, f32, f32); 19] = [ + ("FFFFFF", 0.000, 0.000, 1.000), + ("808080", 0.000, 0.000, 0.500), + ("000000", 0.000, 0.000, 0.000), + ("FF0000", 0.000, 1.000, 0.500), + ("BFBF00", 60.00, 1.000, 0.375), + ("008000", 120.0, 1.000, 0.250), + ("80FFFF", 180.0, 1.000, 0.750), + ("8080FF", 240.0, 1.000, 0.750), + ("BF40BF", 300.0, 0.500, 0.500), + ("A0A424", 61.80, 0.638, 0.393), + ("411BEA", 251.1, 0.832, 0.511), + ("1EAC41", 134.9, 0.707, 0.396), + ("F0C80E", 49.50, 0.893, 0.497), + ("B430E5", 283.7, 0.775, 0.542), + ("ED7651", 14.30, 0.817, 0.624), + ("FEF888", 56.90, 0.991, 0.765), + ("19CB97", 162.4, 0.779, 0.447), + ("362698", 248.3, 0.601, 0.373), + ("7E7EB8", 240.5, 0.290, 0.607), + ]; + + #[test] + fn test_from_string() { + assert!(ASSColor::try_from_rgba("#&AABBCC&").is_ok()); + assert!(ASSColor::try_from_rgba("#AABBCC").is_ok()); + assert!(ASSColor::try_from_rgba("&AABBCC&").is_ok()); + assert!(ASSColor::try_from_rgba("AABBCC").is_ok()); + assert!(ASSColor::try_from_rgba("AABBCCAA").is_ok()); + + assert_eq!( + ASSColor::try_from_rgba("AABBCC").unwrap(), + ASSColor::try_from_bgra("CCBBAA").unwrap() + ); + } + + #[test] + fn test_rgb2hsl() { + for (rgb, h_target, s1_target, l1_target) in RGB2HSL_TABLE { + const EPSILON_DEG: f32 = 1.0; + const EPSILON_0_1: f32 = 10E-3; + let ASSColor::HSLA { h, s, l, .. } = ASSColor::try_from_rgba(rgb).unwrap().into_hsla() else { + unreachable!() + }; + assert!( + (h_target - h).abs() <= EPSILON_DEG, + "invalid convertion for color #{rgb}, hue {h_target} != {h}" + ); + assert!( + (s - s1_target).abs() <= EPSILON_0_1, + "invalid convertion for color #{rgb}, s {s1_target} != {s}" + ); + assert!( + (l - l1_target).abs() <= EPSILON_0_1, + "invalid convertion for color #{rgb}, l {l1_target} != {l}" + ); + } + } + + #[test] + fn test_hsl2rgb() { + for (rgb, h, s, l) in RGB2HSL_TABLE { + macro_rules! eq { + ($a: expr, $b: expr, $($msg: expr),+) => { + assert!(($a as f32 - $b as f32).abs() <= 1.0, $($msg),+); + }; + } + let ASSColor::RGBA { r, g, b, .. } = ASSColor::HSLA { h, s, l, a: 0 }.into_rgba() else { + unreachable!() + }; + let ASSColor::RGBA { r: r_target, g: g_target, b: b_target, .. } = ASSColor::try_from_rgba(rgb).unwrap() + else { + unreachable!() + }; + eq! { r_target, r, "invalid convertion on red for #{rgb}: {r_target} != {r}"} + eq! { g_target, g, "invalid convertion on green for #{rgb}: {g_target} != {g}"} + eq! { b_target, b, "invalid convertion on blue for #{rgb}: {b_target} != {b}"} + } + } + + #[test] + fn test_parse_empty_ass() { + use crate::reader::ass_container_from_str; + let content = include_str!("../utils/empty.ass"); + if let Err(err) = ass_container_from_str(reader::ContainerFileType::ASS, content) { + panic!("{err}") + } + } +} diff --git a/src/Rust/vvs_ass/src/types.rs b/src/Rust/vvs_ass/src/types.rs new file mode 100644 index 0000000000000000000000000000000000000000..a3db26679a57b7879c7fd8862234d71f22f707c8 --- /dev/null +++ b/src/Rust/vvs_ass/src/types.rs @@ -0,0 +1,99 @@ +use serde::{Deserialize, Serialize}; +use vvs_procmacro::{EnumVariantCount, EnumVariantFromStr, EnumVariantIter}; + +/// Represents the types of the ASS types that can be manipulated. By combining them we can create +/// a tree of the ASS elements. We have: +/// ```ignore +/// - Lines +/// - content: +/// [0] := Line +/// ... ... ... +/// [n] := Line +/// - Line +/// - pos: AssPosition +/// - aux: HashMap<String, AssAuxValue> +/// - content: Syllabes +/// - Syllabes +/// - content: +/// [0] := Syllabe +/// ... ... ... +/// [n] := Syllabe +/// - Syllabe: +/// - pos: AssPosition +/// - aux: HashMap<String, AssAuxValue> +/// - content: String +/// ``` +/// +/// We also derive the Ord/PartialOrd crates. The types are ordered from the Lines to the Syllabe. +/// Thus if a type is greater than another one, the former must contains the latter. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Serialize, + Deserialize, + EnumVariantCount, + EnumVariantIter, + EnumVariantFromStr, +)] +pub enum ASSType { + Lines = 0, + Line = 1, + Syllabes = 2, + Syllabe = 3, +} + +impl ASSType { + /// Get the name of the ASS type. + pub fn as_str(&self) -> &'static str { + match self { + ASSType::Lines => "lines", + ASSType::Line => "line", + ASSType::Syllabes => "syllabes", + ASSType::Syllabe => "syllabe", + } + } + + /// Get the name of the type, but padded with spaces. + pub fn as_padded_str(&self) -> &'static str { + match self { + ASSType::Lines => "lines ", + ASSType::Line => "line ", + ASSType::Syllabes => "syllabes", + ASSType::Syllabe => "syllabe ", + } + } + + /// Returns the base type. + pub fn base_type(&self) -> Self { + match self { + ASSType::Lines | ASSType::Line => ASSType::Line, + ASSType::Syllabes | ASSType::Syllabe => ASSType::Syllabe, + } + } + + /// Returns the vec type. + pub fn vec_type(&self) -> Self { + match self { + ASSType::Lines | ASSType::Line => ASSType::Lines, + ASSType::Syllabes | ASSType::Syllabe => ASSType::Syllabes, + } + } +} + +impl AsRef<str> for ASSType { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl std::fmt::Display for ASSType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} diff --git a/src/Rust/vvs_ass/src/values.rs b/src/Rust/vvs_ass/src/values.rs new file mode 100644 index 0000000000000000000000000000000000000000..32a1681984392632070e14ada2b58787f6570096 --- /dev/null +++ b/src/Rust/vvs_ass/src/values.rs @@ -0,0 +1,200 @@ +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, convert::TryFrom}; + +/// The values that can be added to an ASS element. +#[derive(Clone, PartialEq, Serialize, Deserialize)] +pub enum ASSAuxValue { + Integer(i64), + Floating(f64), + Boolean(bool), + String(String), +} + +/// The auxiliary table of user values associated to ASS elements. +#[derive(Debug, Default, Clone, PartialEq)] +pub struct ASSAuxTable(HashMap<String, ASSAuxValue>); + +impl std::fmt::Debug for ASSAuxValue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Integer(arg0) => write!(f, "{arg0}"), + Self::Floating(arg0) => write!(f, "{arg0}"), + Self::String(arg0) => write!(f, "{arg0:?}"), + Self::Boolean(arg0) => write!(f, "{arg0}"), + } + } +} + +impl ASSAuxValue { + pub fn type_str(&self) -> &'static str { + match self { + ASSAuxValue::Floating(_) => "floating", + ASSAuxValue::Integer(_) => "integer", + ASSAuxValue::Boolean(_) => "boolean", + ASSAuxValue::String(_) => "string", + } + } + + pub fn coerce_like(self, like: &ASSAuxValue) -> Option<ASSAuxValue> { + use ASSAuxValue::*; + match (&self, like) { + (Floating(_), Floating(_)) + | (Integer(_), Integer(_)) + | (Boolean(_), Boolean(_)) + | (String(_), String(_)) => Some(self), + + (Integer(v), Floating(_)) => Some(Floating(i32::try_from(*v).ok()? as f64)), + (Integer(0), Boolean(_)) => Some(Boolean(false)), + (Integer(_), Boolean(_)) => Some(Boolean(true)), + + (Boolean(v), String(_)) => Some(String(format!("{v}"))), + (Integer(v), String(_)) => Some(String(format!("{v}"))), + (Floating(v), String(_)) => Some(String(format!("{v}"))), + + (Boolean(v), Integer(_)) => Some(Integer(*v as i64)), + + (String(_), Integer(_)) => todo!(), + (String(_), Floating(_)) => todo!(), + (String(_), Boolean(_)) => todo!(), + + (Floating(_), Integer(_)) | (Floating(_), Boolean(_)) | (Boolean(_), Floating(_)) => { + log::error!(target: "lua", "invalid convertion from type `{}` to `{}`", self.type_str(), like.type_str()); + None + } + } + } +} + +impl From<String> for ASSAuxValue { + fn from(value: String) -> Self { + Self::String(value) + } +} + +impl From<&str> for ASSAuxValue { + fn from(value: &str) -> Self { + Self::String(value.to_string()) + } +} + +impl From<bool> for ASSAuxValue { + fn from(value: bool) -> Self { + Self::Boolean(value) + } +} + +impl From<f32> for ASSAuxValue { + fn from(value: f32) -> Self { + Self::Floating(value as f64) + } +} + +impl From<f64> for ASSAuxValue { + fn from(value: f64) -> Self { + Self::Floating(value) + } +} + +impl From<i64> for ASSAuxValue { + fn from(value: i64) -> Self { + Self::Integer(value) + } +} + +impl From<i32> for ASSAuxValue { + fn from(value: i32) -> Self { + Self::Integer(value as i64) + } +} + +impl From<u32> for ASSAuxValue { + fn from(value: u32) -> Self { + Self::Integer(value as i64) + } +} + +impl From<i16> for ASSAuxValue { + fn from(value: i16) -> Self { + Self::Integer(value as i64) + } +} + +impl From<u16> for ASSAuxValue { + fn from(value: u16) -> Self { + Self::Integer(value as i64) + } +} + +impl From<i8> for ASSAuxValue { + fn from(value: i8) -> Self { + Self::Integer(value as i64) + } +} + +impl From<u8> for ASSAuxValue { + fn from(value: u8) -> Self { + Self::Integer(value as i64) + } +} + +impl std::fmt::Display for ASSAuxValue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ASSAuxValue::Floating(val) => write!(f, "{val}"), + ASSAuxValue::Integer(val) => write!(f, "{val}"), + ASSAuxValue::Boolean(val) => write!(f, "{val}"), + ASSAuxValue::String(val) => f.write_str(val), + } + } +} + +impl ASSAuxTable { + pub fn new() -> Self { + Default::default() + } + + pub fn set(&mut self, name: impl AsRef<str>, value: ASSAuxValue) { + let name = name.as_ref(); + let new = value.type_str(); + match self.0.get_mut(name) { + Some(old) => match value.coerce_like(old) { + Some(new) => *old = new, + None => log::error!( + target: "lua", + "can't set new value for `{name}`, old value was of type `{}` and new one is of type `{new}`", + old.type_str() + ), + }, + None => { + let _ = self.0.insert(name.to_string(), value); + } + } + } + + pub fn get_copy(&self, name: impl AsRef<str>) -> Option<ASSAuxValue> { + self.0.get(name.as_ref()).cloned() + } + + pub fn get(&self, name: impl AsRef<str>) -> Option<&ASSAuxValue> { + self.0.get(name.as_ref()) + } + + pub fn get_mut(&mut self, name: impl AsRef<str>) -> Option<&mut ASSAuxValue> { + self.0.get_mut(name.as_ref()) + } +} + +impl IntoIterator for ASSAuxTable { + type Item = <HashMap<String, ASSAuxValue> as IntoIterator>::Item; + type IntoIter = <HashMap<String, ASSAuxValue> as IntoIterator>::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +impl FromIterator<(String, ASSAuxValue)> for ASSAuxTable { + fn from_iter<T: IntoIterator<Item = (String, ASSAuxValue)>>(iter: T) -> Self { + Self(HashMap::from_iter(iter)) + } +} diff --git a/src/Rust/vvs_ass/utils/empty.ass b/src/Rust/vvs_ass/utils/empty.ass new file mode 100644 index 0000000000000000000000000000000000000000..d57acc0be6a70fc16956f02bcf4f98282cf0aef6 --- /dev/null +++ b/src/Rust/vvs_ass/utils/empty.ass @@ -0,0 +1,19 @@ +[Script Info] +; Script generated by Aegisub 3.3.3 +; http://www.aegisub.org/ +Title: Default Aegisub file +ScriptType: v4.00+ +WrapStyle: 0 +ScaledBorderAndShadow: yes +YCbCr Matrix: None + +[Aegisub Project Garbage] + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,48,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,, + diff --git a/src/Rust/vvs_cli/Cargo.toml b/src/Rust/vvs_cli/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..004cbf2662f52157f3948e890c96e9a6f7a4e83f --- /dev/null +++ b/src/Rust/vvs_cli/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "vvs_cli" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +description = "The CLI for VVS (VVCC)" + +[[bin]] +name = "vvcc" +path = "src/main.rs" + +[dependencies] +vvs_font = { path = "../vvs_font" } +vvs_utils = { path = "../vvs_utils" } + +thiserror.workspace = true +anyhow.workspace = true +serde.workspace = true +toml.workspace = true +log.workspace = true + +clap_mangen = "^0.2" +clap_complete = "^4" +clap = { version = "^4", default-features = false, features = [ + "usage", + "help", + "std", + "suggestions", + "error-context", + "derive", + "wrap_help", +] } + +[target.'cfg(unix)'.dependencies] +# vvs_repl = { path = "../vvs_repl" } diff --git a/src/Rust/vvs_cli/src/args.rs b/src/Rust/vvs_cli/src/args.rs new file mode 100644 index 0000000000000000000000000000000000000000..1c1d6343e8644ad1147bb0660d34fac8fefd0911 --- /dev/null +++ b/src/Rust/vvs_cli/src/args.rs @@ -0,0 +1,140 @@ +use crate::parser::FileTypeValueParser; +use clap::Parser; +use clap_complete::Shell; +use std::path::PathBuf; +use vvs_utils::*; + +fn get_cli_groups() -> impl IntoIterator<Item = clap::ArgGroup> { + #[cfg(unix)] + fn is_unix_target() -> bool { + true + } + #[cfg(not(unix))] + fn is_unix_target() -> bool { + false + } + + use clap::ArgGroup as grp; + [ + grp::new("action").args(["manpage", "shell", "font-file", "script.vvs"]), + grp::new("ass") + .args(["subtitle.ass"]) + .conflicts_with_all(["manpage", "font-file"]), + grp::new("opts") + .args(["options.toml"]) + .conflicts_with_all(["shell", "manpage", "font-file"]), + grp::new("infos") + .args(["info"]) + .conflicts_with_all(["shell", "manpage", "font-file"]), + ] + .into_iter() + .chain(either!(!is_unix_target() => None; Some( + clap::ArgGroup::new("repl") .args(["interactive"]) .conflicts_with_all(["shell", "manpage", "font-file"]) + ))) +} + +#[derive(Parser, Debug)] +#[command( author + , version + , about + , name = "vvcc" + , groups = get_cli_groups() +)] +pub struct Args { + /// The script to run. + /// + /// A script must not touch to fields that begins by underscores, never. Those fields are + /// reserved for vivy or for library developpers. Note that even library developpers must not + /// touch or call fields from vivy that begins by underscores. + #[arg( action = clap::ArgAction::Set + , id = "script.vvs" + , value_parser = FileTypeValueParser::new("vvs") + )] + pub script: Option<PathBuf>, + + /// The input ASS file to execute the script on. + /// + /// The supported subtitle extensions are the ASS files (V4+) or the Json files exported by the + /// Vivy application. + #[arg ( short = 'f' + , long = "subtitle" + , action = clap::ArgAction::Set + , id = "subtitle.ass" + , value_parser = FileTypeValueParser::new("ass"), + )] + pub ass_file: Option<PathBuf>, + + /// The option file that will be used when running the script. + #[arg( short = 't' + , long = "option" + , action = clap::ArgAction::Set + , id = "options.toml" + , value_parser = FileTypeValueParser::new("toml") + )] + pub options: Option<PathBuf>, + + /// The includes' folder list, in order. + /// + /// Will search for modules to import in those folders. Note that there is no need to add the + /// folder of the loaded script as it will automatically be added in the top position of the + /// include list. + #[arg( short = 'I' + , long = "include-path" + , action = clap::ArgAction::Append + , id = "include" + )] + pub include_folders: Vec<PathBuf>, + + /// Launch vvcc REPL after loading the passed script if any. + /// + /// In REPL mode you must not touch to fields that begins by underscores, never. Those fields + /// are reserved for vivy or for library developpers. Note that even library developpers must + /// not touch or call fields from vivy that begins by underscores. + #[cfg(unix)] + #[arg( short = 'i' + , action = clap::ArgAction::SetTrue + )] + pub interactive: bool, + + /// Shows informations about a script. + /// + /// The informations consists of the loaded modules, all the options, their possible values, + /// the order of the jobs, etc. + /// + /// When printing informations about a script you won't be able to run it. You can however + /// print the informations about a script then enter in iteractive mode. + #[arg( short = 'P' + , long = "info" + , action = clap::ArgAction::SetTrue + )] + pub info: bool, + + /// Display infos about embeded fonts or external fonts. + #[arg( long = "font-info" + , action = clap::ArgAction::Set + , id = "font-file" + )] + pub font_info: Option<Option<PathBuf>>, + + /// Make vvcc more verbose, repeat to make it even more verbose + #[arg( long + , short = 'v' + , action = clap::ArgAction::Count + )] + pub verbose: u8, + + /// Generate the manpage for vvcc and print it out to stdout + /// + /// You may read it by running `vvcc --manpage | man -l -` or writing it in the + /// `$HOME/.local/share/man/man1/` or `/usr/share/man/man1` folder + #[arg( long = "manpage" + , action = clap::ArgAction::SetTrue + )] + pub manpage: bool, + + /// Generate completion script for the given shell + #[arg( long = "shell" + , action = clap::ArgAction::Set + )] + pub shell: Option<Shell>, +} diff --git a/src/Rust/vvs_cli/src/config.rs b/src/Rust/vvs_cli/src/config.rs new file mode 100644 index 0000000000000000000000000000000000000000..38b4d3040d0a292ffbb13e44292f53a289b18d95 --- /dev/null +++ b/src/Rust/vvs_cli/src/config.rs @@ -0,0 +1,115 @@ +use crate::args::Args; +use clap_complete::Shell; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use vvs_utils::*; + +#[derive(Debug, Serialize, Deserialize)] +pub struct ConfigKaraMaker { + #[serde(rename = "name")] + kara_maker: String, + + email: Option<String>, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct ConfigFile { + #[serde(rename = "includes")] + include_folders: Vec<PathBuf>, + + karamaker: ConfigKaraMaker, +} + +#[derive(Debug, Default)] +pub struct Config { + // From config::ConfigFile + pub kara_maker: String, + + // From args::Args + pub script: Option<PathBuf>, + pub ass_file: Option<PathBuf>, + pub options: Option<PathBuf>, + pub interactive: bool, + pub info: bool, + pub manpage: bool, + pub shell: Option<Shell>, + pub font_info: Option<Option<PathBuf>>, + pub verbose: u8, + + // Merge of the two + pub include_folders: Vec<PathBuf>, +} + +macro_rules! merge { + ($($what: ident $src: expr => $dest: expr, { $( $field: ident ),+ });+ $(;)?) => {{ + $(merge! { @@internal $what $( $field ),+ : $src => $dest });+; + }}; + + (@@internal override $( $field: ident ),+ : $src: expr => $dest: expr) => {{$( $dest.$field = $src.$field; )+}}; + (@@internal flg_or $( $field: ident ),+ : $src: expr => $dest: expr) => {{$( $dest.$field |= $src.$field; )+}}; + (@@internal flg_and $( $field: ident ),+ : $src: expr => $dest: expr) => {{$( $dest.$field &= $src.$field; )+}}; + + (@@internal append $( $field: ident ),+ : $src: expr => $dest: expr) => {{$( $dest.$field.extend($src.$field); )+}}; + + (@@internal max $( $field: ident ),+ : $src: expr => $dest: expr) => {{$( $dest.$field = max_partial!($src.$field, $dest.$field); )+}}; + (@@internal min $( $field: ident ),+ : $src: expr => $dest: expr) => {{$( $dest.$field = min_partial!($src.$field, $dest.$field); )+}}; + + (@@internal set_if_not $( $field: ident),+ : $src: expr => $dest: expr) => {{$( + if $dest.$field.is_none() { + $dest.$field = $src.$field; + } + )+}}; +} + +impl Extend<ConfigFile> for ConfigFile { + fn extend<T: IntoIterator<Item = ConfigFile>>(&mut self, iter: T) { + iter.into_iter().for_each(|other| { + let (km, self_km) = (other.karamaker, &mut self.karamaker); + merge! { + append other => self, { include_folders }; + override km => self_km, { kara_maker }; + } + }); + } +} + +impl Extend<ConfigFile> for Config { + fn extend<T: IntoIterator<Item = ConfigFile>>(&mut self, iter: T) { + iter.into_iter().for_each(|cfg| { + let km = cfg.karamaker; + merge! { + append cfg => self, { include_folders }; + override km => self, { kara_maker }; + } + }); + } +} + +impl Extend<Args> for Config { + fn extend<T: IntoIterator<Item = Args>>(&mut self, iter: T) { + iter.into_iter().for_each(|args| { + merge! { + append args => self, { include_folders }; + set_if_not args => self, { script, ass_file, options, shell, font_info }; + max args => self, { verbose }; + override args => self, { info, manpage, interactive }; + } + }); + } +} + +impl ConfigFile { + pub fn serialize(&self) -> Result<String, Box<dyn std::error::Error>> { + Ok(toml::to_string_pretty(self).map_err(Box::new)?) + } + + pub fn deserialize(input: String) -> Result<Self, Box<dyn std::error::Error>> { + Ok(toml::from_str(&input).map_err(Box::new)?) + } +} + +impl Default for ConfigKaraMaker { + fn default() -> Self { + Self { kara_maker: "Viieux".to_string(), email: Default::default() } + } +} diff --git a/src/Rust/vvs_cli/src/lib.rs b/src/Rust/vvs_cli/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..2c31f840f0a5cd4c6123589fe9f2dbfc68920392 --- /dev/null +++ b/src/Rust/vvs_cli/src/lib.rs @@ -0,0 +1,7 @@ +#![forbid(unsafe_code)] + +pub mod args; +pub mod config; +pub mod logger; + +mod parser; diff --git a/src/Rust/vvs_cli/src/logger.rs b/src/Rust/vvs_cli/src/logger.rs new file mode 100644 index 0000000000000000000000000000000000000000..0a9dcdd60d59600dacaa39164fcb3f8c8070bdc7 --- /dev/null +++ b/src/Rust/vvs_cli/src/logger.rs @@ -0,0 +1,112 @@ +use log::{Level, Metadata, Record, SetLoggerError}; +use std::sync::{atomic::AtomicU8, OnceLock}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub struct LoggerInitError(SetLoggerError); + +#[derive(Debug, Default)] +struct SimpleLogger { + level: AtomicU8, +} + +#[repr(transparent)] +#[derive(Debug, Default)] +struct SimpleLoggerRef { + inner: SimpleLogger, +} + +static LOGGER: OnceLock<SimpleLoggerRef> = OnceLock::new(); + +fn logger() -> &'static SimpleLogger { + &LOGGER.get().unwrap().inner +} + +impl std::fmt::Display for LoggerInitError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl SimpleLogger { + fn write_str<S: AsRef<str>>(level: char, prefix: String, target: &str, content: S) { + let prefix = if target.is_empty() { + format!("{level} [{prefix}]") + } else if prefix.is_empty() { + format!("{level} [{target}] ") + } else { + format!("{level} [{prefix} {target}] ") + }; + let mut content = content.as_ref().lines().filter(|content| !content.trim().is_empty()); + if let Some(content) = content.next() { + eprintln!("{prefix}{content}"); + } + content.for_each(|content| eprintln!("{level} {content}")); + } + + fn level(&self) -> Level { + // Always display errors and warnings. + match self.level.load(std::sync::atomic::Ordering::SeqCst) { + 0 => Level::Warn, + 1 => Level::Info, + 2 => Level::Debug, + _ => Level::Trace, + } + } +} + +impl log::Log for SimpleLogger { + fn enabled(&self, metadata: &Metadata) -> bool { + metadata.level() <= self.level() + } + + fn log(&self, record: &Record) { + if self.enabled(record.metadata()) { + let level = match record.level() { + Level::Error => '!', + Level::Warn => '*', + Level::Info => '#', + Level::Debug => '.', + Level::Trace => ' ', + }; + let prefix = match (record.file(), record.line()) { + (None, None) => "".to_string(), + (None, Some(line)) => format!("...+{line}"), + (Some(file), None) => format!("{file}+..."), + (Some(file), Some(line)) => format!("{file}+{line}"), + }; + if let Some(s) = record.args().as_str() { + SimpleLogger::write_str(level, prefix, record.target(), s) + } else { + SimpleLogger::write_str(level, prefix, record.target(), record.args().to_string()); + } + } + } + + fn flush(&self) {} +} + +pub fn level(lvl: u8) { + logger().level.store(lvl, std::sync::atomic::Ordering::SeqCst); + log::set_max_level(LOGGER.get().unwrap().inner.level().to_level_filter()); +} + +pub fn init(level: Option<Level>) -> Result<(), LoggerInitError> { + LOGGER.set(Default::default()).expect("failed to set default logger..."); + log::set_logger(logger()) + .map(|()| { + log::set_max_level(match level { + None => logger().level().to_level_filter(), + Some(level) => { + match level { + Level::Trace => self::level(3), + Level::Debug => self::level(2), + Level::Info => self::level(1), + Level::Warn | Level::Error => self::level(0), + }; + level.to_level_filter() + } + }); + }) + .map_err(LoggerInitError) +} diff --git a/src/Rust/vvs_cli/src/main.rs b/src/Rust/vvs_cli/src/main.rs new file mode 100644 index 0000000000000000000000000000000000000000..da51848fb4528aff924b7a16f7f1495181a3bbb1 --- /dev/null +++ b/src/Rust/vvs_cli/src/main.rs @@ -0,0 +1,99 @@ +//! The VivyScript cli +#![forbid(unsafe_code)] + +use anyhow::{Context, Result}; +use vvs_cli::{ + args, + config::{Config, ConfigFile}, + logger, +}; +use vvs_utils::xdg::*; + +fn print_face_info(name: &str, font: &[u8]) -> Result<()> { + let font = vvs_font::Font::try_from(font).with_context(|| format!("failed to parse font {name}"))?; + + let font_types = font + .font_types() + .into_iter() + .map(|ty| format!("{ty:?}")) + .collect::<Vec<_>>() + .join(", "); + + println!("###"); + println!( + "# Family Name: {}", + font.name().with_context(|| "failed to get the font name")? + ); + println!("# Name(s): {}", font.family_names().join(", ")); + println!("# Font Type(s): {font_types}"); + println!("# Number of glyph(s): {}", font.number_of_glyphs()); + + Ok(()) +} + +fn main() -> Result<()> { + logger::init(None).map_err(Box::new)?; + + let mut config = Config::default(); + config.extend([ + XDGConfig::<ConfigFile, XDGConfigMergedSilent>::new("vvcc", ConfigFile::deserialize) + .file("vvcc.toml") + .read_or_default(ConfigFile::serialize), + ]); + config.extend([<args::Args as clap::Parser>::parse()]); + let Config { + script, + ass_file: _, + options: _, + #[cfg(unix)] + interactive, + info: _, + manpage, + shell, + font_info, + verbose, + include_folders: _, + .. + } = config; + #[cfg(not(unix))] + let interactive = false; + logger::level(verbose); + + if manpage { + log::debug!(target: "vvcc", "generate the manpage for vvcc"); + use clap::CommandFactory; + return clap_mangen::Man::new(args::Args::command()) + .render(&mut std::io::stdout()) + .with_context(|| "failed to render manpage for vvcc"); + } else if let Some(shell) = shell { + log::debug!(target: "vvcc", "generate shell completion script for shell {shell}"); + use clap::CommandFactory; + let mut command = args::Args::command(); + let command_name = command.get_name().to_string(); + clap_complete::generate(shell, &mut command, command_name, &mut std::io::stdout()); + return Ok(()); + } else if let Some(font_info) = font_info { + match font_info { + Some(path) => { + let font = + std::fs::read(&path).with_context(|| format!("failed to read font: {}", path.to_string_lossy()))?; + print_face_info(&path.to_string_lossy(), &font)?; + } + None => { + for (name, font) in vvs_font::embeded_fonts() { + print_face_info(name, font)?; + } + } + } + return Ok(()); + } + + if script.is_none() && !interactive { + use clap::CommandFactory; + return args::Args::command() + .print_help() + .with_context(|| "failed to print help message for vvcc"); + } + + Ok(()) +} diff --git a/src/Rust/vvs_cli/src/parser.rs b/src/Rust/vvs_cli/src/parser.rs new file mode 100644 index 0000000000000000000000000000000000000000..21f29bfd2c7dfa81092cf289294bf4102ed88079 --- /dev/null +++ b/src/Rust/vvs_cli/src/parser.rs @@ -0,0 +1,62 @@ +use clap::{ + builder::TypedValueParser, + error::{ContextKind, ContextValue, ErrorKind}, +}; +use std::path::PathBuf; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct FileTypeValueParser { + extension: &'static str, +} + +impl FileTypeValueParser { + pub fn new(extension: &'static str) -> Self { + Self { extension } + } +} + +impl TypedValueParser for FileTypeValueParser { + type Value = PathBuf; + + fn parse_ref( + &self, + cmd: &clap::Command, + arg: Option<&clap::Arg>, + value: &std::ffi::OsStr, + ) -> Result<Self::Value, clap::Error> { + let mut err = clap::Error::new(ErrorKind::ValueValidation).with_cmd(cmd); + if let Some(arg) = arg { + err.insert(ContextKind::InvalidArg, ContextValue::String(arg.to_string())); + } + match value.to_ascii_lowercase().to_str() { + Some(value) => match value.trim().split('.').last() { + Some(file_ext) => (file_ext == self.extension) + .then_some(PathBuf::from(value)) + .ok_or_else(|| { + err.insert( + ContextKind::InvalidValue, + ContextValue::String(format!("invalid extension {file_ext}, expected {}", self.extension)), + ); + err + }), + None => { + err.insert( + ContextKind::InvalidValue, + ContextValue::String(format!( + "file has no extension, expected extension {}", + self.extension + )), + ); + Err(err) + } + }, + None => { + err.insert( + ContextKind::InvalidValue, + ContextValue::String("invalid utf8 string".to_string()), + ); + Err(err) + } + } + } +} diff --git a/src/Rust/vvs_font/Cargo.toml b/src/Rust/vvs_font/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..838aa4cfe8f2a0c17b03ec1372ebe818ccbc9cf3 --- /dev/null +++ b/src/Rust/vvs_font/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "vvs_font" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +description = "The font crate for VVS" + +[dependencies] +thiserror.workspace = true +log.workspace = true + +ttf-parser = { version = "^0.19" } +ab_glyph = { version = "^0.2.20" } diff --git a/src/Rust/vvs_font/build.rs b/src/Rust/vvs_font/build.rs new file mode 100644 index 0000000000000000000000000000000000000000..aaf4757c9f391e9d11974ea1631cb48a35bbaf43 --- /dev/null +++ b/src/Rust/vvs_font/build.rs @@ -0,0 +1,56 @@ +use std::{env, fs, path::Path}; + +fn rerun_directory<T: AsRef<Path> + ?Sized>(dir: &T) { + println!("cargo:rerun-if-changed={}", dir.as_ref().to_string_lossy()); + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.expect("Couldn't access file in src directory").path(); + if path.is_dir() { + rerun_directory(&path); + } + } +} + +fn main() { + let out_dir = Path::new(&env::var_os("OUT_DIR").expect("no OUT_DIR env variable...")) + .canonicalize() + .expect("failed to canonicalize OUT_DIR"); + let font_dir = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("manifest folder should have a parent") + .join("vvs_font/fonts") + .canonicalize() + .expect("failed to canonicalize the font folder"); + + let fonts = fs::read_dir(&font_dir) + .expect("failed to read the font folder") + .filter_map(Result::ok) + .filter(|file| { + file.file_type().map(|ft| ft.is_file()).unwrap_or_default() + && file.path().extension().map(|e| e == "ttf").unwrap_or(false) + }) + .map(|file| { + let (path, file_name) = (file.path(), file.file_name()); + let path = path.to_string_lossy(); + let name = file_name.to_string_lossy(); + let name = name.rsplit_once('.').unwrap().0; + format!("({name:?}, include_bytes!({path:?}))") + }) + .collect::<Vec<_>>() + .join(",\n"); + + // Generate + let src_content = format!( + r#" +pub const fn embeded_fonts() -> &'static [(&'static str, &'static [u8])] {{ + &[ {fonts} ] +}} + "# + ); + + // Write + fs::write(out_dir.join("generated_font_utils.rs"), src_content).expect("failed to write generated source file"); + + // Rerun + rerun_directory(&font_dir); + println!("cargo:rerun-if-changed=build.rs"); +} diff --git a/src/Rust/vvs_font/fonts/NotoSans-Bold.ttf b/src/Rust/vvs_font/fonts/NotoSans-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..3e68bc24167c9d26e2c982807c8b6f1e6c8047fc Binary files /dev/null and b/src/Rust/vvs_font/fonts/NotoSans-Bold.ttf differ diff --git a/src/Rust/vvs_font/fonts/NotoSans-BoldItalic.ttf b/src/Rust/vvs_font/fonts/NotoSans-BoldItalic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..4b5635171654dfda7d30b856357394aad5084ea9 Binary files /dev/null and b/src/Rust/vvs_font/fonts/NotoSans-BoldItalic.ttf differ diff --git a/src/Rust/vvs_font/fonts/NotoSans-Italic.ttf b/src/Rust/vvs_font/fonts/NotoSans-Italic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..eedc5e4593d3eef68109d35823560d98031a7e83 Binary files /dev/null and b/src/Rust/vvs_font/fonts/NotoSans-Italic.ttf differ diff --git a/src/Rust/vvs_font/fonts/NotoSans-LICENCE-OFL.txt b/src/Rust/vvs_font/fonts/NotoSans-LICENCE-OFL.txt new file mode 100644 index 0000000000000000000000000000000000000000..90b733268379b957b2aa1990f4e3795ebe55a148 --- /dev/null +++ b/src/Rust/vvs_font/fonts/NotoSans-LICENCE-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2015-2021 Google LLC. All Rights Reserved. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/Rust/vvs_font/fonts/NotoSans-Regular.ttf b/src/Rust/vvs_font/fonts/NotoSans-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..973bc2ed3a92372004f5bc0c3fe85092c75624e0 Binary files /dev/null and b/src/Rust/vvs_font/fonts/NotoSans-Regular.ttf differ diff --git a/src/Rust/vvs_font/src/error.rs b/src/Rust/vvs_font/src/error.rs new file mode 100644 index 0000000000000000000000000000000000000000..00d4c894cf293e8b0f1b97540596a9975d526ba7 --- /dev/null +++ b/src/Rust/vvs_font/src/error.rs @@ -0,0 +1,22 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum FontCreationError { + #[error("ab_glyph error: {0}")] + ABGlyphError(ab_glyph::InvalidFont), + + #[error("ttf-parser error: {0}")] + TTFParserError(ttf_parser::FaceParsingError), +} + +#[derive(Debug, Error)] +pub enum FontError { + #[error("font has no name")] + NoName, + + #[error("failed to outline glyph '{1}' for size {0}pt")] + FailedToOutline(f64, char), + + #[error("can't outline an empty string")] + EmptyStringToOutline, +} diff --git a/src/Rust/vvs_font/src/font.rs b/src/Rust/vvs_font/src/font.rs new file mode 100644 index 0000000000000000000000000000000000000000..5fc71f228ac230895c6240235bd83a53f671dae2 --- /dev/null +++ b/src/Rust/vvs_font/src/font.rs @@ -0,0 +1,120 @@ +use crate::{error::*, *}; +use ab_glyph::Font as _; + +/// The different types of fonts. To be sure of what you are doing, please use only regular +/// fonts, behaviour of non regular fonts may change depending on the platform if the italic or +/// bold tag is set in the ASS Style. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum FontType { + Regular, + Italic, + Bold, + Oblique, + Monospaced, + Variable, +} + +/// Struct used to store informations we need about a font. +#[derive(Debug)] +pub struct Font<'a> { + font: ab_glyph::FontRef<'a>, + face: ttf_parser::Face<'a>, +} + +impl<'a> TryFrom<&'a [u8]> for Font<'a> { + type Error = FontCreationError; + + fn try_from(data: &'a [u8]) -> Result<Self, Self::Error> { + Ok(Self { + face: ttf_parser::Face::parse(data, 0).map_err(FontCreationError::TTFParserError)?, + font: ab_glyph::FontRef::try_from_slice(data).map_err(FontCreationError::ABGlyphError)?, + }) + } +} + +impl<'a> Font<'a> { + /// Get the general name of the font. + pub fn name(&self) -> Result<String, FontError> { + self.face + .names() + .into_iter() + .find(|name| name.name_id == ttf_parser::name_id::FAMILY && name.is_unicode()) + .and_then(|name| name.to_string()) + .ok_or(FontError::NoName) + } + + /// Get the family names. + pub fn family_names(&self) -> Vec<String> { + let names = self.face.names(); + let filter = [ttf_parser::name_id::POST_SCRIPT_NAME, ttf_parser::name_id::FULL_NAME]; + names + .into_iter() + .flat_map(|name| (filter.contains(&name.name_id) && name.is_unicode()).then(|| name.to_string())) + .flatten() + .collect() + } + + /// The the font types. + pub fn font_types(&self) -> Vec<FontType> { + use FontType::*; + let mut ret = [ + self.face.is_regular().then_some(Regular), + self.face.is_italic().then_some(Italic), + self.face.is_bold().then_some(Bold), + self.face.is_oblique().then_some(Oblique), + self.face.is_monospaced().then_some(Monospaced), + self.face.is_variable().then_some(Variable), + ] + .into_iter() + .flatten() + .chain(Some(match self.face.style() { + ttf_parser::Style::Normal => Regular, + ttf_parser::Style::Italic => Italic, + ttf_parser::Style::Oblique => Oblique, + })) + .collect::<Vec<_>>(); + ret.sort(); + ret.dedup(); + ret + } + + /// Get the number of glyphs in the font. + pub fn number_of_glyphs(&self) -> i64 { + self.face.number_of_glyphs() as i64 + } + + /// Outline a glyph with the specified pt size. + pub fn outline_glyph(&self, pt: f64, glyph: char) -> Result<Rect, FontError> { + self.font + .outline_glyph( + self.font.glyph_id(glyph).with_scale( + self.font + .pt_to_px_scale(f64::clamp(pt, f32::MIN.into(), f32::MAX.into()) as f32) + .expect("failed to get the px_scale..."), + ), + ) + .map(|outlined| { + let ab_glyph::Rect { min, max } = outlined.px_bounds(); + Rect::new( + Point { x: min.x.trunc() as i64, y: min.y.trunc() as i64 }, + Point { x: max.x.trunc() as i64, y: max.y.trunc() as i64 }, + ) + }) + .ok_or(FontError::FailedToOutline(pt, glyph)) + } + + /// Outline a string slice with the specified pt size. + pub fn outline_str(&self, pt: f64, str: impl AsRef<str>) -> Result<Rect, FontError> { + let (rects, errs): (Vec<_>, Vec<_>) = str + .as_ref() + .chars() + .map(|glyph| self.outline_glyph(pt, glyph)) + .partition(Result::is_ok); + if let Some(err) = errs.into_iter().next() { + return err; + } + let mut rects = rects.into_iter().map(Result::unwrap); + let first = rects.next().ok_or(FontError::EmptyStringToOutline)?; + Ok(rects.fold(first, Rect::merge)) + } +} diff --git a/src/Rust/vvs_font/src/lib.rs b/src/Rust/vvs_font/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..b631d37b7da57af932a519d7340716d70039ebc7 --- /dev/null +++ b/src/Rust/vvs_font/src/lib.rs @@ -0,0 +1,10 @@ +#![forbid(unsafe_code)] + +mod error; +mod font; +mod rect; + +pub use font::*; +pub use rect::*; + +include!(concat!(env!("OUT_DIR"), "/generated_font_utils.rs")); diff --git a/src/Rust/vvs_font/src/rect.rs b/src/Rust/vvs_font/src/rect.rs new file mode 100644 index 0000000000000000000000000000000000000000..86454e17cb599bf0cf843fe1f1517a907c94b661 --- /dev/null +++ b/src/Rust/vvs_font/src/rect.rs @@ -0,0 +1,55 @@ +use std::cmp::{max, min}; + +/// Describes coordinates in the plan, the origin is the top left corner of the screen. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Point { + pub x: i64, + pub y: i64, +} + +/// Describes an area in the plan, the origin is the top left corner of the screen. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Rect { + top_left_corner: Point, + bottom_right_corner: Point, +} + +impl Rect { + /// Create a correctly formed [Rect] that includes the passed [Point]. + pub fn new(p1: Point, p2: Point) -> Self { + Self { + top_left_corner: Point::min(p1, p2), + bottom_right_corner: Point::max(p1, p2), + } + } + + /// Returns a new [Rect] that includes the passed rectangles. + pub fn merge(self, other: Self) -> Self { + Self { + top_left_corner: Point::min(self.top_left_corner, other.top_left_corner), + bottom_right_corner: Point::min(self.bottom_right_corner, other.bottom_right_corner), + } + } + + /// Get the Top Left corner, the min coordinates. + pub fn tl_corner(&self) -> Point { + self.top_left_corner + } + + /// Get the Bottom Right corner, the max coordinates. + pub fn br_corner(&self) -> Point { + self.bottom_right_corner + } +} + +impl Point { + /// Returns the min components of the two [Point]. + pub fn min(p1: Point, p2: Point) -> Point { + Point { x: min(p1.x, p2.x), y: min(p1.y, p2.y) } + } + + /// Returns the max components of the two [Point]. + pub fn max(p1: Point, p2: Point) -> Point { + Point { x: max(p1.x, p2.x), y: max(p1.y, p2.y) } + } +} diff --git a/src/Rust/vvs_lang/Cargo.toml b/src/Rust/vvs_lang/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..0f83afca939061c62ded0bdd6306a3013c96c280 --- /dev/null +++ b/src/Rust/vvs_lang/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "vvs_lang" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +description = "Vivy Script Language" + +[dependencies] +thiserror.workspace = true +serde.workspace = true +hashbrown.workspace = true +log.workspace = true +regex.workspace = true +nom.workspace = true +nom_locate.workspace = true +anyhow.workspace = true + +vvs_utils = { path = "../vvs_utils" } diff --git a/src/Rust/vvs_lang/VVL.g4 b/src/Rust/vvs_lang/VVL.g4 new file mode 100644 index 0000000000000000000000000000000000000000..b64d20b2b0dcc18061a71fc46e390d3e5232b42f --- /dev/null +++ b/src/Rust/vvs_lang/VVL.g4 @@ -0,0 +1,177 @@ +/* +BSD License + +Copyright (c) 2013, Kazunori Sakamoto +Copyright (c) 2016, Alexander Alexeev +Copyright (c) 2023, Maël Martin +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +3. Neither the NAME of Rainer Schuster nor the NAMEs of its contributors may be + used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ + +grammar VVL; + +chunk: topblock* EOF; +visibility: ('pub')?; +block: stat* laststat?; + +topblock: visibility 'function' NAME (':' NAME)? funcbody + | visibility 'job' NAME funcbody + | visibility 'const' NAME opttypespec '=' exp + | visibility 'option' NAME opttypespec '=' exp + | ('import' | 'requires') string + ; + +stat: ';' + | varlist '=' explist + | functioncall + | 'do' block 'end' + | 'while' exp 'do' block 'end' + | 'repeat' block 'until' exp + | 'if' exp 'then' block ('elseif' exp 'then' block)* ('else' block)? 'end' + | 'for' NAME '=' exp ',' exp (',' exp)? 'do' block 'end' + | 'for' namelist 'in' explist 'do' block 'end' + | 'let' attnamelist ('=' explist)? + ; + +attnamelist: NAME opttypespec (',' NAME opttypespec)*; +opttypespec: (':' NAME)?; +laststat: 'return' explist? | 'break' | 'continue' ';'?; +varlist: var (',' var)*; +namelist: NAME (',' NAME)*; +explist: (exp ',')* exp; + +exp: 'nil' | 'false' | 'true' + | number + | color + | string + | prefixexp + | tableconstructor + | <assoc=right> exp operatorPower exp + | operatorUnary exp + | exp operatorMulDivMod exp + | exp operatorAddSub exp + | <assoc=right> exp operatorStrcat exp + | exp operatorComparison exp + | exp operatorAnd exp + | exp operatorOr exp + | exp operatorBitwise exp + | '(' exp (',' exp)+ (',')? ')' + ; + +prefixexp: varOrExp nameAndArgs*; +functioncall: varOrExp nameAndArgs+; +varOrExp: var | '(' exp ')'; +var: (NAME | '(' exp ')' varSuffix) varSuffix*; +varSuffix: nameAndArgs* ('[' exp ']' | '.' NAME); +nameAndArgs: (':' NAME)? args; +args: '(' explist? ')' | tableconstructor | string; +funcbody: '(' parlist? ')' '->' NAME 'begin' block 'end'; +parlist: namelist (',')?; +tableconstructor: '{' fieldlist? '}'; +fieldlist: field (fieldsep field)* fieldsep?; +field: '[' exp ']' '=' exp | NAME '=' exp | exp; +fieldsep: ',' | ';'; + +operatorOr: 'or'; +operatorAnd: 'and'; +operatorComparison: '<' | '>' | '<=' | '>=' | '~=' | '==' | '!='; +operatorStrcat: '..'; +operatorAddSub: '+' | '-'; +operatorMulDivMod: '*' | '/' | '%' | '//' | 'mod'; +operatorBitwise: '&' | '^' | '|' | '~' | '<<' | '>>'; +operatorUnary: 'not' | '#' | '-' | '~'; +operatorPower: '^'; + +color: '#(' ... ')'; +number: INT | HEX | FLOAT | HEX_FLOAT; +string: NORMALSTRING | CHARSTRING | LONGSTRING; + +// LEXER + +NAME: [a-zA-Z_][a-zA-Z_0-9]*; +NORMALSTRING: '"' ( EscapeSequence | ~('\\'|'"') )* '"'; +CHARSTRING: '\'' ( EscapeSequence | ~('\''|'\\') )* '\''; +LONGSTRING: '[' NESTED_STR ']'; + +fragment +NESTED_STR: '=' NESTED_STR '=' + | '[' .*? ']' + ; + +INT: Digit+; +HEX: '0' [xX] HexDigit+; + +FLOAT: Digit+ '.' Digit* ExponentPart? + | '.' Digit+ ExponentPart? + | Digit+ ExponentPart + ; + +HEX_FLOAT + : '0' [xX] HexDigit+ '.' HexDigit* HexExponentPart? + | '0' [xX] '.' HexDigit+ HexExponentPart? + | '0' [xX] HexDigit+ HexExponentPart + ; + +fragment +ExponentPart + : [eE] [+-]? Digit+ + ; + +fragment +HexExponentPart + : [pP] [+-]? Digit+ + ; + +fragment +EscapeSequence + : '\\' '\r'? '\n' + ; + +fragment +Digit + : [0-9] + ; + +fragment +HexDigit + : [0-9a-fA-F] + ; + +fragment +SingleLineInputCharacter + : ~[\r\n\u0085\u2028\u2029] + ; + +COMMENT + : '--[' NESTED_STR ']' -> channel(HIDDEN) + ; + +LINE_COMMENT + : '--' SingleLineInputCharacter* -> channel(HIDDEN) + ; + +WS + : [ \t\u000C\r\n]+ -> skip + ; diff --git a/src/Rust/vvs_lang/VVS.g4 b/src/Rust/vvs_lang/VVS.g4 new file mode 100644 index 0000000000000000000000000000000000000000..4b82087b3cb3b37d35406813ee426dba2923883a --- /dev/null +++ b/src/Rust/vvs_lang/VVS.g4 @@ -0,0 +1,117 @@ +// MIT License +// +// Copyright 2023 Maël MARTIN +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +// associated documentation files (the "Software"), to deal in the Software without restriction, +// including without limitation the rights to use, copy, modify, merge, publish, distribute, +// sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT +// NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +grammar VVS; + +chunk: topblock* mainblock EOF; + +topblock: 'import' string + : 'set' NAME '.' NAME '=' exp + ; + +mainblock: + 'main' NAME '{' + (NAME '=' NAME '.' NAME NAME ';')* + + 'write' '{' (NAME (',' NAME)* (,)?)? '}' + '}' +; + +exp: 'nil' | 'false' | 'true' + | number + | color + | string + ; + +color: '#(' ... ')'; +number: INT | HEX | FLOAT | HEX_FLOAT; +string: NORMALSTRING | CHARSTRING | LONGSTRING; + +// LEXER + +NAME: [a-zA-Z_][a-zA-Z_0-9]*; +NORMALSTRING: '"' ( EscapeSequence | ~('\\'|'"') )* '"'; +CHARSTRING: '\'' ( EscapeSequence | ~('\''|'\\') )* '\''; +LONGSTRING: '[' NESTED_STR ']'; + +fragment +NESTED_STR: '=' NESTED_STR '=' + | '[' .*? ']' + ; + +INT: Digit+; +HEX: '0' [xX] HexDigit+; + +FLOAT: Digit+ '.' Digit* ExponentPart? + | '.' Digit+ ExponentPart? + | Digit+ ExponentPart + ; + +HEX_FLOAT + : '0' [xX] HexDigit+ '.' HexDigit* HexExponentPart? + | '0' [xX] '.' HexDigit+ HexExponentPart? + | '0' [xX] HexDigit+ HexExponentPart + ; + +fragment +ExponentPart + : [eE] [+-]? Digit+ + ; + +fragment +HexExponentPart + : [pP] [+-]? Digit+ + ; + +fragment +EscapeSequence + : '\\' '\r'? '\n' + ; + +fragment +Digit + : [0-9] + ; + +fragment +HexDigit + : [0-9a-fA-F] + ; + +fragment +SingleLineInputCharacter + : ~[\r\n\u0085\u2028\u2029] + ; + +COMMENT + : '--[' NESTED_STR ']' -> channel(HIDDEN) + ; + +LINE_COMMENT + : '--' SingleLineInputCharacter* -> channel(HIDDEN) + ; + +WS + : [ \t\u000C\r\n]+ -> skip + ; + +SHEBANG + : '#' '!' SingleLineInputCharacter* -> channel(HIDDEN) + ; + diff --git a/src/Rust/vvs_lang/samples/retime.vvl b/src/Rust/vvs_lang/samples/retime.vvl new file mode 100644 index 0000000000000000000000000000000000000000..3599efdf772e8c6ab925d44238a8831fc4129b1b --- /dev/null +++ b/src/Rust/vvs_lang/samples/retime.vvl @@ -0,0 +1,28 @@ +-- Contains utilities to retime lines from an ASS file. + + +import "math" + + +option before : int = 900 -- Retime time in millisecond for the aparition of the line. +option after : int = 300 -- Retime time in millisecond for the disaparition of the line. + + +pub job start(l: line) -> line +-- Here we set the begin of the syllabes at the begin of the line, each +-- syllabes will end when it should in fact begin. +begin + for s in l do + s.begin = l.start - before + end +end + + +pub job finish(l: line) -> line +-- Here we set the end of the syllabes at the end of the line, each +-- syllabes will begin when it should in fact end. +begin + for s in l do + s.finish = l.finish - after + end +end diff --git a/src/Rust/vvs_lang/samples/test.vvs b/src/Rust/vvs_lang/samples/test.vvs new file mode 100644 index 0000000000000000000000000000000000000000..3b3c9a603a4114aed37b25b23746bab7c2b71f70 --- /dev/null +++ b/src/Rust/vvs_lang/samples/test.vvs @@ -0,0 +1,24 @@ +import "retime" +import "utils" +import "tag" + + +-- Set some options. +set retime.before = 900 +set retime.after = 400 + +set outline.border = 4 +set outline.color = #(rgb: 0, 0, 0) + + +-- What we want to do for this script, and how we name the initial value. +main INIT { + BEFORE = retime.start INIT; + AFTER = retime.finish INIT; + + OUTLINED = utils.outline BEFORE, INIT, AFTER; + TAGGED = tag.syl_modulo<3> OUTLINED; -- Here we tag some objects... + + -- What we want to write in the file, in order. + write { OUTLINED } +} diff --git a/src/Rust/vvs_lang/src/ast/mod.rs b/src/Rust/vvs_lang/src/ast/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..99fe75f81773539ae067e481bc9e23fd5f7e82f4 --- /dev/null +++ b/src/Rust/vvs_lang/src/ast/mod.rs @@ -0,0 +1,7 @@ +mod module; +mod program; +mod span; +mod string; +mod tree; + +pub use self::{module::*, program::*, span::*, string::*, tree::*}; diff --git a/src/Rust/vvs_lang/src/ast/module.rs b/src/Rust/vvs_lang/src/ast/module.rs new file mode 100644 index 0000000000000000000000000000000000000000..335c8b9aad07a0f33f0a122856e83ee80d203eb8 --- /dev/null +++ b/src/Rust/vvs_lang/src/ast/module.rs @@ -0,0 +1,82 @@ +use super::{tree::*, ASTStringCacheHandle}; +use hashbrown::{HashMap, HashSet}; +use std::{cell::RefCell, rc::Rc}; + +/// A VVL module of a Vivy Script +pub struct Module { + functions: HashMap<ASTString, ASTFunction>, + jobs: HashMap<ASTString, ASTJob>, + consts: HashMap<ASTString, (ASTVar, ASTConst)>, + options: HashMap<ASTString, (ASTVar, ASTConst)>, + imports: HashSet<ASTString>, + + /// Caching for strings, identified by theyr hash and reuse the same memory location to reduce + /// memory footprint. Use the .finish function from the hasher to get the key. + strings: Rc<RefCell<HashMap<u64, ASTString>>>, +} + +/// Here, all the getters... +impl Module { + /// Get a handle to the string cache. + pub fn strings(&mut self) -> ASTStringCacheHandle { + ASTStringCacheHandle::new(self.strings.borrow_mut()) + } +} + +/// Here all the setters, we try to have pure functions. +impl Module { + /// Declare a new function. + /// + /// TODO: Use the entry thingy which should be better... + pub fn declare_function(mut self, name: ASTString, function: ASTFunction) -> Self { + if self.functions.contains_key(&name) { + log::warn!(target: "cc", ";re-definition of function {name} at {}", function.span); + } + self.functions.insert(name, function); + self + } + + /// Declare a new job. + /// + /// TODO: Use the entry thingy which should be better... + pub fn declare_job(mut self, name: ASTString, job: ASTJob) -> Self { + if self.jobs.contains_key(&name) { + log::warn!(target: "cc", ";re-definition of job {name} at {}", job.span); + } + self.jobs.insert(name, job); + self + } + + /// Declare a new option. + /// + /// TODO: Use the entry thingy which should be better... + pub fn declare_option(self, var: ASTVar, value: ASTConst) -> Self { + todo!() + } + + /// Declare a new constant. + /// + /// TODO: Use the entry thingy which should be better... + pub fn declare_const(self, var: ASTVar, value: ASTConst) -> Self { + todo!() + } + + /// Declare a new import. + /// + /// TODO: Use the entry thingy which should be better... + pub fn declare_import(self, import: ASTString) -> Self { + todo!() + } +} + +impl std::fmt::Debug for Module { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Module") + .field("functions", &self.functions) + .field("jobs", &self.jobs) + .field("consts", &self.consts) + .field("options", &self.options) + .field("imports", &self.imports) + .finish() + } +} diff --git a/src/Rust/vvs_lang/src/ast/program.rs b/src/Rust/vvs_lang/src/ast/program.rs new file mode 100644 index 0000000000000000000000000000000000000000..c0691a0cdb1c4b6c9df71815acebe52a93c9a5ed --- /dev/null +++ b/src/Rust/vvs_lang/src/ast/program.rs @@ -0,0 +1,132 @@ +use crate::{ast::*, parser::*}; +use hashbrown::{HashMap, HashSet}; +use std::{cell::RefCell, rc::Rc}; + +/// The first element of the tuple is the destination variable, the second is the tuple to describe +/// the job picked from a module, the last is the list of variables to use as input for this job. +pub type ProgramOperation = (ASTString, (ASTString, ASTString), Vec<ASTString>); + +/// The main VVS file/program. +pub struct Program { + modules: HashMap<ASTString, Module>, + + /// Options specified in the VVL module. All options must be constants. + /// + /// TODO: Remove that, use the options from the modules directly... + options: Vec<((ASTString, ASTVar), ASTConst)>, + + /// The setted options, from the VVS file. + setted: Vec<((ASTString, ASTString), ASTConst)>, + + /// The operations to do in order. + operations: Vec<ProgramOperation>, + + /// What the program writes. + writes: (ASTSpan, Vec<ASTString>), + + /// Initial variable. + initial_var: ASTString, + + /// Caching for strings, identified by theyr hash and reuse the same memory location to reduce + /// memory footprint. Use the .finish function from the hasher to get the key. + strings: Rc<RefCell<HashMap<u64, ASTString>>>, +} + +/// Here we have all the getters... +impl Program { + /// Get a handle to the string cache. + pub fn strings(&mut self) -> ASTStringCacheHandle { + ASTStringCacheHandle::new(self.strings.borrow_mut()) + } + + /// Get a const reference to a module. + pub fn module(&self, name: impl AsRef<str>) -> Option<&Module> { + self.modules.get(name.as_ref()) + } + + /// Get a mutable reference to a module. + pub fn module_mut(&mut self, name: impl AsRef<str>) -> Option<&mut Module> { + self.modules.get_mut(name.as_ref()) + } + + /// Get the value for an option. For the resolution: we first take the value from the setted + /// list before the default value. Note that if we set an option that was never declared we + /// don't return it here, it should have raised a warning and should not be used in the code + /// anyway. + pub fn options(&self, module: &ASTString, name: &ASTString) -> Option<&ASTConst> { + let default = self + .options + .iter() + .find_map(|((mm, var), val)| (*module == *mm && *name == *var.name()).then_some(val))?; + let setted = self + .setted + .iter() + .find_map(|((mm, var), val)| (*module == *mm && *name == *var).then_some(val)); + Some(setted.unwrap_or(default)) + } +} + +/// Here we have all the setters... We always take the [Program] and return a new one for the +/// functions to be pure. +impl Program { + /// Get the operations and the variables to write. Can return an error if a variable was + /// assigned multiple times or if a variable to write was never assigned. + pub fn into_operations(self) -> VVResult<(ASTString, Vec<ProgramOperation>, Vec<ASTString>)> { + let assigned = HashSet::<ASTString>::from_iter( + self.operations + .iter() + .map(|(dest, ..)| dest.clone()) + .chain([self.initial_var.clone()]), + ); + if let Some(unwritten) = self.writes.1.iter().find(|var| !assigned.contains(*var)) { + return Err(VVError::ErrorMsg( + self.writes.0, + format!("variable `{unwritten}` can't be saved because it was never assigned"), + )); + } + let res = (self.initial_var, self.writes); + todo!("add the verified operations to {res:?}") + } + + /// Set an option in the [Program], if the option was already set or was not declared in a + /// [Module] we raise a warning. + pub fn set_option(mut self, module: ASTString, name: ASTString, value: ASTConst) -> Self { + if self + .options + .iter() + .any(|((mm, nn), _)| *mm == module && name == *nn.name()) + { + log::warn!(target: "cc", ";set option {module}.{name} which was not declared"); + } + if self.setted.iter().any(|((mm, nn), _)| *mm == module && name == *nn) { + log::warn!(target: "cc", ";re-set option {module}.{name}"); + } + self.setted.push(((module, name), value)); + self + } + + /// Add an option to the module. We try to expose a functional semantic for the usage of the + /// [Program] struct to help the parsing implementation. + pub fn declare_option(mut self, module: ASTString, var: ASTVar, value: ASTConst) -> VVResult<Self> { + let key = (module, var); + if let Some(old) = self.options.iter().find_map(|(k, _)| key.eq(k).then_some(k.1 .0)) { + Err(VVError::OptionRedefined(old, key.0, key.1)) + } else { + self.options.push((key, value)); + Ok(self) + } + } +} + +impl std::fmt::Debug for Program { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Program") + .field("modules", &self.modules) + .field("options", &self.options) + .field("setted", &self.setted) + .field("operations", &self.operations) + .field("writes", &self.writes) + .field("initial_var", &self.initial_var) + .finish() + } +} diff --git a/src/Rust/vvs_lang/src/ast/span.rs b/src/Rust/vvs_lang/src/ast/span.rs new file mode 100644 index 0000000000000000000000000000000000000000..25c56237168a3258213033a1ed94ab6c1ca48400 --- /dev/null +++ b/src/Rust/vvs_lang/src/ast/span.rs @@ -0,0 +1,66 @@ +/// A span, without the borrowed string. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ASTSpan { + line: u64, + column: u64, + offset: u64, +} + +impl std::fmt::Display for ASTSpan { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self { line, column, .. } = &self; + write!(f, "+{line}:{column}") + } +} + +impl ASTSpan { + /// Create the span out of a line and column and an offset. The lines and + /// columns begeins at 1. The offset begins at 0. + pub fn new(line: u64, column: u64, offset: u64) -> Self { + assert!(line >= 1); + assert!(column >= 1); + Self { line, column, offset } + } + + /// Get the column of the span. The column starts at 1 + pub fn line(&self) -> u64 { + self.line + } + + /// Get the column of the span. The column starts at 1 + pub fn column(&self) -> u64 { + self.column + } + + /// Get the offset of the span. + pub fn offset(&self) -> u64 { + self.offset + } + + /// Merge two spans. For now we just take the first one (the one with the + /// lesser offset, i.e. the minimal one). In the future we will update the + /// length field (when it's added to the structure...) + pub fn merge(s1: Self, s2: Self) -> Self { + if PartialOrd::gt(&s1, &s2) { + s2 + } else { + s1 + } + } +} + +impl From<nom_locate::LocatedSpan<&str>> for ASTSpan { + fn from(span: nom_locate::LocatedSpan<&str>) -> Self { + Self { + line: span.location_line() as u64, + column: span.naive_get_utf8_column() as u64, + offset: span.location_offset() as u64, + } + } +} + +impl PartialOrd for ASTSpan { + fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> { + PartialOrd::partial_cmp(&self.offset, &other.offset) + } +} diff --git a/src/Rust/vvs_lang/src/ast/string.rs b/src/Rust/vvs_lang/src/ast/string.rs new file mode 100644 index 0000000000000000000000000000000000000000..acd94e432b06376b03a6c0ee281f1d1d3faa8917 --- /dev/null +++ b/src/Rust/vvs_lang/src/ast/string.rs @@ -0,0 +1,41 @@ +use super::ASTString; +use hashbrown::HashMap; +use std::{cell::RefMut, collections::hash_map::DefaultHasher, hash::Hasher}; + +/// Used when iterating into the module with mut access, we might need to access the string +/// cache... +#[derive(Debug)] +pub struct ASTStringCacheHandle<'a> { + strings: RefMut<'a, HashMap<u64, ASTString>>, +} + +impl<'a> ASTStringCacheHandle<'a> { + pub(crate) fn new(strings: RefMut<'a, HashMap<u64, ASTString>>) -> Self { + Self { strings } + } + + /// Get the id of a string. + fn get_id(str: impl AsRef<str>) -> u64 { + let mut hasher = DefaultHasher::new(); + hasher.write(str.as_ref().as_bytes()); + hasher.finish() + } + + /// Get a string from the cache, fail if not present. + pub fn get(&mut self, str: impl AsRef<str>) -> Option<ASTString> { + self.strings.get(&Self::get_id(str.as_ref())).cloned() + } + + /// Get or create a string in the cache. + pub fn get_or_insert(&mut self, str: impl AsRef<str>) -> ASTString { + let id = Self::get_id(str.as_ref()); + match self.strings.get(&id) { + Some(str) => str.clone(), + None => { + let str = ASTString::from(str.as_ref()); + self.strings.insert(id, str.clone()); + str + } + } + } +} diff --git a/src/Rust/vvs_lang/src/ast/tree.rs b/src/Rust/vvs_lang/src/ast/tree.rs new file mode 100644 index 0000000000000000000000000000000000000000..e9934ae32003928b248c66a51af17b7279c256c2 --- /dev/null +++ b/src/Rust/vvs_lang/src/ast/tree.rs @@ -0,0 +1,266 @@ +use super::ASTSpan; +use hashbrown::HashMap; +use std::rc::Rc; + +pub type ASTString = Rc<str>; +pub type ASTFloating = f32; +pub type ASTInteger = i32; +pub type ASTTable<T> = HashMap<ASTString, T>; + +#[macro_export] +macro_rules! anon_expression { + ($variant: ident $($args: tt)?) => { + ASTExpr { + span: Default::default(), + content: ASTExprVariant::$variant $($args)?, + } + }; +} + +#[macro_export] +macro_rules! expression { + ($span: expr, $variant: ident $($args: tt)?) => { + ASTExpr { + span: $span.into(), + content: ASTExprVariant::$variant $($args)?, + } + }; +} + +#[macro_export] +macro_rules! anon_instruction { + ($variant: ident $($args: tt)?) => { + ASTInstr { + span: Default::default(), + content: ASTInstrVariant::$variant $($args)?, + } + }; +} + +#[macro_export] +macro_rules! instruction { + ($span: expr, $variant: ident $($args: tt)?) => { + ASTInstr { + span: $span.into(), + content: ASTInstrVariant::$variant $($args)?, + } + }; +} + +/// Job to execute on a line, syllabe, list of lines, etc. +#[derive(Debug)] +pub struct ASTJob { + pub name: ASTString, + pub returns: ASTType, + pub arguments: Vec<ASTVar>, + pub content: Vec<ASTInstr>, + + pub span: ASTSpan, +} + +/// A function. +#[derive(Debug)] +pub struct ASTFunction { + pub name: ASTString, + pub arguments: Vec<ASTVar>, + pub content: Vec<ASTInstr>, + + pub span: ASTSpan, +} + +/// Instructions. +#[derive(Debug)] +pub struct ASTInstr { + pub content: ASTInstrVariant, + pub span: ASTSpan, +} + +/// Instructions. +#[derive(Debug, PartialEq)] +pub enum ASTInstrVariant { + /// Declare variables. + Decl(Vec<ASTVar>, Vec<ASTExpr>), + + /// Assign into variables. + Assign(Vec<ASTExpr>, Vec<ASTExpr>), + + /// FunctionCall. + FuncCall(ASTString, Vec<ASTExpr>), + + /// Begin a block. + Block(Vec<ASTInstr>), + + /// A WhileDo instruction. + WhileDo(ASTExpr, Vec<ASTInstr>), + + /// A DoWhile instruction. + RepeatUntil(ASTExpr, Vec<ASTInstr>), + + /// Conditionals, contract the elseid blocks. + Cond { + cond: ASTExpr, + then_block: Vec<ASTInstr>, + elseif_blocks: Vec<(ASTExpr, Vec<ASTInstr>)>, + else_block: Option<Vec<ASTInstr>>, + }, + + /// For loop, the classic one: + /// ```vvs + /// for elem = 1, 3, 1 do print(elem) end + /// ``` + ForLoop { + var: ASTVar, + lower: ASTInteger, + upper: ASTInteger, + step: Option<ASTInteger>, + }, + + /// For loop with an iterable expression (table): + /// ```vvs + /// for elem in { 1, 2, 3 } do print(elem) end + /// for elem in ( 1, 2, 3 ) do print(elem) end + /// for elem in my_table do print(elem) end + /// ``` + ForInto { var: ASTVar, list: ASTExpr }, + + /// Final thing: break from block. + Break, + + /// Final thing: continue to next iteration. + Continue, + + /// Final thing: return something. + Return(ASTExpr), +} + +/// Binops, sorted by precedence. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ASTBinop { + /// Assoc: right + Power, + + Mul, + Div, + Mod, + Add, + Sub, + + /// Assoc: right + StrCat, + + CmpLE, + CmpLT, + CmpGE, + CmpGT, + CmpEQ, + CmpNE, + + LogicAnd, + LogicXor, + LogicOr, + + BitAnd, + BitXor, + BitOr, + BitShiftLeft, + BitShiftRight, +} + +/// Unops. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ASTUnop { + LogicNot, + BitNot, + Len, + Neg, +} + +/// Expressions. For the partial equality we skip the span field to be able to test efficiently the +/// parsing. +#[derive(Debug)] +pub struct ASTExpr { + pub content: ASTExprVariant, + pub span: ASTSpan, +} + +/// Expressions. +#[derive(Debug, PartialEq)] +pub enum ASTExprVariant { + Nil, + False, + True, + Integer(ASTInteger), + Floating(ASTFloating), + String(ASTString), + Table(ASTTable<ASTExpr>), + Binop(Box<ASTExpr>, ASTBinop, Box<ASTExpr>), + Unop(ASTUnop, Box<ASTExpr>), + FuncCall(Box<ASTExpr>, Vec<ASTExpr>), + MethodInvok(Box<ASTExpr>, ASTString, Vec<ASTExpr>), + PrefixExpr(Box<ASTExpr>, Vec<ASTField>), + Tuple(Vec<ASTExpr>), + Var(ASTVar), + Const(ASTConst), +} + +/// Fields indexes can be expressions or identifiers +#[derive(Debug, PartialEq)] +pub enum ASTField { + Expr(ASTSpan, ASTExprVariant), + Identifier(ASTSpan, ASTString), +} + +/// Variable thing. Have a name, a where-it-is-defined span and optionally a type. Having to no +/// type means that the type was not already found. +#[derive(Debug, PartialEq, Eq)] +pub struct ASTVar(pub ASTSpan, pub ASTString, pub Option<ASTType>); + +/// A constant expr. +#[derive(Debug, PartialEq)] +pub enum ASTConst { + Color(ASTInteger), + String(ASTString), + Integer(ASTInteger), + Floating(ASTFloating), + Table(ASTTable<ASTConst>), +} + +/// Types. +#[derive(Debug, PartialEq, Eq)] +pub enum ASTType { + Integer, + Floating, + String, + Color, + Line, + Syllabe, + Table(ASTTable<ASTType>), +} + +impl PartialEq for ASTExpr { + fn eq(&self, other: &Self) -> bool { + self.content == other.content + } +} + +impl PartialEq for ASTInstr { + fn eq(&self, other: &Self) -> bool { + self.content == other.content + } +} + +impl ASTVar { + pub fn span(&self) -> ASTSpan { + self.0 + } + + pub fn name(&self) -> &ASTString { + &self.1 + } +} + +impl std::fmt::Display for ASTVar { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.1.as_ref()) + } +} diff --git a/src/Rust/vvs_lang/src/lib.rs b/src/Rust/vvs_lang/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..a310c76d6da8c9fbfc8dd0f2598b6810a43715cd --- /dev/null +++ b/src/Rust/vvs_lang/src/lib.rs @@ -0,0 +1,2 @@ +pub mod ast; +pub mod parser; diff --git a/src/Rust/vvs_lang/src/parser/error.rs b/src/Rust/vvs_lang/src/parser/error.rs new file mode 100644 index 0000000000000000000000000000000000000000..c6a251764d8cd9db1c693f6126f3dd60f9723a17 --- /dev/null +++ b/src/Rust/vvs_lang/src/parser/error.rs @@ -0,0 +1,124 @@ +use crate::{ast::ASTSpan, parser::*}; +pub(crate) use nom::error::{ContextError as NomContextError, ParseError as NomParseError}; +use thiserror::Error; + +#[macro_export] +macro_rules! nom_err_error { + (vvs: $code: ident $args: tt) => { + Err(nom::Err::Error($crate::parser::error::VVError::$code $args)) + }; + + (vvs bare: $code: ident $args: tt) => { + nom::Err::Error($crate::parser::error::VVError::$code $args) + }; + + (nom: $i: expr, $code: ident) => { + Err(nom::Err::Error(VVError::from_error_kind( + $i, + nom::error::ErrorKind::$code, + ))) + }; + + (nom bare: $i: expr, $code: ident) => { + VVError::from_error_kind($i, nom::error::ErrorKind::$code) + }; +} +pub(crate) use nom_err_error; + +#[macro_export] +macro_rules! nom_err_failure { + (vvs: $code: ident $args: tt) => { + Err(nom::Err::Failure($crate::parser::error::VVError::$code $args)) + }; + + (vvs bare: $code: ident $args: tt) => { + nom::Err::Failure($crate::parser::error::VVError::$code $args) + }; + + (nom: $i: expr, $code: ident) => { + Err(nom::Err::Failure(VVError::from_error_kind( + $i, + nom::error::ErrorKind::$code, + ))) + }; + + (nom bare: $i: expr, $code: ident) => { + VVError::from_error_kind($i, nom::error::ErrorKind::$code) + }; +} +pub(crate) use nom_err_failure; + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum VVError { + #[error("got an error from nom at {0}: {1:?}")] + Nom(ASTSpan, nom::error::ErrorKind), + + #[error("got a nom error needed error at {0}: {1:?}")] + NomNeeded(ASTSpan, nom::Needed), + + #[error("got multiple errors:\n{0:?}")] + Multiple(Vec<VVError>), + + #[error("failed to parse an integer at {0}: {1}")] + ParseIntError(ASTSpan, std::num::ParseIntError), + + #[error("failed to parse a floating number at {0}: {1}")] + ParseFloatError(ASTSpan, std::num::ParseFloatError), + + #[error("failed to get tag `{1}` at {0}")] + FailedToGetTag(ASTSpan, &'static str), + + #[error("not an identifier at {0}")] + NotAnIdentifier(ASTSpan), + + #[error("table index `{1}` redefined at {0}")] + TableIndexRedefined(ASTSpan, ASTString), + + #[error("option `{1}.{2}` at line {} redefined, first definition at {0}", .2.span())] + OptionRedefined(ASTSpan, ASTString, ASTVar), + + #[error("error at {0}: {1}")] + ErrorMsg(ASTSpan, String), +} + +pub type VVParserError<'a, T> = IResult<PSpan<'a>, T, VVError>; +pub type VVResult<T> = Result<T, VVError>; + +impl<'a> NomContextError<PSpan<'a>> for VVError {} + +impl<'a> NomParseError<PSpan<'a>> for VVError { + fn from_error_kind(input: PSpan, kind: nom::error::ErrorKind) -> Self { + Self::Nom(input.into(), kind) + } + + fn append(input: PSpan, kind: nom::error::ErrorKind, other: Self) -> Self { + VVError::Multiple(match other { + VVError::Multiple(mut other) => { + other.push(Self::from_error_kind(input, kind)); + other + } + other => vec![other, Self::from_error_kind(input, kind)], + }) + } +} + +impl<'a> nom::error::FromExternalError<PSpan<'a>, std::num::ParseIntError> for VVError { + fn from_external_error(input: PSpan<'a>, _: nom::error::ErrorKind, e: std::num::ParseIntError) -> Self { + Self::ParseIntError(input.into(), e) + } +} + +impl<'a> nom::error::FromExternalError<PSpan<'a>, std::num::ParseFloatError> for VVError { + fn from_external_error(input: PSpan<'a>, _: nom::error::ErrorKind, e: std::num::ParseFloatError) -> Self { + Self::ParseFloatError(input.into(), e) + } +} + +impl<'a> nom::error::FromExternalError<PSpan<'a>, nom::Err<VVError>> for VVError { + fn from_external_error(i: PSpan, _: nom::error::ErrorKind, e: nom::Err<VVError>) -> Self { + match e { + nom::Err::Incomplete(e) => Self::NomNeeded(i.into(), e), + nom::Err::Error(e) | nom::Err::Failure(e) => e, + } + } +} diff --git a/src/Rust/vvs_lang/src/parser/mod.rs b/src/Rust/vvs_lang/src/parser/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..89d575c3ab92f9d5095b140a59b928de39f21725 --- /dev/null +++ b/src/Rust/vvs_lang/src/parser/mod.rs @@ -0,0 +1,110 @@ +//! Module responsible to parse the user's code, composed of .VVL files where the jobs are defined +//! and .VVS files where the global workflow is written explicitly. Some parsing functions are +//! common between the two parser, those things are expressed here. + +mod error; +mod string; +mod types; + +pub mod vvl; +pub mod vvs; + +use crate::ast::*; + +pub(crate) use crate::parser::{error::*, string::string, types::types}; +pub(crate) use nom::{ + branch::alt, + bytes::complete::{is_not, tag, take_while, take_while_m_n}, + character::{ + complete::{alpha1, alphanumeric1, char, digit1, multispace0, multispace1}, + is_alphanumeric, + }, + combinator::{cut, map, map_opt, map_res, opt, peek, recognize, value, verify}, + error::context, + multi::{fold_many0, many0, many0_count, separated_list0, separated_list1}, + sequence::{delimited, pair, preceded, separated_pair, terminated, tuple}, + IResult, Parser as NomParser, +}; +pub(crate) use nom_locate::LocatedSpan; +pub(crate) use std::{cell::RefCell, rc::Rc}; + +/// A parser span. Will be converted into an AST span latter. +type PSpan<'a> = LocatedSpan<&'a str>; + +pub(crate) fn with_span<'a, T>( + mut cb: impl NomParser<PSpan<'a>, T, VVError>, +) -> impl FnMut(PSpan<'a>) -> VVParserError<'a, (PSpan<'a>, T)> { + move |input: PSpan<'a>| { + let (input, _) = multispace0(input)?; + let (i, res) = cb.parse(input)?; + Ok((i, (input, res))) + } +} + +pub(crate) fn spaced_tag<'a>(str: &'static str) -> impl FnMut(PSpan<'a>) -> VVParserError<'a, ()> { + delimited(multispace0, map(tag(str), |_| ()), multispace0) +} + +pub(crate) fn keyword<'a>(kw: &'static str) -> impl FnMut(PSpan<'a>) -> VVParserError<'a, ()> { + delimited(multispace0, plain_tag(kw), multispace0) +} + +pub(crate) fn plain_tag<'a>(t: &'static str) -> impl FnMut(PSpan<'a>) -> VVParserError<'a, ()> { + move |i| { + let (input, remain) = take_while(|x| is_alphanumeric(x as u8))(tag(t)(i)?.0)?; + remain + .is_empty() + .then_some((input, ())) + .ok_or_else(|| nom_err_error!(vvs bare: FailedToGetTag (i.into(), t))) + } +} + +pub(crate) fn number(i: PSpan) -> VVParserError<ASTInteger> { + alt(( + map_res(preceded(opt(tag("+")), digit1), |digit_str: PSpan| { + digit_str.parse::<ASTInteger>() + }), + map_res(preceded(tag("-"), digit1), |digit_str: PSpan| { + digit_str.parse::<ASTInteger>().map(|x| -x) + }), + ))(i) +} + +pub(crate) fn float(i: PSpan) -> VVParserError<ASTFloating> { + map( + pair( + opt(alt((tag("+"), tag("-")))), + map_res(recognize(tuple((digit1, tag("."), digit1))), |f: PSpan| { + f.fragment().parse::<f32>() + }), + ), + |(sign, value)| { + sign.map(|s| if s.starts_with('-') { -value } else { value }) + .unwrap_or(value) + }, + )(i) +} + +pub(crate) fn identifier(input: PSpan) -> VVParserError<&str> { + let (input, _) = multispace0(input)?; + let (i, id) = match recognize(pair( + alt((alpha1::<PSpan, VVError>, tag("_"))), + many0_count(alt((alphanumeric1, tag("_")))), + ))(input) + { + Ok(ok) => ok, + Err(_) => return nom_err_error!(vvs: NotAnIdentifier (input.into())), + }; + Ok((i, *id.fragment())) +} + +#[test] +fn test_floats_and_numbers() { + assert_eq!(number("1".into()).unwrap().1, 1); + assert_eq!(number("0".into()).unwrap().1, 0); + assert_eq!(number("-1".into()).unwrap().1, -1); + assert_eq!(float("1.0".into()).unwrap().1, 1.0); + assert_eq!(float("0.0".into()).unwrap().1, 0.0); + assert_eq!(float("-1.0".into()).unwrap().1, -1.0); + assert!(float("1.".into()).is_err()); +} diff --git a/src/Rust/vvs_lang/src/parser/string.rs b/src/Rust/vvs_lang/src/parser/string.rs new file mode 100644 index 0000000000000000000000000000000000000000..5adb03fffa4bbf5938b79ca681b80b970fd4e813 --- /dev/null +++ b/src/Rust/vvs_lang/src/parser/string.rs @@ -0,0 +1,99 @@ +use super::*; + +/// Parse a unicode sequence, of the form u{XXXX}, where XXXX is 1 to 6 +/// hexadecimal numerals. We will combine this later with parse_escaped_char +/// to parse sequences like \u{00AC}. +fn parse_unicode(input: PSpan) -> VVParserError<char> { + let parse_hex = take_while_m_n(1, 6, |c: char| c.is_ascii_hexdigit()); + let parse_delimited_hex = preceded(char('u'), delimited(char('{'), parse_hex, char('}'))); + let parse_u32 = map_res(parse_delimited_hex, move |hex: PSpan| { + u32::from_str_radix(hex.fragment(), 16) + }); + map_opt(parse_u32, std::char::from_u32)(input) +} + +/// Parse an escaped character: \n, \t, \r, \u{00AC}, etc. +fn parse_escaped_char(input: PSpan) -> VVParserError<char> { + preceded( + char('\\'), + alt(( + parse_unicode, + value('\n', char('n')), + value('\r', char('r')), + value('\t', char('t')), + value('\u{08}', char('b')), + value('\u{0C}', char('f')), + value('\\', char('\\')), + value('/', char('/')), + value('"', char('"')), + )), + ) + .parse(input) +} + +/// Parse a backslash, followed by any amount of whitespace. This is used later +/// to discard any escaped whitespace. +fn parse_escaped_whitespace(input: PSpan) -> VVParserError<&str> { + let (i, str) = preceded(char('\\'), multispace1)(input)?; + Ok((i, str.fragment())) +} + +/// Parse a non-empty block of text that doesn't include \ or " +fn parse_literal(input: PSpan) -> VVParserError<&str> { + verify(map(is_not("\"\\"), |str: PSpan| *str.fragment()), |s: &str| { + !s.is_empty() + })(input) +} + +/// A string fragment contains a fragment of a string being parsed: either +/// a non-empty Literal (a series of non-escaped characters), a single +/// parsed escaped character, or a block of escaped whitespace. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StringFragment<'a> { + Literal(&'a str), + EscapedChar(char), + EscapedWS, +} + +/// Combine parse_literal, parse_escaped_whitespace, and parse_escaped_char +/// into a StringFragment. +fn parse_fragment(input: PSpan) -> VVParserError<StringFragment> { + alt(( + map(parse_literal, StringFragment::Literal), + map(parse_escaped_char, StringFragment::EscapedChar), + value(StringFragment::EscapedWS, parse_escaped_whitespace), + )) + .parse(input) +} + +/// Parse a string. Use a loop of parse_fragment and push all of the fragments +/// into an output string. +pub(crate) fn string(input: PSpan) -> VVParserError<String> { + let build_string = fold_many0(parse_fragment, String::new, |mut string, fragment| { + match fragment { + StringFragment::Literal(s) => string.push_str(s), + StringFragment::EscapedChar(c) => string.push(c), + StringFragment::EscapedWS => {} + } + string + }); + delimited(char('"'), build_string, char('"')).parse(input) +} + +#[test] +fn test_parse_string() { + use vvs_utils::assert_err; + let data = "\"abc\""; + let result = string(data.into()); + assert_eq!(result.unwrap().1, String::from("abc")); + + let data = "\"tab:\\tafter tab, newline:\\nnew line, quote: \\\", emoji: \\u{1F602}, newline:\\nescaped whitespace: \\ abc\""; + let result = string(data.into()); + assert_eq!( + result.unwrap().1, + String::from("tab:\tafter tab, newline:\nnew line, quote: \", emoji: 😂, newline:\nescaped whitespace: abc") + ); + + // Don't do single quotes for now. + assert_err!(string("\'abc\'".into())); +} diff --git a/src/Rust/vvs_lang/src/parser/types.rs b/src/Rust/vvs_lang/src/parser/types.rs new file mode 100644 index 0000000000000000000000000000000000000000..dffdfff7ce32b4261f170101dcf00baec59adb78 --- /dev/null +++ b/src/Rust/vvs_lang/src/parser/types.rs @@ -0,0 +1,5 @@ +use super::*; + +pub(crate) fn types(input: PSpan) -> VVParserError<ASTType> { + todo!() +} diff --git a/src/Rust/vvs_lang/src/parser/vvl.rs b/src/Rust/vvs_lang/src/parser/vvl.rs new file mode 100644 index 0000000000000000000000000000000000000000..4ca6daba506d71a49f82b880439f23a51e06839e --- /dev/null +++ b/src/Rust/vvs_lang/src/parser/vvl.rs @@ -0,0 +1,9 @@ +//! Module responsible to parse .VVL files, the files where the code is defined. + +mod expr; +mod inst; +mod tabl; +mod tupl; + +use self::{expr::expr, inst::instruction, tabl::table, tupl::any_tuple}; +use crate::{ast::*, parser::*}; diff --git a/src/Rust/vvs_lang/src/parser/vvl/expr.rs b/src/Rust/vvs_lang/src/parser/vvl/expr.rs new file mode 100644 index 0000000000000000000000000000000000000000..cd7fbfe72f7d45092329b4a282fb093d9a71d0fa --- /dev/null +++ b/src/Rust/vvs_lang/src/parser/vvl/expr.rs @@ -0,0 +1,237 @@ +use crate::{ast::*, expression, parser::vvl::*}; +use vvs_utils::either; + +#[allow(dead_code)] +pub(crate) fn expr<'a, 'b>( + cache: Rc<RefCell<ASTStringCacheHandle<'b>>>, +) -> impl Fn(PSpan<'a>) -> VVParserError<'a, ASTExpr> + 'b +where + 'b: 'a, +{ + macro_rules! expr { + // No binary expression, parse a unary expression or something else, here we have reached + // the leaf of the tree. We are parsing some things, but we still need to handle more things: + // - tuples + // Stil need to write some tests! + () => { + |cache: Rc<RefCell<ASTStringCacheHandle<'b>>>| { move |input| -> VVParserError<'a, ASTExpr> { + let (input, _) = multispace0(input)?; + let (i, (rvalue, indices, method, args)) = context("expr leaf", tuple(( + // Final leaf for the expression tree. + alt(( + delimited(spaced_tag("("), expr(cache.clone()), spaced_tag(")")), + map(with_span(preceded(tag("$"), number)), |(sp, c)| expression! { sp, Const(ASTConst::Color(c)) }), + map(with_span(keyword("true")), |(sp, _)| expression! { sp, True }), + map(with_span(keyword("false")), |(sp, _)| expression! { sp, False }), + map(with_span(keyword("nil")), |(sp, _)| expression! { sp, Nil }), + map(with_span(preceded(keyword("not"), expr(cache.clone()))), |(sp, e)| expression! { sp, Unop(ASTUnop::LogicNot, Box::new(e)) }), + map(with_span(preceded(spaced_tag("#"), expr(cache.clone()))), |(sp, e)| expression! { sp, Unop(ASTUnop::Len, Box::new(e)) }), + map(with_span(preceded(spaced_tag("-"), expr(cache.clone()))), |(sp, e)| expression! { sp, Unop(ASTUnop::Neg, Box::new(e)) }), + map(with_span(preceded(spaced_tag("~"), expr(cache.clone()))), |(sp, e)| expression! { sp, Unop(ASTUnop::BitNot, Box::new(e)) }), + map(with_span(identifier), |(sp, n)| expression! { sp, Var(ASTVar(sp.into(), cache.borrow_mut().get_or_insert(n), None)) }), + map(with_span(string), |(sp, s)| expression! { sp, Const(ASTConst::String(cache.borrow_mut().get_or_insert(s))) }), + map(with_span(float), |(sp, f)| expression! { sp, Const(ASTConst::Floating(f)) }), + map(with_span(number), |(sp, i)| expression! { sp, Const(ASTConst::Integer(i)) }), + map(with_span(table(cache.clone())), |(sp, t)| expression! { sp, Table(t) }), + map(with_span(any_tuple(cache.clone())), |(sp, t)| expression! { sp, Tuple(t) }), + )), + // Do we need to index into the thing? + many0(alt(( + map(delimited(spaced_tag("["), expr(cache.clone()), spaced_tag("]")), |expr| ASTField::Expr(expr.span, expr.content)), + map(with_span(preceded(spaced_tag("."), identifier)), |(span, field)| ASTField::Identifier(span.into(), cache.borrow_mut().get_or_insert(field))), + ))), + // Function call and method invoking stuff + opt(preceded(spaced_tag(":"), identifier)), + opt(delimited(spaced_tag("("), separated_list0(spaced_tag(","), expr(cache.clone())), spaced_tag(")"))), + )))(input)?; + // Build an expression from the rvalue + let expr = either!(!indices.is_empty() + => expression! { input, PrefixExpr(Box::new(rvalue), indices) } + ; rvalue + ); + // Some logic to handle the call function/method thing + let expr = match (method, args) { + (Some(func), Some(args)) => expression! { input, MethodInvok(Box::new(expr), cache.borrow_mut().get_or_insert(func), args) }, + (None, Some(args)) => expression! { input, FuncCall(Box::new(expr), args) }, + (Some(_), None) => todo!("a method invokation without arguments, should return the function with the first argument bounded"), + (None, None) => expr, + }; + Ok((i, expr)) + }} + }; + + // We are in a node of a binary expression. The binary operator can be a keyword or a + // symbol list, need to handle those two cases differently. + ( $(($str: literal, $op: ident)),+ + $(; $(($strs: literal, $ops: ident)),+)* + $(;)? + ) => { + |cache: Rc<RefCell<ASTStringCacheHandle<'b>>>| { move |i| -> VVParserError<'a, ASTExpr> { + let (input, _) = multispace0(i)?; + let next_clozure = expr!($($(($strs, $ops)),+);*); + let next_clozure = next_clozure(cache.clone()); + let (i, initial) = next_clozure(input)?; + let (i, _) = multispace0(i)?; + let (i, remainder) = many0(alt(($( + |i| -> VVParserError<(ASTBinop, ASTExpr)> { + let (i, item) = either! { $str.chars().next().unwrap().is_ascii_alphabetic() + => preceded(keyword($str), preceded(multispace0, next_clozure.clone()))(i)? + ; preceded(tag($str), preceded(multispace0, next_clozure.clone()))(i)? + }; + Ok((i, (ASTBinop::$op, item))) + } + ),+,)))(i)?; + Ok((i, remainder.into_iter().fold(initial, |acc, (oper, expr)| expression! { + ASTSpan::merge(acc.span, expr.span), + Binop(Box::new(acc), oper, Box::new(expr)) + }))) + }} + }; + } + + let expr = expr! [ /* 12 */ ("|", BitOr) + ; /* 11 */ ("^", BitXor) + ; /* 10 */ ("&", BitAnd) + ; /* 09 */ ("or", LogicOr) + ; /* 08 */ ("xor", LogicXor) + ; /* 07 */ ("and", LogicAnd) + ; /* 06 */ ("==", CmpEQ), ("~=", CmpNE), ("!=", CmpNE) + ; /* 05 */ ("<=", CmpLE), ("<", CmpLT), (">=", CmpGE), (">", CmpGT) + ; /* 04 */ ("..", StrCat) + ; /* 03 */ ("<<", BitShiftLeft), (">>", BitShiftRight) + ; /* 02 */ ("+", Add), ("-", Sub) + ; /* 01 */ ("*", Mul), ("/", Div), ("%", Mod), ("mod", Mod) + ; /* 00 */ ("**", Power) + ]; + + expr(cache) +} + +#[test] +fn test_arithmetic() { + use crate::{anon_expression, ast::ASTBinop::*}; + use hashbrown::HashMap; + let strings = Rc::<RefCell<HashMap<u64, ASTString>>>::default(); + let expr = expr(Rc::new(RefCell::new(ASTStringCacheHandle::new(strings.borrow_mut())))); + let expr = move |str: &'static str| expr(str.into()); + + assert_eq!( + expr("1+2*3").unwrap().1, + anon_expression!(Binop( + Box::new(anon_expression!(Const(ASTConst::Integer(1)))), + Add, + Box::new(anon_expression!(Binop( + Box::new(anon_expression!(Const(ASTConst::Integer(2)))), + Mul, + Box::new(anon_expression!(Const(ASTConst::Integer(3)))) + ))) + )) + ); + assert_eq!( + expr("2*3+1").unwrap().1, + anon_expression!(Binop( + Box::new(anon_expression!(Binop( + Box::new(anon_expression!(Const(ASTConst::Integer(2)))), + Mul, + Box::new(anon_expression!(Const(ASTConst::Integer(3)))) + ))), + Add, + Box::new(anon_expression!(Const(ASTConst::Integer(1)))), + )) + ); +} + +#[test] +fn test_valid_table() { + use crate::{anon_expression, ast::*}; + use hashbrown::HashMap; + use vvs_utils::{assert_ok, assert_some}; + let strings = Rc::<RefCell<HashMap<u64, ASTString>>>::default(); + let table = expr(Rc::new(RefCell::new(ASTStringCacheHandle::new(strings.borrow_mut())))); + let table = move |str: &'static str| table(str.into()); + let table = assert_ok!(table( + r#"{ toto = "tata", foo = 3.14, ["bar"] = 42, titi = {}, oupsy = nil }"# + )); + + let table = match table.1.content { + ASTExprVariant::Table(table) => table, + expr => panic!("should be a table, got {expr:#?}"), + }; + + assert_eq!( + assert_some!(table.get("toto")).content, + anon_expression!(Const(ASTConst::String("tata".into()))).content + ); + + assert_eq!( + assert_some!(table.get("foo")).content, + anon_expression!(Const(ASTConst::Floating(3.14))).content + ); + + assert_eq!( + assert_some!(table.get("bar")).content, + anon_expression!(Const(ASTConst::Integer(42))).content + ); + + assert_eq!( + assert_some!(table.get("titi")).content, + anon_expression!(Table(HashMap::default())).content + ); + + assert_eq!( + assert_some!(table.get("oupsy")).content, + anon_expression!(Nil).content + ); +} + +#[test] +fn test_redefined_key_table() { + use crate::ast::*; + use hashbrown::HashMap; + use vvs_utils::assert_err; + let strings = Rc::<RefCell<HashMap<u64, ASTString>>>::default(); + let table = expr(Rc::new(RefCell::new(ASTStringCacheHandle::new(strings.borrow_mut())))); + let table = move |str: &'static str| table(str.into()); + assert_err!(table(r#"{ toto = "tata", toto = nil }"#)); +} + +#[test] +fn test_tuple_invalid() { + use crate::ast::*; + use hashbrown::HashMap; + use vvs_utils::assert_err; + let strings = Rc::<RefCell<HashMap<u64, ASTString>>>::default(); + let expr = expr(Rc::new(RefCell::new(ASTStringCacheHandle::new(strings.borrow_mut())))); + let expr = move |str: &'static str| expr(str.into()); + match expr(r#""a", "b", "c""#) { + Ok((i, expr)) if i.is_empty() => panic!("successfully parsed {expr:#?}"), + _ => {} + } + assert_err!(expr("(1,)")); +} + +#[test] +fn test_tuple() { + use crate::{anon_expression, ast::*}; + use hashbrown::HashMap; + use vvs_utils::assert_ok; + let strings = Rc::<RefCell<HashMap<u64, ASTString>>>::default(); + let expr = expr(Rc::new(RefCell::new(ASTStringCacheHandle::new(strings.borrow_mut())))); + let expr = move |str: &'static str| expr(str.into()); + assert_eq!( + assert_ok!(expr("(1, 2, 3, 4)")).1, + anon_expression!(Tuple(vec![ + anon_expression!(Const(ASTConst::Integer(1))), + anon_expression!(Const(ASTConst::Integer(2))), + anon_expression!(Const(ASTConst::Integer(3))), + anon_expression!(Const(ASTConst::Integer(4))), + ])) + ); + assert_eq!( + assert_ok!(expr("(1, 2,)")).1, + anon_expression!(Tuple(vec![ + anon_expression!(Const(ASTConst::Integer(1))), + anon_expression!(Const(ASTConst::Integer(2))), + ])) + ); +} diff --git a/src/Rust/vvs_lang/src/parser/vvl/inst.rs b/src/Rust/vvs_lang/src/parser/vvl/inst.rs new file mode 100644 index 0000000000000000000000000000000000000000..4699bcaf2be78b62f998dd1acca2bb509dff1376 --- /dev/null +++ b/src/Rust/vvs_lang/src/parser/vvl/inst.rs @@ -0,0 +1,76 @@ +use crate::{ast::*, instruction, parser::vvl::*}; + +pub(crate) fn do_end_block<'a, 'b>( + r#do: &'static str, + end: &'static str, + cache: Rc<RefCell<ASTStringCacheHandle<'b>>>, +) -> impl Fn(PSpan<'a>) -> VVParserError<'a, Vec<ASTInstr>> + 'b +where + 'b: 'a, +{ + move |input| { + let (input, _) = multispace0(input)?; + delimited(keyword(r#do), many0(instruction(cache.clone())), keyword(end))(input) + } +} + +#[allow(dead_code)] +pub(crate) fn instruction<'a, 'b>( + cache: Rc<RefCell<ASTStringCacheHandle<'b>>>, +) -> impl Fn(PSpan<'a>) -> VVParserError<'a, ASTInstr> + 'b +where + 'b: 'a, +{ + move |input| { + let (input, _) = multispace0(input)?; + terminated( + alt(( + // Break/Continue loops + map(with_span(keyword("break")), |(sp, _)| instruction!(sp, Break)), + map(with_span(keyword("continue")), |(sp, _)| instruction!(sp, Continue)), + // Variable assignation. Don't do the destructuring for now. + map( + with_span(separated_pair( + expr(cache.clone()), + spaced_tag("="), + expr(cache.clone()), + )), + |(sp, (dest, src))| instruction!(sp, Assign(vec![dest], vec![src])), + ), + // Variable declaration. Don't do the destructuring for now. + map( + with_span(tuple(( + keyword("let"), + identifier, + opt(types), + spaced_tag("="), + expr(cache.clone()), + ))), + |(sp, (_, name, ty, _, decl))| { + let var = ASTVar(sp.into(), cache.borrow_mut().get_or_insert(name), ty); + instruction!(sp, Decl(vec![var], vec![decl])) + }, + ), + // A do-end block with a list of instructions inside it. + map(with_span(do_end_block("do", "end", cache.clone())), |(sp, body)| { + instruction!(sp, Block(body)) + }), + // While loop. + map( + with_span(pair( + preceded(keyword("while"), expr(cache.clone())), + do_end_block("do", "end", cache.clone()), + )), + |(sp, (cond, body))| instruction!(sp, WhileDo(cond, body)), + ), + // A DoWhile block (or a reapeat until in lua...) + map( + with_span(pair(do_end_block("do", "until", cache.clone()), expr(cache.clone()))), + |(sp, (body, cond))| instruction!(sp, RepeatUntil(cond, body)), + ), + )), + // We may terminate with a semicolon + opt(spaced_tag(";")), + )(input) + } +} diff --git a/src/Rust/vvs_lang/src/parser/vvl/tabl.rs b/src/Rust/vvs_lang/src/parser/vvl/tabl.rs new file mode 100644 index 0000000000000000000000000000000000000000..25f8d36df88c09b665561a1465f32353f0520cdc --- /dev/null +++ b/src/Rust/vvs_lang/src/parser/vvl/tabl.rs @@ -0,0 +1,56 @@ +use super::*; +use hashbrown::HashMap; + +/// Parses the keys in tables or returns an error. Keys can be identifiers or a string between +/// square braquets: `[`, `]`. +fn key<'a, 'b>(cache: Rc<RefCell<ASTStringCacheHandle<'b>>>) -> impl Fn(PSpan<'a>) -> VVParserError<'a, ASTString> + 'b +where + 'b: 'a, +{ + move |input: PSpan| -> VVParserError<ASTString> { + let (input, _) = multispace0(input)?; + alt(( + map(delimited(spaced_tag("["), string, spaced_tag("]")), |str| { + cache.borrow_mut().get_or_insert(str) + }), + map(identifier, |str| cache.borrow_mut().get_or_insert(str)), + ))(input) + } +} + +/// Parses a separator for key value pairs for the table parser. In Lua separators can be `,` or +/// `;` for some reasons... +fn separator(input: PSpan) -> VVParserError<()> { + alt((spaced_tag(","), spaced_tag(";")))(input) +} + +/// Parses a table or returns an error. +pub(crate) fn table<'a, 'b>( + cache: Rc<RefCell<ASTStringCacheHandle<'b>>>, +) -> impl Fn(PSpan<'a>) -> VVParserError<'a, ASTTable<ASTExpr>> + 'b +where + 'b: 'a, +{ + move |input| { + let (input, _) = multispace0(input)?; + let table_key_value_pair = separated_pair(key(cache.clone()), spaced_tag("="), expr(cache.clone())); + let (i, content) = context( + "table", + delimited( + spaced_tag("{"), + separated_list0(separator, table_key_value_pair), + preceded(opt(separator), spaced_tag("}")), + ), + )(input)?; + let hashmap = HashMap::with_capacity(content.len()); + let hashmap = content.into_iter().try_fold(hashmap, |mut hashmap, (key, value)| { + if hashmap.contains_key(&key) { + nom_err_error!(vvs: TableIndexRedefined (value.span, key)) + } else { + hashmap.insert(key, value); + Ok(hashmap) + } + })?; + Ok((i, hashmap)) + } +} diff --git a/src/Rust/vvs_lang/src/parser/vvl/tupl.rs b/src/Rust/vvs_lang/src/parser/vvl/tupl.rs new file mode 100644 index 0000000000000000000000000000000000000000..9bd32b635c388e59218812fcd5b78ee5ac5e066e --- /dev/null +++ b/src/Rust/vvs_lang/src/parser/vvl/tupl.rs @@ -0,0 +1,24 @@ +use super::*; + +/// Unlike Lua, we require parens (`(` `)`) around tuples. +pub(crate) fn any_tuple<'a, 'b>( + cache: Rc<RefCell<ASTStringCacheHandle<'b>>>, +) -> impl Fn(PSpan<'a>) -> VVParserError<'a, Vec<ASTExpr>> + 'b +where + 'b: 'a, +{ + move |input| { + let (input, _) = multispace0(input)?; + let (i, (head, mut tail)) = delimited( + spaced_tag("("), + separated_pair( + expr(cache.clone()), + spaced_tag(","), + separated_list1(spaced_tag(","), expr(cache.clone())), + ), + preceded(opt(spaced_tag(",")), spaced_tag(")")), + )(input)?; + tail.insert(0, head); + Ok((i, tail)) + } +} diff --git a/src/Rust/vvs_lang/src/parser/vvs.rs b/src/Rust/vvs_lang/src/parser/vvs.rs new file mode 100644 index 0000000000000000000000000000000000000000..727017478067eb2dfe2f315de931b7096af3a656 --- /dev/null +++ b/src/Rust/vvs_lang/src/parser/vvs.rs @@ -0,0 +1,16 @@ +//! Module responsible to parse .VVS files, the files where jobs from .VVS files are composed and +//! where the global workflow of the script is written by the user. + +use std::path::Path; + +use crate::{ast::*, parser::*}; + +/// Parses the content of a file. +pub fn parse_vvs<'a>(_: impl AsRef<str> + 'a) -> VVParserError<'a, Program> { + todo!() +} + +/// Parses a file. +pub fn parse_vvs_file(_: impl AsRef<Path>) -> VVResult<Program> { + todo!() +} diff --git a/src/Rust/vvs_procmacro/Cargo.toml b/src/Rust/vvs_procmacro/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..3e99f27c4f2c4e3f9b13b4b8108390ba1fc94874 --- /dev/null +++ b/src/Rust/vvs_procmacro/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "vvs_procmacro" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +description = "The procmacro utility crate for VVS" + +[lib] +proc-macro = true + +[dependencies] +syn = { version = "2.0", features = ["full"] } +quote = "1.0" +proc-macro2 = "1.0" diff --git a/src/Rust/vvs_procmacro/src/lib.rs b/src/Rust/vvs_procmacro/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..64bbd40ddfc3d205048f3e5b2ecc4bb5eb0800f6 --- /dev/null +++ b/src/Rust/vvs_procmacro/src/lib.rs @@ -0,0 +1,85 @@ +#![forbid(unsafe_code)] + +use proc_macro::TokenStream; +use proc_macro2::*; +use quote::*; +use syn::{parse_macro_input, DeriveInput}; + +#[proc_macro_derive(EnumVariantCount)] +pub fn derive_enum_variant_count(input: TokenStream) -> TokenStream { + let syn_item = parse_macro_input!(input as DeriveInput); + let (len, name) = match syn_item.data { + syn::Data::Enum(enum_item) => ( + enum_item.variants.len(), + Ident::new( + &format!("{}_LENGTH", syn_item.ident.to_string().to_uppercase()), + syn_item.ident.span(), + ), + ), + _ => panic!("EnumVariantCount only works on Enums"), + }; + quote! { pub const #name : usize = #len; }.into() +} + +#[proc_macro_derive(EnumVariantIter)] +pub fn derive_enum_variant_iter(input: TokenStream) -> TokenStream { + let syn_item = parse_macro_input!(input as DeriveInput); + let (enum_name, name, content) = match syn_item.data { + syn::Data::Enum(enum_item) => ( + syn_item.ident.clone(), + Ident::new( + &format!("{}_VALUES", syn_item.ident.to_string().to_uppercase()), + syn_item.ident.span(), + ), + syn::parse_str::<syn::Expr>(&format!( + "&[ {} ]", + enum_item + .variants + .into_iter() + .map(|variant| format!("{}::{}", syn_item.ident, variant.ident)) + .collect::<Vec<_>>() + .join(", "), + )) + .expect("failed generation of enum's variant list"), + ), + _ => panic!("EnumVariantIter only works on Enums"), + }; + quote! { pub const #name : &[#enum_name] = #content; }.into() +} + +#[proc_macro_derive(EnumVariantFromStr)] +pub fn derive_enum_variant_from_str(input: TokenStream) -> TokenStream { + let syn_item = parse_macro_input!(input as DeriveInput); + let enum_item = match syn_item.data { + syn::Data::Enum(enum_item) => enum_item, + _ => panic!("EnumVariantFromStr only works on Enums"), + }; + let name = syn_item.ident; + let match_branches = enum_item + .variants + .into_iter() + .map(|variant| { + format!( + "{:?} => Ok({name}::{}),", + variant.ident.to_string().to_lowercase(), + variant.ident + ) + }) + .chain(Some("_ => Err(format!(\"unknown field '{{s}}'\")),".to_string())) + .collect::<Vec<_>>() + .join(""); + let match_branches = syn::parse_str::<syn::Expr>(&format!( + "match s.trim().to_lowercase().as_str() {{ {match_branches} }}" + )) + .expect("failed generation of enum's FromStr implementation"); + quote! { + impl std::str::FromStr for #name { + type Err = std::string::String; + fn from_str(s: &str) -> std::result::Result<Self, Self::Err> { + #match_branches + } + + } + } + .into() +} diff --git a/src/Rust/vvs_utils/Cargo.toml b/src/Rust/vvs_utils/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..b27c119fe14e52d7e91e34e794d9dcaf1f1cb131 --- /dev/null +++ b/src/Rust/vvs_utils/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "vvs_utils" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +description = "Utility crate for VVS" + +[dependencies] +thiserror.workspace = true +serde.workspace = true +log.workspace = true diff --git a/src/Rust/vvs_utils/src/angles.rs b/src/Rust/vvs_utils/src/angles.rs new file mode 100644 index 0000000000000000000000000000000000000000..53424bca73c2b29483bc6bcba1bb6678334b9af6 --- /dev/null +++ b/src/Rust/vvs_utils/src/angles.rs @@ -0,0 +1,17 @@ +use core::{f32::consts::PI as PI_32, f64::consts::PI as PI_64}; + +pub fn f64_randians_clamp(rad: f64) -> f64 { + rad % (2.0 * PI_64) +} + +pub fn f64_degres_clamp(rad: f64) -> f64 { + rad % 360.0 +} + +pub fn f32_randians_clamp(rad: f32) -> f32 { + rad % (2.0 * PI_32) +} + +pub fn f32_degres_clamp(rad: f32) -> f32 { + rad % 360.0 +} diff --git a/src/Rust/vvs_utils/src/assert.rs b/src/Rust/vvs_utils/src/assert.rs new file mode 100644 index 0000000000000000000000000000000000000000..2f34f04cd47f83eac41e4a55613b88aec263c874 --- /dev/null +++ b/src/Rust/vvs_utils/src/assert.rs @@ -0,0 +1,49 @@ +#[macro_export] +macro_rules! assert_eq_delta { + ($a: expr, $b: expr, $delta: literal) => {{ + let (a, b, delta) = ($a, $b, $delta); + let diff = (a - b); + if !((diff <= delta && diff >= 0.0) || (-diff <= delta && diff <= 0.0)) { + panic!( + "assertion failed: not delta-equal: `abs({a} - {b}) > {delta}` \n\tleft: {}\n\tright: {}\n\tdelta: {}", + stringify!($a), + stringify!($b), + stringify!($delta) + ); + } + }}; +} + +/// Test that something was [`Err`] and return the content of the +/// [`Result::Err`] variant. Panic on [`Ok`]. +#[macro_export] +macro_rules! assert_err { + ($x: expr) => { + match $x { + Ok(e) => panic!("'{}' was successfull and got '{e:?}'", stringify!($x)), + Err(e) => e, + } + }; +} + +/// Test that something was [`Ok`] and return the content of the [`Result::Ok`] +/// variant. Panic on [`Err`]. +#[macro_export] +macro_rules! assert_ok { + ($x: expr) => { + match $x { + Ok(x) => x, + Err(e) => panic!("failed '{}' with error '{e:?}'", stringify!($x)), + } + }; +} + +#[macro_export] +macro_rules! assert_some { + ($x: expr) => { + match $x { + Some(x) => x, + _ => panic!("failed '{}', expected [Some], got [None]", stringify!($x)), + } + }; +} diff --git a/src/Rust/vvs_utils/src/conds.rs b/src/Rust/vvs_utils/src/conds.rs new file mode 100644 index 0000000000000000000000000000000000000000..98256e0c5a19d2e900f59dc6b6f3c1c68f9c8969 --- /dev/null +++ b/src/Rust/vvs_utils/src/conds.rs @@ -0,0 +1,18 @@ +#[macro_export] +macro_rules! either { + ($cond: expr => $then: expr ; $else: expr) => {{ + if $cond { + $then + } else { + $else + } + }}; +} + +pub fn f64_epsilon_eq(a: f64, b: f64) -> bool { + (a - b).abs() <= 10E-10 +} + +pub fn f32_epsilon_eq(a: f32, b: f32) -> bool { + (a - b).abs() <= 10E-7 +} diff --git a/src/Rust/vvs_utils/src/file/lock.rs b/src/Rust/vvs_utils/src/file/lock.rs new file mode 100644 index 0000000000000000000000000000000000000000..4dfbdf3336da6f8e88110a5b8f065b8b21a8ed14 --- /dev/null +++ b/src/Rust/vvs_utils/src/file/lock.rs @@ -0,0 +1,84 @@ +use crate::xdg::*; +use std::{ + collections::hash_map::DefaultHasher, + fs::{File, OpenOptions}, + hash::{Hash, Hasher}, + io::Error as IoError, + path::Path, +}; +use thiserror::Error; + +/// An error can be comming from the IO thing or the resource could be already locked. +#[derive(Debug, Error)] +pub enum LockError { + #[error("{0}")] + IO(std::io::Error), + + #[error("resource is already locked")] + AlreadyLocked, + + #[error("{0}")] + XDG(#[from] XDGError), +} + +/// Result type for lock functions. +pub type LockResult<T> = Result<T, LockError>; + +/// Structure used to hold the lock file for a resource that can be hashed. +pub struct LockFile(File); + +impl LockFile { + fn handle_xdg_error(err: XDGError) -> LockError { + match err { + XDGError::ConfigIO(_, _, err) => Self::handle_io_error(err), + err => LockError::XDG(err), + } + } + + fn handle_io_error(err: IoError) -> LockError { + match err.kind() { + std::io::ErrorKind::AlreadyExists => LockError::AlreadyLocked, + _ => LockError::IO(err), + } + } + + fn create(app: impl AsRef<str>, file: String) -> LockResult<Self> { + match XDGFolder::RuntimeDir + .find(app, file, XDGFindBehaviour::NonExistingOnly) + .map_err(Self::handle_xdg_error)? + { + MaybeFolderList::Empty => unreachable!("failed to find the runtime dir..."), + MaybeFolderList::Folder(f) | MaybeFolderList::Many(f, _) => Ok(Self( + OpenOptions::new() + .create_new(true) + .open(f) + .map_err(Self::handle_io_error)?, + )), + } + } + + /// Create a lock file for another file. + pub fn for_file(app: impl AsRef<str>, path: impl AsRef<Path>) -> LockResult<Self> { + let mut s = DefaultHasher::new(); + path.as_ref().hash(&mut s); + Self::create(app, format!("{:#X}.path.lock", s.finish())) + } + + /// Create a lock file for an hashable ressource. + pub fn for_resource(app: impl AsRef<str>, rsc: impl Hash) -> LockResult<Self> { + let mut s = DefaultHasher::new(); + rsc.hash(&mut s); + Self::create(app, format!("{:#X}.rsc.lock", s.finish())) + } + + /// Create a lock file for an ID. + pub fn for_id(app: impl AsRef<str>, id: u64) -> LockResult<Self> { + Self::create(app, format!("{id:#X}.id.lock")) + } +} + +impl Drop for LockFile { + fn drop(&mut self) { + todo!("drop {:?}", self.0) + } +} diff --git a/src/Rust/vvs_utils/src/file/mod.rs b/src/Rust/vvs_utils/src/file/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..4529c8399854cb87684329c365350747de5c9331 --- /dev/null +++ b/src/Rust/vvs_utils/src/file/mod.rs @@ -0,0 +1,5 @@ +mod lock; +mod temp; + +pub use lock::*; +pub use temp::*; diff --git a/src/Rust/vvs_utils/src/file/temp.rs b/src/Rust/vvs_utils/src/file/temp.rs new file mode 100644 index 0000000000000000000000000000000000000000..63537b834459f8c4d73abea7ae3b9ff02d762563 --- /dev/null +++ b/src/Rust/vvs_utils/src/file/temp.rs @@ -0,0 +1,111 @@ +use crate::xdg::*; +use std::{ + fs::{File, OpenOptions}, + io::Error as IoError, + ops::{Deref, DerefMut}, + path::PathBuf, +}; +use thiserror::Error; + +/// An error can be comming from the IO thing or we have a collision with the temp file name. +#[derive(Debug, Error)] +pub enum TempError { + #[error("{0}")] + IO(#[from] std::io::Error), + + #[error("generated a duplicate temporary file")] + Duplicate, + + #[error("{0}")] + XDG(#[from] XDGError), + + #[error("failed to read env variable: {0}")] + NoEnvVar(&'static str), +} + +/// Result type for lock functions. +pub type TempResult<T> = Result<T, TempError>; + +/// The temporary file structure. +pub struct TempFile(File); + +impl Deref for TempFile { + type Target = File; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for TempFile { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl TempFile { + fn handle_xdg_error(err: XDGError) -> TempError { + match err { + XDGError::ConfigIO(_, _, err) => Self::handle_io_error(err), + err => TempError::XDG(err), + } + } + + fn handle_io_error(err: IoError) -> TempError { + match err.kind() { + std::io::ErrorKind::AlreadyExists => TempError::Duplicate, + _ => TempError::IO(err), + } + } + + #[cfg(unix)] + fn create(app: impl AsRef<str>, file: impl AsRef<str>) -> TempResult<PathBuf> { + let list = XDGFolder::RuntimeDir + .find(app, file, XDGFindBehaviour::NonExistingOnly) + .map_err(Self::handle_xdg_error)?; + match list { + MaybeFolderList::Empty => todo!(), + MaybeFolderList::Folder(path) | MaybeFolderList::Many(path, _) => Ok(path), + } + } + + #[cfg(not(unix))] + fn create(app: impl AsRef<str>, file: impl AsRef<str>) -> TempResult<PathBuf> { + use std::{env, io::ErrorKind as IoErrorKind}; + let folder = env::var("TEMP") + .map(PathBuf::from) + .or_else(|_| { + env::var("TMP").map(PathBuf::from).or_else(|_| { + env::var("USERPROFILE").map(|profile| PathBuf::from(profile).join("AppData/Local/Temp")) + }) + }) + .map_err(|_| TempError::NoEnvVar("TEMP, TMP, USERPROFILE"))?; + if !folder.is_dir() { + Err(IoError::new( + IoErrorKind::NotFound, + format!( + "folder doesn't exists or is not a directory: {}", + folder.to_string_lossy() + ), + ) + .into()) + } else { + let file = "toto"; + Ok(folder) + } + } + + /// Create a new temporary file somewhere. Note that the creation pattern is predictible and + /// not secure in any way. Moreover we depend on the fact that PID from dead processes are not + /// reused between reboots, and that temp folder are cleared at reboots... + pub fn new(app: impl AsRef<str>) -> TempResult<Self> { + let (pid, rand) = (std::process::id(), crate::rand()); + let file = OpenOptions::new() + .create_new(true) + .write(true) + .read(true) + .open(Self::create(app, format!("tmp.{pid}.{rand}"))?) + .map_err(Self::handle_io_error)?; + Ok(Self(file)) + } +} diff --git a/src/Rust/vvs_utils/src/lib.rs b/src/Rust/vvs_utils/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..9a351c5015a64094cb67947f3d13930905882e7f --- /dev/null +++ b/src/Rust/vvs_utils/src/lib.rs @@ -0,0 +1,17 @@ +mod angles; +mod assert; +mod conds; +mod minmax; +mod rand; + +pub mod file; +pub mod xdg; + +pub use angles::*; +pub use assert::*; +pub use conds::*; +pub use minmax::*; +pub use rand::*; + +#[cfg(test)] +mod tests; diff --git a/src/Rust/vvs_utils/src/minmax.rs b/src/Rust/vvs_utils/src/minmax.rs new file mode 100644 index 0000000000000000000000000000000000000000..70263bb9ffba5ce33e683e11a3c75dd3cac4c7d6 --- /dev/null +++ b/src/Rust/vvs_utils/src/minmax.rs @@ -0,0 +1,49 @@ +#[inline] +pub fn min<T: PartialOrd>(a: T, b: T) -> T { + crate::either!(a < b => a; b) +} + +#[inline] +pub fn max<T: PartialOrd>(a: T, b: T) -> T { + crate::either!(a > b => a; b) +} + +#[macro_export] +macro_rules! min { + ($x: expr) => { $x }; + ($x: expr, $($z: expr),+) => { ::core::cmp::min($x, $crate::min!($($z),*)) }; +} + +#[macro_export] +macro_rules! max { + ($x: expr) => { $x }; + ($x: expr, $($z: expr),+) => { ::core::cmp::max($x, $crate::max!($($z),*)) }; +} + +#[macro_export] +macro_rules! minmax { + ($($xs:expr),+) => {( + $crate::min!($($xs),+), + $crate::max!($($xs),+), + )}; +} + +#[macro_export] +macro_rules! min_partial { + ($x:expr) => { $x }; + ($x:expr, $($xs:expr),+) => { $crate::min($x, $crate::min_partial!( $($xs),+ )) }; +} + +#[macro_export] +macro_rules! max_partial { + ($x:expr) => { $x }; + ($x:expr, $($xs:expr),+) => { $crate::max($x, $crate::max_partial!( $($xs),+ )) }; +} + +#[macro_export] +macro_rules! minmax_partial { + ($($xs:expr),+) => {( + $crate::min_partial!($($xs),+), + $crate::max_partial!($($xs),+), + )}; +} diff --git a/src/Rust/vvs_utils/src/rand.rs b/src/Rust/vvs_utils/src/rand.rs new file mode 100644 index 0000000000000000000000000000000000000000..9833b2e57278368b56f5a114d89c2864f8da35c7 --- /dev/null +++ b/src/Rust/vvs_utils/src/rand.rs @@ -0,0 +1,88 @@ +//! Simple Mersenne Twister (MT19937-64) implementation. + +use crate::either; +use std::{cell::RefCell, sync::OnceLock}; + +/// Simple Mersenne Twister (MT19937-64) implementation. +struct MersenneTwister { + mt: [u64; MersenneTwister::W as usize], + index: usize, +} + +thread_local! { + /// We do our things here. + static MERSENNE: OnceLock<RefCell<MersenneTwister>> = OnceLock::new(); +} + +/// Get the seed for the thread. +fn get_seed() -> u64 { + f64::to_bits( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("time leap before the UNIX_EPOCH") + .as_secs_f64(), + ) +} + +impl MersenneTwister { + const W: u64 = u64::BITS as u64; + const N: u64 = 624; + const M: u64 = 397; + const R: u64 = 31; + + const A: u64 = 0xb5026f5aa96619e9; + const B: u64 = 0x71d67fffeda60000; + const C: u64 = 0xfff7eee000000000; + const D: u64 = 0x5555555555555555; + const S: u64 = 17; + const T: u64 = 37; + const U: u64 = 29; + const L: u64 = 43; + const F: u64 = 6364136223846793005; + + const LOWER_MASK: u64 = (1_u64 << MersenneTwister::R).overflowing_sub(1_u64).0; + const UPPER_MASK: u64 = !Self::LOWER_MASK; + + fn twist(&mut self) { + (0..Self::N).for_each(|i| { + let index = i as usize; + let index_next = (i + 1) as usize % Self::W as usize; + let x = (self.mt[index] & Self::UPPER_MASK) | (self.mt[index_next] & Self::LOWER_MASK); + self.mt[index] = + self.mt[((i + Self::M) % Self::N) as usize] ^ either!((x % 2) != 0 => (x >> 1) ^ Self::A; x >> 1); + }); + self.index = 0; + } + + fn extract_number(&mut self) -> u64 { + if self.index >= Self::N as usize { + self.twist(); + } + let y = self.mt[self.index]; + let y = y ^ ((y >> Self::U) & Self::D); + let y = y ^ ((y << Self::S) & Self::B); + let y = y ^ ((y << Self::T) & Self::C); + let y = y ^ (y >> Self::L); + self.index += 1; + y + } +} + +/// Get next pseudo random number with our homebrew mersenne twister. +pub fn rand() -> u64 { + MERSENNE.with(|mt| { + mt.get_or_init(|| { + let index = MersenneTwister::N as usize; + let mut mt = [0; MersenneTwister::W as usize]; + mt[0] = get_seed(); + (1..MersenneTwister::N).for_each(|i| { + let index = i as usize - 1; + let factor = (mt[index] ^ (MersenneTwister::F * (mt[index] >> (MersenneTwister::W - 2)))) + i; + mt[index] = MersenneTwister::F.overflowing_mul(factor).0; + }); + RefCell::new(MersenneTwister { mt, index }) + }) + .borrow_mut() + .extract_number() + }) +} diff --git a/src/Rust/vvs_utils/src/tests.rs b/src/Rust/vvs_utils/src/tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..9b40b25d9c6e9d073bf45150acbc522714fbc4c8 --- /dev/null +++ b/src/Rust/vvs_utils/src/tests.rs @@ -0,0 +1,7 @@ +use crate::*; + +/// Test whever we have what we expect with the floating modulo in Rust. +#[test] +fn test_floating_modulo() { + assert_eq_delta!(7.4 % 6.0, 1.4, 0.1); +} diff --git a/src/Rust/vvs_utils/src/xdg/config.rs b/src/Rust/vvs_utils/src/xdg/config.rs new file mode 100644 index 0000000000000000000000000000000000000000..499ba276644a8d59e198bb3740efd6640d6c4f87 --- /dev/null +++ b/src/Rust/vvs_utils/src/xdg/config.rs @@ -0,0 +1,387 @@ +//! Utilities to extend the [XDGFolder] thing with config files: take into account the +//! deserialization, merge of configs and others. + +use crate::xdg::{MaybeFolderList, XDGError, XDGFindBehaviour, XDGFolder}; +use serde::{Deserialize, Serialize}; +use std::rc::Rc; + +/// Search configurations in all config folders and merge them. +#[derive(Debug)] +pub struct XDGConfigMerged; + +/// Only get the config file with higher priority, which means searching in order: +/// 1. $XDG_CONFIG_HOME <- which has a default value. +/// 2. $XDG_CONFIG_DIRS <- which has a default value. +#[derive(Debug)] +pub struct XDGConfigFirst; + +/// Search configurations in all config folders and merge them. If the parsing for a configuration +/// file failed then log it and continue execution like normal, i.e. fail silently. +#[derive(Debug)] +pub struct XDGConfigMergedSilent; + +pub type DeserializeFunctionPtr<'a, Format> = + Rc<dyn Fn(String) -> Result<Format, Box<dyn std::error::Error>> + 'static>; + +/// We will write one impl block for merged searches. +mod private_merged { + use crate::xdg::*; + use serde::Deserialize; + use std::collections::VecDeque; + + /// Search with merging. + pub trait Sealed: Sized { + fn try_read<'a, Format>(config: &XDGConfig<'a, Format, Self>) -> Result<Format, XDGError> + where + Format: for<'de> Deserialize<'de> + Extend<Format>; + } + + impl Sealed for XDGConfigMergedSilent { + fn try_read<'a, Format>(config: &XDGConfig<'a, Format, Self>) -> Result<Format, XDGError> + where + Format: for<'de> Deserialize<'de> + Extend<Format>, + { + use XDGError::*; + let result = config.search_files()?.into_iter().filter_map(|file| { + let file = std::fs::read_to_string(file) + .map_err(|err| log::error!(target: "xdg", "{}", ConfigIO(config.app.to_string(), config.get_file().to_string(), err))) + .ok()?; + config.deserialize.as_ref()(file) + .map_err(|err| log::error!(target: "xdg", "{}", DeserializeError(config.app.to_string(), config.get_file().to_string(), err))) + .ok() + }); + + let mut result: VecDeque<Format> = result.collect(); + let mut first = result + .pop_front() + .ok_or(ConfigNotFound(config.app.to_string(), config.get_file().to_string()))?; + first.extend(result); + Ok(first) + } + } + + impl Sealed for XDGConfigMerged { + fn try_read<'a, Format>(config: &XDGConfig<'a, Format, Self>) -> Result<Format, XDGError> + where + Format: for<'de> Deserialize<'de> + Extend<Format>, + { + use XDGError::*; + let mut result: VecDeque<Format> = Default::default(); + for file in config.search_files()?.into_iter() { + let file = std::fs::read_to_string(file) + .map_err(|err| ConfigIO(config.app.to_string(), config.get_file().to_string(), err))?; + let file = config.deserialize.as_ref()(file) + .map_err(|err| DeserializeError(config.app.to_string(), config.get_file().to_string(), err))?; + result.push_back(file); + } + let mut first = result + .pop_front() + .ok_or(ConfigNotFound(config.app.to_string(), config.get_file().to_string()))?; + first.extend(result); + Ok(first) + } + } +} + +/// A struct to contain informations about the app we are querying the config for. The behaviour of +/// the search can be changed. +/// --- +/// On the `Format` generic parameter: +/// - If the `Format` parameter implements [Serialize], then you can write or provide a default +/// that will be written to disk (or fail silently...) +/// - If the `Format` parameter implements [Serialize] and [Default], you can depend on the +/// default value if an error occured in the deserialization. +/// --- +/// On the `Merged` generic parameter: +/// - If the `Merged` parameter is [XDGConfigFirst], then only the file with the higher priority +/// is parsed. +/// - If the `Merged` parameter is [XDGConfigMerged] and `Format` implements [Extend] on itself, +/// then all the found files are parsed and then merged, an error is returned on the first +/// failure of the deserialization process. +pub struct XDGConfig<'a, Format, Merged> +where + Format: for<'de> Deserialize<'de>, +{ + /// The application name. + app: &'a str, + + /// The file name. If not present will be defaulted to `config` at resolution time. + file: Option<&'a str>, + + /// Deserializer function. + deserialize: DeserializeFunctionPtr<'a, Format>, + + /// Stores the format for deserialization. + _type: std::marker::PhantomData<Format>, + + /// Stores the format for deserialization. + _merged: std::marker::PhantomData<Merged>, +} + +impl<'a, Format, Merged> std::fmt::Debug for XDGConfig<'a, Format, Merged> +where + Format: for<'de> Deserialize<'de>, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("XDGConfig") + .field("app", &self.app) + .field("file", &self.get_file()) + .field("merged", &self._merged) + .finish() + } +} + +impl<'a, Format, Merged> XDGConfig<'a, Format, Merged> +where + Format: for<'de> Deserialize<'de>, +{ + /// By default we say that the default config file is ${FOLDER}/${APP}/config like many + /// applications do. + const DEFAULT_FILE: &'static str = "config"; + + /// Create a new [XDGConfig] helper. + pub fn new( + app: &'a str, + deserialize: impl Fn(String) -> Result<Format, Box<dyn std::error::Error>> + 'static, + ) -> Self { + Self { + app, + deserialize: Rc::new(deserialize), + file: Default::default(), + _type: Default::default(), + _merged: Default::default(), + } + } + + /// Change the file to resolve. + pub fn file(&mut self, file: &'a str) -> &mut Self { + self.file = Some(file); + self + } + + /// Get the name of the config file that we will try to read. Returns the default if not set by + /// the user. + pub fn get_file(&self) -> &str { + match self.file.as_ref() { + Some(file) => file, + None => Self::DEFAULT_FILE, + } + } + + /// Search all config files in all locations... + fn search_files(&self) -> Result<MaybeFolderList, XDGError> { + XDGFolder::ConfigDirs.find(self.app, self.get_file(), XDGFindBehaviour::ExistingOnly) + } + + /// Prepare config folders. + pub fn prepare_folder(&self) -> impl IntoIterator<Item = <MaybeFolderList as IntoIterator>::Item> { + XDGFolder::ConfigDirs.prepare_folder() + } +} + +impl<'a, Format> XDGConfig<'a, Format, XDGConfigFirst> +where + Format: for<'de> Deserialize<'de>, +{ + /// Try to read the config file and deserialize it. + pub fn try_read(&self) -> Result<Format, XDGError> { + let Some(file) = self.search_files()?.into_first() else { + return Err(XDGError::ConfigNotFound( + self.app.to_string(), + self.get_file().to_string(), + )); + }; + let file = std::fs::read_to_string(file) + .map_err(|err| XDGError::ConfigIO(self.app.to_string(), self.get_file().to_string(), err))?; + self.deserialize.as_ref()(file) + .map_err(|err| XDGError::DeserializeError(self.app.to_string(), self.get_file().to_string(), err)) + } +} + +impl<'a, Format> XDGConfig<'a, Format, XDGConfigFirst> +where + Format: for<'de> Deserialize<'de> + Serialize, +{ + /// Try to read the config file and deserialize it. If an error is encountred at any point, log + /// it and return the provided default. If needed the default value is written on the disk, + /// note that this operation may fail silently. + pub fn read_or( + &self, + serialize: impl FnOnce(&Format) -> Result<String, Box<dyn std::error::Error>>, + default: Format, + ) -> Format { + self.try_read().unwrap_or_else(|err| { + log::error!(target: "xdg", "read error, return default value to user: {err}"); + self.write_silent(serialize, default) + }) + } + + /// Try to read the config file and deserialize it. If an error is encountred at any point, log + /// it and return the provided default. If needed the default value is written on the disk, + /// note that this operation may fail silently. + pub fn read_or_else( + &self, + serialize: impl FnOnce(&Format) -> Result<String, Box<dyn std::error::Error>>, + cb: impl FnOnce() -> Format, + ) -> Format { + self.try_read().unwrap_or_else(|err| { + log::error!(target: "xdg", "read error, return default value to user: {err}"); + self.write_silent(serialize, cb()) + }) + } + + /// Write a value to the default config file location, the one with the higher priority + /// (usually the user's one) + fn write( + &self, + serialize: impl FnOnce(&Format) -> Result<String, Box<dyn std::error::Error>>, + value: &Format, + ) -> Result<(), XDGError> { + let content = serialize(value) + .map_err(|err| XDGError::SerializeError(self.app.to_string(), self.get_file().to_string(), err))?; + let Some(path) = XDGFolder::ConfigDirs + .find(self.app, self.get_file(), XDGFindBehaviour::FirstOrCreate)? + .into_first() + else { + unreachable!( + "the user must have at least one location to place a config file, permission or fs quota problem?" + ) + }; + + match std::fs::create_dir_all(path.parent().ok_or(XDGError::ConfigFileHasNoParentFolder( + self.app.to_string(), + self.get_file().to_string(), + ))?) { + Err(err) if !matches!(err.kind(), std::io::ErrorKind::AlreadyExists) => { + return Err(XDGError::ConfigIO( + self.app.to_string(), + self.get_file().to_string(), + err, + )) + } + _ => {} + } + + std::fs::write(path, content) + .map_err(|err| XDGError::ConfigIO(self.app.to_string(), self.get_file().to_string(), err))?; + + Ok(()) + } + + /// Same as [XDGConfig::write] but log any error and fail silently, returning the value that + /// was attempted to be written to disk. + fn write_silent( + &self, + serialize: impl FnOnce(&Format) -> Result<String, Box<dyn std::error::Error>>, + value: Format, + ) -> Format { + if let Err(err) = self.write(serialize, &value) { + log::error!(target: "xdg", "failed to write default with err: {err}") + } + value + } +} + +impl<'a, Format> XDGConfig<'a, Format, XDGConfigFirst> +where + Format: for<'de> Deserialize<'de> + Serialize + Default, +{ + /// Try to read the config file and deserialize it. If an error is encountred at any point, log + /// it and return the default. If needed the default value is written on the disk, note that + /// this operation may fail silently. + pub fn read_or_default( + &self, + serialize: impl FnOnce(&Format) -> Result<String, Box<dyn std::error::Error>>, + ) -> Format { + self.try_read().unwrap_or_else(|err| { + log::error!(target: "xdg", "read error, return default value to user: {err}"); + self.write_silent(serialize, Default::default()) + }) + } +} + +// XDGConfigMerged + XDGConfigMergedSilent + +impl<'a, Format, Merged> XDGConfig<'a, Format, Merged> +where + Format: for<'de> Deserialize<'de> + Extend<Format>, + Merged: private_merged::Sealed, +{ + /// When trying to read or write the default, we write the file with the same logic as the + /// [XDGConfigFirst] variant, we add this function to reduce the code to write for the write + /// logic... + fn to_config_first(&self) -> XDGConfig<Format, XDGConfigFirst> { + XDGConfig::<Format, XDGConfigFirst> { + app: self.app, + file: self.file, + deserialize: self.deserialize.clone(), + _type: std::marker::PhantomData, + _merged: std::marker::PhantomData, + } + } + + /// Try to read the config files and deserialize them. If one config file failed to be + /// deserialized then returns an error if the merged was not silent, or just ignore the file if + /// the merge was silent. If no config file where found this is an error. All the files are + /// merged, this operation can't fail, implementations must fail silently or have sane defaults + /// and prefer files with higher priority. + pub fn try_read(&self) -> Result<Format, XDGError> { + Merged::try_read(self) + } +} + +impl<'a, Format, Merged> XDGConfig<'a, Format, Merged> +where + Format: for<'de> Deserialize<'de> + Extend<Format> + Serialize, + Merged: private_merged::Sealed, +{ + /// Try to read the config files and deserialize them. If an error is encountred at any point, + /// log it and return the provided default if the merge was not silent, skip the file if it was + /// silent. If needed the default value is written on the disk, note that this operation may + /// fail silently. + pub fn read_or( + &self, + serialize: impl FnOnce(&Format) -> Result<String, Box<dyn std::error::Error>>, + default: Format, + ) -> Format { + self.try_read().unwrap_or_else(|err| { + log::error!(target: "xdg", "read error, return default value to user: {err}"); + self.to_config_first().write_silent(serialize, default) + }) + } + + /// Try to read the config files and deserialize them. If an error is encountred at any point, + /// log it and return the provided default if the merge was not silent, skip the file if it was + /// silent. If needed the default value is written on the disk, note that this operation may + /// fail silently. + pub fn read_or_else( + &self, + serialize: impl FnOnce(&Format) -> Result<String, Box<dyn std::error::Error>>, + cb: impl FnOnce() -> Format, + ) -> Format { + self.try_read().unwrap_or_else(|err| { + log::error!(target: "xdg", "read error, return default value to user: {err}"); + self.to_config_first().write_silent(serialize, cb()) + }) + } +} + +impl<'a, Format, Merged> XDGConfig<'a, Format, Merged> +where + Format: for<'de> Deserialize<'de> + Extend<Format> + Serialize + Default, + Merged: private_merged::Sealed, +{ + /// Try to read the config files and deserialize them. If an error is encountred at any point, + /// log it and return the provided default if the merge was not silent, skip the file if it was + /// silent. If needed the default value is written on the disk, note that this operation may + /// fail silently. + pub fn read_or_default( + &self, + serialize: impl FnOnce(&Format) -> Result<String, Box<dyn std::error::Error>>, + ) -> Format { + self.try_read().unwrap_or_else(|err| { + log::error!(target: "xdg", "read error, return default value to user: {err}"); + self.to_config_first().write_silent(serialize, Default::default()) + }) + } +} diff --git a/src/Rust/vvs_utils/src/xdg/folders.rs b/src/Rust/vvs_utils/src/xdg/folders.rs new file mode 100644 index 0000000000000000000000000000000000000000..4552b2c6ec1009c180c1fdc609e9166bfb809ba6 --- /dev/null +++ b/src/Rust/vvs_utils/src/xdg/folders.rs @@ -0,0 +1,274 @@ +//! The main provider for resolving files with the XDG specification + some utilities. + +use crate::{ + either, + xdg::{MaybeFolderList, XDGError, XDGFindBehaviour}, +}; +use std::{io::ErrorKind as IoErrorKind, path::PathBuf}; + +/// The type of folder we want. Here are some remarks from the specification about file resolution: +/// +/// - If, when attempting to write a file, the destination directory is non-existent an attempt +/// should be made to create it with permission 0700. If the destination directory exists already +/// the permissions should not be changed. The application should be prepared to handle the case +/// where the file could not be written, either because the directory was non-existent and could +/// not be created, or for any other reason. In such case it may choose to present an error +/// message to the user. +/// - When attempting to read a file, if for any reason a file in a certain directory is +/// unaccessible, e.g. because the directory is non-existent, the file is non-existent or the +/// user is not authorized to open the file, then the processing of the file in that directory +/// should be skipped. If due to this a required file could not be found at all, the application +/// may choose to present an error message to the user. +/// - A specification that refers to $XDG_DATA_DIRS or $XDG_CONFIG_DIRS should define what the +/// behaviour must be when a file is located under multiple base directories. It could, for +/// example, define that only the file under the most important base directory should be used or, +/// as another example, it could define rules for merging the information from the different +/// files. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum XDGFolder { + /// $XDG_DATA_HOME defines the base directory relative to which user-specific data files should + /// be stored. If $XDG_DATA_HOME is either not set or empty, a default equal to + /// $HOME/.local/share should be used. + DataHome, + + /// $XDG_CONFIG_HOME defines the base directory relative to which user-specific configuration + /// files should be stored. If $XDG_CONFIG_HOME is either not set or empty, a default equal to + /// $HOME/.config should be used. + ConfigHome, + + /// $XDG_STATE_HOME defines the base directory relative to which user-specific state files + /// should be stored. If $XDG_STATE_HOME is either not set or empty, a default equal to + /// $HOME/.local/state should be used. + /// + /// The $XDG_STATE_HOME contains state data that should persist between (application) restarts, + /// but that is not important or portable enough to the user that it should be stored in + /// $XDG_DATA_HOME. It may contain: + /// - actions history (logs, history, recently used files, …) + /// - current state of the application that can be reused on a restart (view, layout, open + /// files, undo history, …) + StateHome, + + /// $XDG_DATA_DIRS defines the preference-ordered set of base directories to search for data + /// files in addition to the $XDG_DATA_HOME base directory. The directories in $XDG_DATA_DIRS + /// should be seperated with a colon ':'. + /// + /// If $XDG_DATA_DIRS is either not set or empty, a value equal to + /// /usr/local/share/:/usr/share/ should be used. + DataDirs, + + /// $XDG_CONFIG_DIRS defines the preference-ordered set of base directories to search for + /// configuration files in addition to the $XDG_CONFIG_HOME base directory. The directories in + /// $XDG_CONFIG_DIRS should be seperated with a colon ':'. + /// + /// If $XDG_CONFIG_DIRS is either not set or empty, a value equal to /etc/xdg should be used. + ConfigDirs, + + /// $XDG_CACHE_HOME defines the base directory relative to which user-specific non-essential + /// data files should be stored. If $XDG_CACHE_HOME is either not set or empty, a default equal + /// to $HOME/.cache should be used. + CacheHome, + + /// $XDG_RUNTIME_DIR defines the base directory relative to which user-specific non-essential + /// runtime files and other file objects (such as sockets, named pipes, ...) should be stored. + /// The directory MUST be owned by the user, and he MUST be the only one having read and write + /// access to it. Its Unix access mode MUST be 0700. + /// + /// The lifetime of the directory MUST be bound to the user being logged in. It MUST be created + /// when the user first logs in and if the user fully logs out the directory MUST be removed. + /// If the user logs in more than once he should get pointed to the same directory, and it is + /// mandatory that the directory continues to exist from his first login to his last logout on + /// the system, and not removed in between. Files in the directory MUST not survive reboot or a + /// full logout/login cycle. + /// + /// The directory MUST be on a local file system and not shared with any other system. The + /// directory MUST by fully-featured by the standards of the operating system. More + /// specifically, on Unix-like operating systems AF_UNIX sockets, symbolic links, hard links, + /// proper permissions, file locking, sparse files, memory mapping, file change notifications, + /// a reliable hard link count must be supported, and no restrictions on the file name + /// character set should be imposed. Files in this directory MAY be subjected to periodic + /// clean-up. To ensure that your files are not removed, they should have their access time + /// timestamp modified at least once every 6 hours of monotonic time or the 'sticky' bit should + /// be set on the file. + /// + /// If $XDG_RUNTIME_DIR is not set applications should fall back to a replacement directory + /// with similar capabilities and print a warning message. Applications should use this + /// directory for communication and synchronization purposes and should not place larger files + /// in it, since it might reside in runtime memory and cannot necessarily be swapped out to + /// disk. + RuntimeDir, +} + +impl XDGFolder { + /// The list separator. + const SEPARATOR: char = ':'; + + /// Get the folders or folder list that the user asked for. If the env variable is not present, + /// falls back to the default value. + /// + /// Note that the folder or folders doesn't exist, this function won't try to create them. + pub fn get_folder(&self) -> MaybeFolderList { + #[cfg(unix)] + mod variables { + pub fn data_dirs(_: impl AsRef<std::path::Path>) -> super::MaybeFolderList { + super::MaybeFolderList::from_iter(["/usr/local/share", "/usr/share"]) + } + pub fn config_dirs(_: impl AsRef<std::path::Path>) -> super::MaybeFolderList { + super::MaybeFolderList::from("/etc/xdg") + } + } + #[cfg(not(unix))] + mod variables { + pub fn data_dirs(_: impl AsRef<std::path::Path>) -> super::MaybeFolderList { + super::MaybeFolderList::Folder(home.join(".local/share")) + } + pub fn config_dirs(_: impl AsRef<std::path::Path>) -> super::MaybeFolderList { + super::MaybeFolderList::Folder(home.join(".config")) + } + } + + let home = crate::xdg::home_folder(); + match std::env::var(self.env_var_name()) { + Ok(folder_list) if self.is_list() => MaybeFolderList::from_str_list(&folder_list, Self::SEPARATOR), + Ok(folder) => MaybeFolderList::Folder(PathBuf::from(folder)), + Err(_) => match self { + XDGFolder::DataHome => MaybeFolderList::Folder(home.join(".local/share")), + XDGFolder::ConfigHome => MaybeFolderList::Folder(home.join(".config")), + XDGFolder::StateHome => MaybeFolderList::Folder(home.join(".local/state")), + XDGFolder::CacheHome => MaybeFolderList::Folder(home.join(".cache")), + XDGFolder::RuntimeDir => panic!("failed to find the env variable $XDG_RUNTIME_DIR"), + XDGFolder::DataDirs => variables::data_dirs(&home), + XDGFolder::ConfigDirs => variables::config_dirs(&home), + }, + } + } + + /// Get the folders list of what you asked for, but try to create them and only return existing + /// ones. The thing returned is a thing that can be iterate over to facilitate chaining. + pub fn prepare_folder(&self) -> impl IntoIterator<Item = <MaybeFolderList as IntoIterator>::Item> { + self.get_folder().into_iter().filter_map(|folder| { + if folder.is_dir() { + Some(folder) + } else { + std::fs::create_dir_all(&folder) + .map(|()| folder) + .map_err(|err| log::error!(target: "xdg", "{err}")) + .ok() + } + }) + } + + /// Get the file associated with the said application in the specified folders. An option can + /// be passed to specify behaviour in the search or file/folder creation processes. + pub fn find( + &self, + app: impl AsRef<str>, + file: impl AsRef<str>, + opt: XDGFindBehaviour, + ) -> Result<MaybeFolderList, XDGError> { + enum FindState { + CanCreate(PathBuf), + Exists(PathBuf), + } + impl<'a> std::iter::Extend<&'a FindState> for MaybeFolderList { + fn extend<T: IntoIterator<Item = &'a FindState>>(&mut self, iter: T) { + self.extend(iter.into_iter().map(|m| match m { + FindState::CanCreate(path) | FindState::Exists(path) => path.clone(), + })); + } + } + + // DataHome is more important than DataDirs in the search process... Idem for the Config + // env var family... + let matches: Vec<_> = match self { + XDGFolder::DataDirs => XDGFolder::DataHome.get_folder(), + XDGFolder::ConfigDirs => XDGFolder::ConfigHome.get_folder(), + _ => MaybeFolderList::Empty, + } + .into_iter() + .chain(self.get_folder()) + .flat_map(|path| { + let path = path.join(app.as_ref()); + if let Err(err) = std::fs::create_dir_all(&path) { + if !matches!(err.kind(), IoErrorKind::PermissionDenied | IoErrorKind::AlreadyExists) { + log::error!(target: "xdg", + "failed to create app folder for {{{self}}}/{}/{} as {}", + app.as_ref(), file.as_ref(), path.to_string_lossy() + ); + return None; + } + } + let path = path.join(file.as_ref()); + Some(either!(path.exists() => FindState::Exists(path); FindState::CanCreate(path))) + }) + .collect(); + + // For now, default behaviour is to return the first file found or try to create it, the + // first file created will be returned (if we can't create the file in system folders it's + // not an error...) + + let (mut exists, mut can_create): (MaybeFolderList, MaybeFolderList) = + matches.iter().partition(|m| matches!(m, FindState::Exists(_))); + use XDGFindBehaviour::*; + match (exists.is_some(), can_create.is_some()) { + // [XDGFindBehaviour::FirstOrCreate] case... + (true, false) if opt == FirstOrCreate => Ok(exists.into_first().into_iter().collect()), + (false, true) if opt == FirstOrCreate => Ok(can_create.into_first().into_iter().collect()), + + // Should we return the one list that is not empty? + (true, false) if matches!(opt, AllFiles | ExistingOnly) => Ok(exists), + (false, true) if matches!(opt, AllFiles | NonExistingOnly) => Ok(can_create), + + // Complicated case + (true, true) => match opt { + AllFiles => { + exists.append(&mut can_create); + Ok(exists) + } + + FirstOrCreate => Ok(exists.into_first().into_iter().collect()), + ExistingOnly => Ok(exists), + NonExistingOnly => Ok(can_create), + }, + + // The list that we want is empty, or all lists are empty, returns nothing -> should it + // really be an error? + _ => Err(XDGError::NotFound( + *self, + app.as_ref().to_string(), + file.as_ref().to_string(), + )), + } + } + + /// Is this variable a folder list? + pub fn is_list(&self) -> bool { + use XDGFolder::*; + matches!(self, DataDirs | ConfigDirs) + } + + /// Get the env variable name associated with the folder or folder list. + pub fn env_var_name(&self) -> &str { + self.as_ref() + } +} + +impl AsRef<str> for XDGFolder { + fn as_ref(&self) -> &str { + match self { + XDGFolder::DataHome => "XDG_DATA_HOME", + XDGFolder::StateHome => "XDG_STATE_HOME", + XDGFolder::CacheHome => "XDG_CACHE_HOME", + XDGFolder::ConfigHome => "XDG_CONFIG_HOME", + + XDGFolder::DataDirs => "XDG_DATA_DIRS", + XDGFolder::ConfigDirs => "XDG_CONFIG_DIRS", + XDGFolder::RuntimeDir => "XDG_RUNTIME_DIR", + } + } +} + +impl std::fmt::Display for XDGFolder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "${}", self.as_ref()) + } +} diff --git a/src/Rust/vvs_utils/src/xdg/mod.rs b/src/Rust/vvs_utils/src/xdg/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..bc1e04b27c284e24c064a55742a0f34d74008cec --- /dev/null +++ b/src/Rust/vvs_utils/src/xdg/mod.rs @@ -0,0 +1,47 @@ +//! Utility functions to follow the freedesktop specifications on config folders and such: +//! https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html + +#[cfg(test)] +mod tests; + +mod config; +mod folders; +mod options; +mod paths; + +pub use config::*; +pub use folders::*; +pub use options::*; +pub use paths::*; + +use std::{io::Error as IoError, path::PathBuf}; +use thiserror::Error; + +/// Get the user's home folder. Panics if the env variable was not found or it can't be +/// canonicalized. +pub fn home_folder() -> PathBuf { + PathBuf::from(std::env::var("HOME").expect("failed to get the $HOME env variable")) + .canonicalize() + .expect("failed to canonicalize the $HOME folder path") +} + +#[derive(Debug, Error)] +pub enum XDGError { + #[error("failed to find file {2} for application {1} with folder family {0}")] + NotFound(XDGFolder, String, String), + + #[error("failed to find config file {1} for application {0}")] + ConfigNotFound(String, String), + + #[error("failed to find parent folder of config file {1} for application {0}")] + ConfigFileHasNoParentFolder(String, String), + + #[error("io error for config file {1} for application {0}: {2}")] + ConfigIO(String, String, IoError), + + #[error("deserialization failed on file {1} for application {0} with: {2}")] + DeserializeError(String, String, Box<dyn std::error::Error>), + + #[error("serialization failed on file {1} for application {0} with: {2}")] + SerializeError(String, String, Box<dyn std::error::Error>), +} diff --git a/src/Rust/vvs_utils/src/xdg/options.rs b/src/Rust/vvs_utils/src/xdg/options.rs new file mode 100644 index 0000000000000000000000000000000000000000..3985de17e9e1be548a0cb8cecb9a8503eb473422 --- /dev/null +++ b/src/Rust/vvs_utils/src/xdg/options.rs @@ -0,0 +1,24 @@ +//! Options that can be passed to some functions to control the behaviour. + +/// Control the behaviour of the [XDGFolder::find] function on conflicts. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum XDGFindBehaviour { + /// Returns only the files that already exists. If no file is found, returns the file with the + /// post priority that can be created, usually where the user want this file to exists. If you + /// don't want to merge config/data files/folders this can be a sane default. + #[default] + FirstOrCreate, + + /// Returns only the files that already exists. If you want to merge config/data files/folders + /// this can be a sane default. + ExistingOnly, + + /// Only returns files that don't already exists and may be created. Note that even if a file + /// can be created by the user, it won't necessarily means that you will be able to, keep in + /// mind that race conditions may occur here. + NonExistingOnly, + + /// Returns all files, the ones that exist and the ones that may be created. The user will need + /// to handle the returned files as he sees fit. + AllFiles, +} diff --git a/src/Rust/vvs_utils/src/xdg/paths.rs b/src/Rust/vvs_utils/src/xdg/paths.rs new file mode 100644 index 0000000000000000000000000000000000000000..a99eb333ec554708af14902c402072946d0e26e8 --- /dev/null +++ b/src/Rust/vvs_utils/src/xdg/paths.rs @@ -0,0 +1,211 @@ +//! A PathBuf container as a sum type. + +use std::{ + mem, + path::{Path, PathBuf}, +}; + +/// An enum to store maybe multiple folders, or none, with utility functions. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub enum MaybeFolderList { + /// No folder. + #[default] + Empty, + + /// Only one folder. + Folder(PathBuf), + + /// Many folders. + Many(PathBuf, Vec<PathBuf>), +} + +/// An interator over [MaybeFolderList]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MaybeFolderListIterator<'a> { + next: Option<&'a PathBuf>, + list: &'a [PathBuf], +} + +impl MaybeFolderList { + /// Is there no folder? + pub fn is_none(&self) -> bool { + matches!(self, MaybeFolderList::Empty) + } + + /// Is there folders? + pub fn is_some(&self) -> bool { + !self.is_none() + } + + /// Get the first folder, or the last folder. If no folder is present just returns [None]. + pub fn first(&self) -> Option<&Path> { + use MaybeFolderList::*; + match self { + Empty => None, + Folder(f) | Many(f, _) => Some(f.as_ref()), + } + } + + /// Append another [MaybeFolderList] at the end of a [MaybeFolderList]. Empties the other + /// variable and try to reuse memory. + pub fn append(&mut self, other: &mut MaybeFolderList) { + use MaybeFolderList::*; + + match (self, other) { + (this @ Empty, other) => match other { + Empty => {} + Folder(f1) => { + *this = Folder(mem::take(f1)); + *other = Empty; + } + Many(f1, fs) => { + *this = Many(mem::take(f1), mem::take(fs)); + *other = Empty; + } + }, + + (_, Empty) => {} + + (Many(_, fs), other @ Folder(_)) => { + let Folder(f2) = other else { unreachable!() }; + fs.push(mem::take(f2)); + *other = Empty; + } + (Many(_, fs), other @ Many(_, _)) => { + let Many(f2, ref mut fs2) = other else { unreachable!() }; + fs.reserve(fs2.len() + 1); + fs.push(mem::take(f2)); + fs.append(fs2); + *other = Empty; + } + + (this @ Folder(_), other) => { + let Folder(f1) = this else { unreachable!() }; + match other { + Empty => unreachable!(), + Folder(f2) => *this = Many(mem::take(f1), vec![mem::take(f2)]), + Many(f2, ref mut fs) => { + fs.insert(0, mem::take(f2)); + *this = Many(mem::take(f1), mem::take(fs)); + } + } + *other = Empty; + } + } + } + + /// Get the first folder, or the last folder. If no folder is present just returns [None]. The + /// difference from the [MaybeFolderList::first] function is that this function consumes the + /// [MaybeFolderList]. + pub fn into_first(self) -> Option<PathBuf> { + use MaybeFolderList::*; + match self { + Empty => None, + Folder(f) | Many(f, _) => Some(f), + } + } + + /// Get an iterator over the contained folders. + pub fn iter(&self) -> MaybeFolderListIterator { + let (next, list): (_, &[_]) = match self { + MaybeFolderList::Empty => (None, &[]), + MaybeFolderList::Folder(f) => (Some(f), &[]), + MaybeFolderList::Many(f, fs) => (Some(f), &fs[..]), + }; + MaybeFolderListIterator { next, list } + } + + /// Create a list of folders from a description string and a separator. + pub(super) fn from_str_list(list: &str, separator: char) -> Self { + let mut folders = list.split(separator); + match folders.next() { + None => MaybeFolderList::Empty, + Some(first) => MaybeFolderList::Many(PathBuf::from(first), folders.map(PathBuf::from).collect()), + } + } +} + +impl std::iter::Extend<PathBuf> for MaybeFolderList { + fn extend<T: IntoIterator<Item = PathBuf>>(&mut self, iter: T) { + use MaybeFolderList::*; + let mut iter = iter.into_iter(); + match self { + Empty => { + if let Some(next) = iter.next() { + *self = Many(next, iter.collect()); + } + } + Folder(f1) => *self = Many(mem::take(f1), iter.collect()), + Many(_, fs) => fs.extend(iter), + } + } +} + +impl<'a> std::iter::Extend<&'a MaybeFolderList> for MaybeFolderList { + fn extend<T: IntoIterator<Item = &'a MaybeFolderList>>(&mut self, iter: T) { + use MaybeFolderList::*; + let other = iter.into_iter().flat_map(|iter| iter.iter()); + match self { + Empty => *self = other.collect(), + Folder(f1) => *self = Many(mem::take(f1), other.cloned().collect()), + Many(_, ref mut fs) => fs.extend(other.cloned()), + } + } +} + +impl<'a, S> From<S> for MaybeFolderList +where + S: AsRef<str> + 'a, +{ + fn from(value: S) -> Self { + MaybeFolderList::Folder(PathBuf::from(value.as_ref())) + } +} + +impl<'a, P> FromIterator<P> for MaybeFolderList +where + P: AsRef<Path> + 'a, +{ + fn from_iter<T: IntoIterator<Item = P>>(iter: T) -> Self { + let mut folders = iter.into_iter(); + match folders.next() { + None => MaybeFolderList::Empty, + Some(first) => MaybeFolderList::Many( + first.as_ref().to_path_buf(), + folders.map(|p| p.as_ref().to_path_buf()).collect(), + ), + } + } +} + +impl IntoIterator for MaybeFolderList { + type Item = PathBuf; + type IntoIter = <Vec<PathBuf> as IntoIterator>::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + match self { + MaybeFolderList::Empty => vec![].into_iter(), + MaybeFolderList::Folder(f) => vec![f].into_iter(), + MaybeFolderList::Many(f, mut fs) => { + fs.insert(0, f); + fs.into_iter() + } + } + } +} + +impl<'a> Iterator for MaybeFolderListIterator<'a> { + type Item = &'a PathBuf; + + fn next(&mut self) -> Option<Self::Item> { + let next = self.next?; + match self.list.split_first() { + Some((next, list)) => { + self.next = Some(next); + self.list = list; + } + None => self.next = None, + } + Some(next) + } +} diff --git a/src/Rust/vvs_utils/src/xdg/tests.rs b/src/Rust/vvs_utils/src/xdg/tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/src/Rust/vvs_utils/src/xdg/tests.rs @@ -0,0 +1 @@ +