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 @@
+